From 3254b0a2e71dc1cb3a3d3ec0c8b6b82ed1f3b0c0 Mon Sep 17 00:00:00 2001 From: fin-sa Date: Sun, 28 Jun 2026 15:47:19 -0400 Subject: [PATCH 1/3] Set Codex harness reasoning default to medium --- harness/codex/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harness/codex/config.toml b/harness/codex/config.toml index d5a25fe67..9598f43e3 100644 --- a/harness/codex/config.toml +++ b/harness/codex/config.toml @@ -1,5 +1,5 @@ model = "gpt-5.5" -model_reasoning_effort = "low" +model_reasoning_effort = "medium" personality = "pragmatic" model_verbosity = "low" service_tier = "fast" From dda196887e711b582344a4563250ddcd20d50fa2 Mon Sep 17 00:00:00 2001 From: fin-sa Date: Sun, 28 Jun 2026 16:05:22 -0400 Subject: [PATCH 2/3] Add Slack thread steering commands --- services/slackbotv2/src/index.ts | 356 +++++++++++++++++- services/slackbotv2/src/types.ts | 7 + .../slackbotv2/test/chat-sdk-emulate.test.ts | 117 ++++++ 3 files changed, 477 insertions(+), 3 deletions(-) diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index c69fca354..2cab9514b 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -1,5 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks' -import { randomUUID } from 'node:crypto' +import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto' import { Hono, type Context } from 'hono' import { Chat, @@ -48,6 +48,7 @@ import type { SlackbotV2Options, SlackbotV2RenderObligation, SlackbotV2RendererSource, + SlackbotV2SteeringDefaults, SlackbotV2ThreadState, SlackbotV2Trace } from './types' @@ -92,6 +93,22 @@ type SlackAssistantAdapter = { setAssistantTitle?(channelId: string, threadTs: string, title: string): Promise } +type SlackCommandPayload = { + channelId: string + command: string + teamId?: string + text: string + threadTs?: string + userId?: string +} + +type SlackThreadTarget = { + channelId: string + display: string + threadKeys: string[] + threadTs: string +} + const MAX_SLACK_MESSAGE_ATTACHMENTS = 20 type SlackbotV2RequestContext = { @@ -115,6 +132,32 @@ const SLACK_FALLBACK_TEXT_MAX_CHARS = 35_000 const POSTGRES_CONNECT_INITIAL_DELAY_MS = 250 const POSTGRES_CONNECT_MAX_DELAY_MS = 10_000 const SLACK_DEDUPE_BUCKETS = ['mention', 'dm', 'subscribed', 'pattern', 'history'] as const +const SLACK_SIGNATURE_MAX_AGE_SECONDS = 5 * 60 +const STEERING_STATE_KEY_PREFIX = 'steering' +const REASONING_EFFORT_ALIASES: Record = { + none: 'none', + minimal: 'minimal', + min: 'minimal', + low: 'low', + medium: 'medium', + med: 'medium', + high: 'high', + hi: 'high', + xhigh: 'xhigh', + xhi: 'xhigh', + 'x-high': 'xhigh' +} +const REASONING_COMMANDS: Record = { + minimal: 'minimal', + min: 'minimal', + low: 'low', + medium: 'medium', + med: 'medium', + high: 'high', + hi: 'high', + xhigh: 'xhigh', + xhi: 'xhigh' +} export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { const userName = options.userName ?? 'centaur' @@ -252,6 +295,7 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { } app.post('/api/webhooks/slack', handleSlackWebhook) app.post('/api/slack/events', handleSlackWebhook) + app.post('/api/slack/commands', c => handleSlackCommand(c, state, options)) if (options.recoverRenderObligationsOnStart !== false) { scheduleRenderObligationRecovery(chat, state, options) @@ -496,6 +540,301 @@ async function ensureStateConnected(state: StateAdapter, options: SlackbotV2Opti } } +async function handleSlackCommand( + c: Context, + state: StateAdapter, + options: SlackbotV2Options +): Promise { + const rawBody = await c.req.raw.clone().text() + if (!verifySlackRequestSignature(c, rawBody, options.signingSecret)) { + return new globalThis.Response('invalid Slack signature', { status: 401 }) + } + + const payload = slackCommandPayload(rawBody) + if (!payload.command) return slackCommandResponse('Unknown command.') + + try { + await ensureStateConnected(state, options) + const text = await applySlackCommand(payload, state) + traceLog(options, 'slackbotv2_slash_command_applied', undefined, { + command: payload.command, + channel_id: payload.channelId, + team_id: payload.teamId, + user_id: payload.userId + }) + return slackCommandResponse(text) + } catch (error) { + traceWarn(options, 'slackbotv2_slash_command_failed', undefined, { + command: payload.command, + error: errorMessage(error) + }) + return slackCommandResponse(errorMessage(error)) + } +} + +function verifySlackRequestSignature(c: Context, rawBody: string, signingSecret: string): boolean { + const timestamp = c.req.header('x-slack-request-timestamp') ?? '' + const signature = c.req.header('x-slack-signature') ?? '' + const parsedTimestamp = Number.parseInt(timestamp, 10) + if (!Number.isFinite(parsedTimestamp)) return false + if (Math.abs(Date.now() / 1000 - parsedTimestamp) > SLACK_SIGNATURE_MAX_AGE_SECONDS) return false + + const expected = `v0=${createHmac('sha256', signingSecret) + .update(`v0:${timestamp}:${rawBody}`) + .digest('hex')}` + const expectedBuffer = Buffer.from(expected) + const signatureBuffer = Buffer.from(signature) + return ( + expectedBuffer.length === signatureBuffer.length + && timingSafeEqual(expectedBuffer, signatureBuffer) + ) +} + +function slackCommandPayload(rawBody: string): SlackCommandPayload { + const params = new URLSearchParams(rawBody) + return { + channelId: params.get('channel_id')?.trim() ?? '', + command: params.get('command')?.trim() ?? '', + teamId: params.get('team_id')?.trim() || undefined, + text: params.get('text') ?? '', + threadTs: params.get('thread_ts')?.trim() || undefined, + userId: params.get('user_id')?.trim() || undefined + } +} + +async function applySlackCommand( + payload: SlackCommandPayload, + state: StateAdapter +): Promise { + const command = payload.command.replace(/^\//, '').toLowerCase() + const parsed = extractThreadTarget( + payload.text, + payload.channelId, + payload.teamId, + payload.threadTs + ) + if (!parsed.target) return steeringUsage(command) + + const commandReasoning = reasoningCommandEffort(command) + if (commandReasoning) { + const reasoning = commandReasoning + await updateThreadSteeringDefaults(state, parsed.target, { reasoning }, payload.userId) + return `Set reasoning to ${reasoning} for ${parsed.target.display}.` + } + + if (command === 'reasoning' || command === 'think' || command === 'thinking') { + const reasoning = firstReasoningEffort(parsed.remainder) + if (!reasoning) return steeringUsage(command) + await updateThreadSteeringDefaults(state, parsed.target, { reasoning }, payload.userId) + return `Set reasoning to ${reasoning} for ${parsed.target.display}.` + } + + return steeringUsage(command) +} + +function reasoningCommandEffort(command: string): string | undefined { + return REASONING_COMMANDS[command] +} + +function firstReasoningEffort(text: string): string | undefined { + for (const token of text.trim().split(/\s+/)) { + const reasoning = normalizeReasoningEffort(token) + if (reasoning) return reasoning + } + return undefined +} + +function normalizeReasoningEffort(value: string): string | undefined { + return REASONING_EFFORT_ALIASES[value.replace(/^--?/, '').toLowerCase()] +} + +function extractThreadTarget( + text: string, + defaultChannelId: string, + teamId?: string, + defaultThreadTs?: string +): { remainder: string; target?: SlackThreadTarget } { + const tokens = text.trim().split(/\s+/).filter(Boolean) + for (let index = 0; index < tokens.length; index += 1) { + const token = slackCommandToken(tokens[index]!) + const fromToken = threadTargetFromToken(token, defaultChannelId, teamId) + if (fromToken) return { target: fromToken, remainder: tokensWithoutRange(tokens, index, 1) } + + const next = tokens[index + 1] ? slackCommandToken(tokens[index + 1]!) : '' + if (isSlackChannelId(token) && next) { + const threadTs = slackTimestampFromToken(next) + if (threadTs) { + return { + target: slackThreadTarget(token, threadTs, teamId), + remainder: tokensWithoutRange(tokens, index, 2) + } + } + } + } + const fallbackThreadTs = defaultThreadTs ? slackTimestampFromToken(defaultThreadTs) : undefined + if (defaultChannelId && fallbackThreadTs) { + return { + target: slackThreadTarget(defaultChannelId, fallbackThreadTs, teamId), + remainder: text.trim() + } + } + return { remainder: text.trim() } +} + +function tokensWithoutRange(tokens: string[], index: number, deleteCount: number): string { + return [...tokens.slice(0, index), ...tokens.slice(index + deleteCount)].join(' ') +} + +function threadTargetFromToken( + token: string, + defaultChannelId: string, + teamId?: string +): SlackThreadTarget | undefined { + const archiveMatch = /\/archives\/([CGD][A-Z0-9]+)\/p(\d{11,})/i.exec(token) + if (archiveMatch?.[1] && archiveMatch[2]) { + const threadTs = slackTimestampFromCompact(archiveMatch[2]) + if (threadTs) return slackThreadTarget(archiveMatch[1], threadTs, teamId) + } + + try { + const url = new URL(token) + if (url.protocol === 'slack:') { + const channelId = url.searchParams.get('id') || url.searchParams.get('channel') || defaultChannelId + const message = url.searchParams.get('message') || url.searchParams.get('thread_ts') + const threadTs = message ? slackTimestampFromToken(message) : undefined + if (channelId && threadTs) return slackThreadTarget(channelId, threadTs, teamId) + } + } catch { + // Not a URL-shaped token; try the compact forms below. + } + + const combined = /^([CGD][A-Z0-9]+)[:/|](p?\d{10}(?:\.\d{6})?|\d{16,})$/i.exec(token) + if (combined?.[1] && combined[2]) { + const threadTs = slackTimestampFromToken(combined[2]) + if (threadTs) return slackThreadTarget(combined[1], threadTs, teamId) + } + + const threadTs = defaultChannelId ? slackTimestampFromToken(token) : undefined + return threadTs ? slackThreadTarget(defaultChannelId, threadTs, teamId) : undefined +} + +function slackCommandToken(token: string): string { + const trimmed = token.trim().replace(/^<|>$/g, '') + return trimmed.includes('|') ? trimmed.split('|')[0]! : trimmed +} + +function slackTimestampFromToken(token: string): string | undefined { + const cleaned = token.replace(/^p/i, '') + if (/^\d{10}\.\d{6}$/.test(cleaned)) return cleaned + return slackTimestampFromCompact(cleaned) +} + +function slackTimestampFromCompact(value: string): string | undefined { + const digits = value.replace(/\D/g, '') + if (digits.length < 16) return undefined + return `${digits.slice(0, -6)}.${digits.slice(-6)}` +} + +function slackThreadTarget(channelId: string, threadTs: string, teamId?: string): SlackThreadTarget { + const threadKeys = [`slack:${channelId}:${threadTs}`] + if (teamId) threadKeys.push(`slack:${teamId}:${channelId}:${threadTs}`) + return { + channelId, + display: `${channelId}/${threadTs}`, + threadKeys: Array.from(new Set(threadKeys)), + threadTs + } +} + +function isSlackChannelId(value: string): boolean { + return /^[CGD][A-Z0-9]+$/i.test(value) +} + +async function updateThreadSteeringDefaults( + state: StateAdapter, + target: SlackThreadTarget, + patch: Partial>, + updatedBy?: string +): Promise { + const existing = await getThreadSteeringDefaultsByKeys(state, target.threadKeys) + const next: SlackbotV2SteeringDefaults = { + ...existing, + ...patch, + updatedAt: new Date().toISOString(), + ...(updatedBy ? { updatedBy } : {}) + } + await Promise.all(target.threadKeys.map(threadKey => state.set(steeringStateKey(threadKey), next))) +} + +async function getThreadSteeringDefaults( + state: StateAdapter, + threadKey: string, + options: SlackbotV2Options, + trace?: SlackbotV2Trace +): Promise { + const keys = steeringLookupKeys(threadKey) + try { + return await getThreadSteeringDefaultsByKeys(state, keys) + } catch (error) { + traceWarn(options, 'slackbotv2_steering_defaults_load_failed', trace, { + error: errorMessage(error), + thread_key: threadKey + }) + return undefined + } +} + +async function getThreadSteeringDefaultsByKeys( + state: StateAdapter, + threadKeys: readonly string[] +): Promise { + for (const threadKey of threadKeys) { + const value = await state.get(steeringStateKey(threadKey)) + if (value?.model || value?.reasoning) return value + } + return undefined +} + +function steeringLookupKeys(threadKey: string): string[] { + const keys = [threadKey] + const parsed = parseSlackAssistantThreadId(threadKey) + if (parsed) keys.push(`slack:${parsed.channel}:${parsed.threadTs}`) + return Array.from(new Set(keys)) +} + +function steeringStateKey(threadKey: string): string { + return `${STEERING_STATE_KEY_PREFIX}:${threadKey}` +} + +function steeringUsage(command: string): string { + const prefix = command ? `/${command}` : '/reasoning' + if (reasoningCommandEffort(command)) { + return `Usage: ${prefix} [Slack thread link or thread timestamp]` + } + if (command === 'reasoning' || command === 'think' || command === 'thinking') { + return `Usage: ${prefix} [Slack thread link or thread timestamp]` + } + return [ + 'Usage:', + '/medium [Slack thread link or thread timestamp]', + '/xhigh [Slack thread link or thread timestamp]', + '/reasoning medium [Slack thread link or thread timestamp]' + ].join('\n') +} + +function slackCommandResponse(text: string, status = 200): Response { + return new globalThis.Response( + JSON.stringify({ + response_type: 'ephemeral', + text + }), + { + headers: { 'content-type': 'application/json; charset=utf-8' }, + status + } + ) +} + /** * Persists a Slack thread update into the session API. In execute mode the create/append/execute * handoff completes before Slack is acknowledged; SSE rendering continues in background. @@ -554,6 +893,9 @@ async function syncThreadMessageToSession( const serializedMessage = await serializeMessage(message) const overrides = extractMessageOverrides(serializedMessage.text) setMessageText(serializedMessage, overrides.cleanedText) + const steeringDefaults = await getThreadSteeringDefaults(input.state, thread.id, input.options, trace) + const model = overrides.model ?? steeringDefaults?.model + const reasoning = overrides.reasoning ?? steeringDefaults?.reasoning if (overrides.harnessType || overrides.model || overrides.provider || overrides.reasoning) { traceLog(input.options, 'slackbotv2_forward_overrides_parsed', trace, { harness_type: overrides.harnessType, @@ -562,6 +904,14 @@ async function syncThreadMessageToSession( reasoning: overrides.reasoning }) } + if (steeringDefaults?.model || steeringDefaults?.reasoning) { + traceLog(input.options, 'slackbotv2_forward_steering_defaults_loaded', trace, { + model: steeringDefaults.model, + reasoning: steeringDefaults.reasoning, + model_applied: overrides.model ? false : Boolean(steeringDefaults.model), + reasoning_applied: overrides.reasoning ? false : Boolean(steeringDefaults.reasoning) + }) + } traceLog(input.options, 'slackbotv2_forward_message_serialized', trace, { attachment_count: serializedMessage.attachments.length, raw_slack_attachment_count: serializedMessage.rawSlackAttachmentCount, @@ -606,9 +956,9 @@ async function syncThreadMessageToSession( // restarting the thread out from under an active execution would kill it. harnessType: shouldStartExecution ? overrides.harnessType : undefined, messages: messagesToAppend, - model: overrides.model, + model, provider: overrides.provider, - reasoning: overrides.reasoning, + reasoning, onEventId: eventId => { lastEventId = Math.max(lastEventId, eventId) }, diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 93c548a40..06407df53 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -143,6 +143,13 @@ export type SlackbotV2ThreadState = { renderObligation?: SlackbotV2RenderObligation | null } +export type SlackbotV2SteeringDefaults = { + model?: string + reasoning?: string + updatedAt: string + updatedBy?: string +} + export type SlackbotV2RenderObligation = { afterEventId: number executionId: string diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 7f27af8ff..7dfda7852 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -156,6 +156,81 @@ describe('slackbotv2', () => { expect(codexApi.executes[0]?.threadKey).toBe(threadKey(parent.ts)) }) + it('sets a per-thread reasoning default through a slash command with thread context', async () => { + const parent = await postUserMessage('Thread context for reasoning steering.') + const commandResponse = await bot.app.request( + '/api/slack/commands', + signedSlackCommand('/xhigh', '', { threadTs: parent.ts }) + ) + + expect(commandResponse.status).toBe(200) + const commandBody = await commandResponse.json() as { text: string } + expect(commandBody.text).toContain('Set reasoning to xhigh') + + const mention = await postUserMessage(`<@${BOT_USER_ID}> use the thread default`, parent.ts) + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-slash-medium-default', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> use the thread default` + } + }), + {}, + waitUntilContext(waits) + ) + + expect(response.status).toBe(200) + await Promise.all(waits) + expect(codexApi.executes).toHaveLength(1) + const input = executeInputLine(codexApi.executes[0]!) + expect(input.reasoning).toBe('xhigh') + }) + + it('lets inline reasoning override a per-thread slash command default', async () => { + const parent = await postUserMessage('Thread context for inline steering.') + await bot.app.request( + '/api/slack/commands', + signedSlackCommand('/medium', slackThreadUrl(parent.ts)) + ) + + const mention = await postUserMessage(`<@${BOT_USER_ID}> use high here -rsn high`, parent.ts) + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-inline-reasoning-overrides-default', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> use high here -rsn high` + } + }), + {}, + waitUntilContext(waits) + ) + + expect(response.status).toBe(200) + await Promise.all(waits) + expect(codexApi.executes).toHaveLength(1) + const input = executeInputLine(codexApi.executes[0]!) + expect(input.reasoning).toBe('high') + expect(sessionMessageTexts(codexApi.appends[0]!.body.messages).join('\n')).not.toContain( + '-rsn high' + ) + }) + it('does not let a non-actionable message event suppress a later app_mention', async () => { const text = `<@${BOT_USER_ID}> process the app mention after duplicate delivery` const mention = await postUserMessage(text) @@ -3753,6 +3828,16 @@ function threadKey(threadTs: string): string { return `slack:${CHANNEL_ID}:${threadTs}` } +function slackThreadUrl(threadTs: string): string { + return `https://slackbot-v2.slack.com/archives/${CHANNEL_ID}/p${threadTs.replace('.', '')}` +} + +function executeInputLine( + execute: MockSessionRequest +): Record { + return JSON.parse(execute.body.input_lines.at(-1)!) as Record +} + function threadTsFromThreadKey(threadId: string, fallback: string): string { const parts = threadId.split(':') if (parts[0] !== 'slack') return fallback @@ -3847,6 +3932,38 @@ function signedSlackEvent(input: { } } +function signedSlackCommand( + command: string, + text: string, + options: { threadTs?: string } = {} +): RequestInit { + const timestamp = Math.floor(Date.now() / 1000) + const params = new URLSearchParams({ + channel_id: CHANNEL_ID, + command, + response_url: 'https://hooks.slack.test/commands', + team_id: TEAM_ID, + text, + token: 'verification-token', + trigger_id: 'trigger-1', + user_id: USER_ID + }) + if (options.threadTs) params.set('thread_ts', options.threadTs) + const body = params.toString() + const signature = createHmac('sha256', SIGNING_SECRET) + .update(`v0:${timestamp}:${body}`) + .digest('hex') + return { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + 'x-slack-request-timestamp': String(timestamp), + 'x-slack-signature': `v0=${signature}` + }, + body + } +} + function waitUntilContext(waits: Promise[]) { return { waitUntil(promise: Promise) { From 7ac280979db623a30e053be3c05206a97079b5db Mon Sep 17 00:00:00 2001 From: fin-sa Date: Sun, 28 Jun 2026 16:41:56 -0400 Subject: [PATCH 3/3] Revert "Add Slack thread steering commands" This reverts commit dda196887e711b582344a4563250ddcd20d50fa2. --- services/slackbotv2/src/index.ts | 356 +----------------- services/slackbotv2/src/types.ts | 7 - .../slackbotv2/test/chat-sdk-emulate.test.ts | 117 ------ 3 files changed, 3 insertions(+), 477 deletions(-) diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 2cab9514b..c69fca354 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -1,5 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks' -import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto' +import { randomUUID } from 'node:crypto' import { Hono, type Context } from 'hono' import { Chat, @@ -48,7 +48,6 @@ import type { SlackbotV2Options, SlackbotV2RenderObligation, SlackbotV2RendererSource, - SlackbotV2SteeringDefaults, SlackbotV2ThreadState, SlackbotV2Trace } from './types' @@ -93,22 +92,6 @@ type SlackAssistantAdapter = { setAssistantTitle?(channelId: string, threadTs: string, title: string): Promise } -type SlackCommandPayload = { - channelId: string - command: string - teamId?: string - text: string - threadTs?: string - userId?: string -} - -type SlackThreadTarget = { - channelId: string - display: string - threadKeys: string[] - threadTs: string -} - const MAX_SLACK_MESSAGE_ATTACHMENTS = 20 type SlackbotV2RequestContext = { @@ -132,32 +115,6 @@ const SLACK_FALLBACK_TEXT_MAX_CHARS = 35_000 const POSTGRES_CONNECT_INITIAL_DELAY_MS = 250 const POSTGRES_CONNECT_MAX_DELAY_MS = 10_000 const SLACK_DEDUPE_BUCKETS = ['mention', 'dm', 'subscribed', 'pattern', 'history'] as const -const SLACK_SIGNATURE_MAX_AGE_SECONDS = 5 * 60 -const STEERING_STATE_KEY_PREFIX = 'steering' -const REASONING_EFFORT_ALIASES: Record = { - none: 'none', - minimal: 'minimal', - min: 'minimal', - low: 'low', - medium: 'medium', - med: 'medium', - high: 'high', - hi: 'high', - xhigh: 'xhigh', - xhi: 'xhigh', - 'x-high': 'xhigh' -} -const REASONING_COMMANDS: Record = { - minimal: 'minimal', - min: 'minimal', - low: 'low', - medium: 'medium', - med: 'medium', - high: 'high', - hi: 'high', - xhigh: 'xhigh', - xhi: 'xhigh' -} export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { const userName = options.userName ?? 'centaur' @@ -295,7 +252,6 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { } app.post('/api/webhooks/slack', handleSlackWebhook) app.post('/api/slack/events', handleSlackWebhook) - app.post('/api/slack/commands', c => handleSlackCommand(c, state, options)) if (options.recoverRenderObligationsOnStart !== false) { scheduleRenderObligationRecovery(chat, state, options) @@ -540,301 +496,6 @@ async function ensureStateConnected(state: StateAdapter, options: SlackbotV2Opti } } -async function handleSlackCommand( - c: Context, - state: StateAdapter, - options: SlackbotV2Options -): Promise { - const rawBody = await c.req.raw.clone().text() - if (!verifySlackRequestSignature(c, rawBody, options.signingSecret)) { - return new globalThis.Response('invalid Slack signature', { status: 401 }) - } - - const payload = slackCommandPayload(rawBody) - if (!payload.command) return slackCommandResponse('Unknown command.') - - try { - await ensureStateConnected(state, options) - const text = await applySlackCommand(payload, state) - traceLog(options, 'slackbotv2_slash_command_applied', undefined, { - command: payload.command, - channel_id: payload.channelId, - team_id: payload.teamId, - user_id: payload.userId - }) - return slackCommandResponse(text) - } catch (error) { - traceWarn(options, 'slackbotv2_slash_command_failed', undefined, { - command: payload.command, - error: errorMessage(error) - }) - return slackCommandResponse(errorMessage(error)) - } -} - -function verifySlackRequestSignature(c: Context, rawBody: string, signingSecret: string): boolean { - const timestamp = c.req.header('x-slack-request-timestamp') ?? '' - const signature = c.req.header('x-slack-signature') ?? '' - const parsedTimestamp = Number.parseInt(timestamp, 10) - if (!Number.isFinite(parsedTimestamp)) return false - if (Math.abs(Date.now() / 1000 - parsedTimestamp) > SLACK_SIGNATURE_MAX_AGE_SECONDS) return false - - const expected = `v0=${createHmac('sha256', signingSecret) - .update(`v0:${timestamp}:${rawBody}`) - .digest('hex')}` - const expectedBuffer = Buffer.from(expected) - const signatureBuffer = Buffer.from(signature) - return ( - expectedBuffer.length === signatureBuffer.length - && timingSafeEqual(expectedBuffer, signatureBuffer) - ) -} - -function slackCommandPayload(rawBody: string): SlackCommandPayload { - const params = new URLSearchParams(rawBody) - return { - channelId: params.get('channel_id')?.trim() ?? '', - command: params.get('command')?.trim() ?? '', - teamId: params.get('team_id')?.trim() || undefined, - text: params.get('text') ?? '', - threadTs: params.get('thread_ts')?.trim() || undefined, - userId: params.get('user_id')?.trim() || undefined - } -} - -async function applySlackCommand( - payload: SlackCommandPayload, - state: StateAdapter -): Promise { - const command = payload.command.replace(/^\//, '').toLowerCase() - const parsed = extractThreadTarget( - payload.text, - payload.channelId, - payload.teamId, - payload.threadTs - ) - if (!parsed.target) return steeringUsage(command) - - const commandReasoning = reasoningCommandEffort(command) - if (commandReasoning) { - const reasoning = commandReasoning - await updateThreadSteeringDefaults(state, parsed.target, { reasoning }, payload.userId) - return `Set reasoning to ${reasoning} for ${parsed.target.display}.` - } - - if (command === 'reasoning' || command === 'think' || command === 'thinking') { - const reasoning = firstReasoningEffort(parsed.remainder) - if (!reasoning) return steeringUsage(command) - await updateThreadSteeringDefaults(state, parsed.target, { reasoning }, payload.userId) - return `Set reasoning to ${reasoning} for ${parsed.target.display}.` - } - - return steeringUsage(command) -} - -function reasoningCommandEffort(command: string): string | undefined { - return REASONING_COMMANDS[command] -} - -function firstReasoningEffort(text: string): string | undefined { - for (const token of text.trim().split(/\s+/)) { - const reasoning = normalizeReasoningEffort(token) - if (reasoning) return reasoning - } - return undefined -} - -function normalizeReasoningEffort(value: string): string | undefined { - return REASONING_EFFORT_ALIASES[value.replace(/^--?/, '').toLowerCase()] -} - -function extractThreadTarget( - text: string, - defaultChannelId: string, - teamId?: string, - defaultThreadTs?: string -): { remainder: string; target?: SlackThreadTarget } { - const tokens = text.trim().split(/\s+/).filter(Boolean) - for (let index = 0; index < tokens.length; index += 1) { - const token = slackCommandToken(tokens[index]!) - const fromToken = threadTargetFromToken(token, defaultChannelId, teamId) - if (fromToken) return { target: fromToken, remainder: tokensWithoutRange(tokens, index, 1) } - - const next = tokens[index + 1] ? slackCommandToken(tokens[index + 1]!) : '' - if (isSlackChannelId(token) && next) { - const threadTs = slackTimestampFromToken(next) - if (threadTs) { - return { - target: slackThreadTarget(token, threadTs, teamId), - remainder: tokensWithoutRange(tokens, index, 2) - } - } - } - } - const fallbackThreadTs = defaultThreadTs ? slackTimestampFromToken(defaultThreadTs) : undefined - if (defaultChannelId && fallbackThreadTs) { - return { - target: slackThreadTarget(defaultChannelId, fallbackThreadTs, teamId), - remainder: text.trim() - } - } - return { remainder: text.trim() } -} - -function tokensWithoutRange(tokens: string[], index: number, deleteCount: number): string { - return [...tokens.slice(0, index), ...tokens.slice(index + deleteCount)].join(' ') -} - -function threadTargetFromToken( - token: string, - defaultChannelId: string, - teamId?: string -): SlackThreadTarget | undefined { - const archiveMatch = /\/archives\/([CGD][A-Z0-9]+)\/p(\d{11,})/i.exec(token) - if (archiveMatch?.[1] && archiveMatch[2]) { - const threadTs = slackTimestampFromCompact(archiveMatch[2]) - if (threadTs) return slackThreadTarget(archiveMatch[1], threadTs, teamId) - } - - try { - const url = new URL(token) - if (url.protocol === 'slack:') { - const channelId = url.searchParams.get('id') || url.searchParams.get('channel') || defaultChannelId - const message = url.searchParams.get('message') || url.searchParams.get('thread_ts') - const threadTs = message ? slackTimestampFromToken(message) : undefined - if (channelId && threadTs) return slackThreadTarget(channelId, threadTs, teamId) - } - } catch { - // Not a URL-shaped token; try the compact forms below. - } - - const combined = /^([CGD][A-Z0-9]+)[:/|](p?\d{10}(?:\.\d{6})?|\d{16,})$/i.exec(token) - if (combined?.[1] && combined[2]) { - const threadTs = slackTimestampFromToken(combined[2]) - if (threadTs) return slackThreadTarget(combined[1], threadTs, teamId) - } - - const threadTs = defaultChannelId ? slackTimestampFromToken(token) : undefined - return threadTs ? slackThreadTarget(defaultChannelId, threadTs, teamId) : undefined -} - -function slackCommandToken(token: string): string { - const trimmed = token.trim().replace(/^<|>$/g, '') - return trimmed.includes('|') ? trimmed.split('|')[0]! : trimmed -} - -function slackTimestampFromToken(token: string): string | undefined { - const cleaned = token.replace(/^p/i, '') - if (/^\d{10}\.\d{6}$/.test(cleaned)) return cleaned - return slackTimestampFromCompact(cleaned) -} - -function slackTimestampFromCompact(value: string): string | undefined { - const digits = value.replace(/\D/g, '') - if (digits.length < 16) return undefined - return `${digits.slice(0, -6)}.${digits.slice(-6)}` -} - -function slackThreadTarget(channelId: string, threadTs: string, teamId?: string): SlackThreadTarget { - const threadKeys = [`slack:${channelId}:${threadTs}`] - if (teamId) threadKeys.push(`slack:${teamId}:${channelId}:${threadTs}`) - return { - channelId, - display: `${channelId}/${threadTs}`, - threadKeys: Array.from(new Set(threadKeys)), - threadTs - } -} - -function isSlackChannelId(value: string): boolean { - return /^[CGD][A-Z0-9]+$/i.test(value) -} - -async function updateThreadSteeringDefaults( - state: StateAdapter, - target: SlackThreadTarget, - patch: Partial>, - updatedBy?: string -): Promise { - const existing = await getThreadSteeringDefaultsByKeys(state, target.threadKeys) - const next: SlackbotV2SteeringDefaults = { - ...existing, - ...patch, - updatedAt: new Date().toISOString(), - ...(updatedBy ? { updatedBy } : {}) - } - await Promise.all(target.threadKeys.map(threadKey => state.set(steeringStateKey(threadKey), next))) -} - -async function getThreadSteeringDefaults( - state: StateAdapter, - threadKey: string, - options: SlackbotV2Options, - trace?: SlackbotV2Trace -): Promise { - const keys = steeringLookupKeys(threadKey) - try { - return await getThreadSteeringDefaultsByKeys(state, keys) - } catch (error) { - traceWarn(options, 'slackbotv2_steering_defaults_load_failed', trace, { - error: errorMessage(error), - thread_key: threadKey - }) - return undefined - } -} - -async function getThreadSteeringDefaultsByKeys( - state: StateAdapter, - threadKeys: readonly string[] -): Promise { - for (const threadKey of threadKeys) { - const value = await state.get(steeringStateKey(threadKey)) - if (value?.model || value?.reasoning) return value - } - return undefined -} - -function steeringLookupKeys(threadKey: string): string[] { - const keys = [threadKey] - const parsed = parseSlackAssistantThreadId(threadKey) - if (parsed) keys.push(`slack:${parsed.channel}:${parsed.threadTs}`) - return Array.from(new Set(keys)) -} - -function steeringStateKey(threadKey: string): string { - return `${STEERING_STATE_KEY_PREFIX}:${threadKey}` -} - -function steeringUsage(command: string): string { - const prefix = command ? `/${command}` : '/reasoning' - if (reasoningCommandEffort(command)) { - return `Usage: ${prefix} [Slack thread link or thread timestamp]` - } - if (command === 'reasoning' || command === 'think' || command === 'thinking') { - return `Usage: ${prefix} [Slack thread link or thread timestamp]` - } - return [ - 'Usage:', - '/medium [Slack thread link or thread timestamp]', - '/xhigh [Slack thread link or thread timestamp]', - '/reasoning medium [Slack thread link or thread timestamp]' - ].join('\n') -} - -function slackCommandResponse(text: string, status = 200): Response { - return new globalThis.Response( - JSON.stringify({ - response_type: 'ephemeral', - text - }), - { - headers: { 'content-type': 'application/json; charset=utf-8' }, - status - } - ) -} - /** * Persists a Slack thread update into the session API. In execute mode the create/append/execute * handoff completes before Slack is acknowledged; SSE rendering continues in background. @@ -893,9 +554,6 @@ async function syncThreadMessageToSession( const serializedMessage = await serializeMessage(message) const overrides = extractMessageOverrides(serializedMessage.text) setMessageText(serializedMessage, overrides.cleanedText) - const steeringDefaults = await getThreadSteeringDefaults(input.state, thread.id, input.options, trace) - const model = overrides.model ?? steeringDefaults?.model - const reasoning = overrides.reasoning ?? steeringDefaults?.reasoning if (overrides.harnessType || overrides.model || overrides.provider || overrides.reasoning) { traceLog(input.options, 'slackbotv2_forward_overrides_parsed', trace, { harness_type: overrides.harnessType, @@ -904,14 +562,6 @@ async function syncThreadMessageToSession( reasoning: overrides.reasoning }) } - if (steeringDefaults?.model || steeringDefaults?.reasoning) { - traceLog(input.options, 'slackbotv2_forward_steering_defaults_loaded', trace, { - model: steeringDefaults.model, - reasoning: steeringDefaults.reasoning, - model_applied: overrides.model ? false : Boolean(steeringDefaults.model), - reasoning_applied: overrides.reasoning ? false : Boolean(steeringDefaults.reasoning) - }) - } traceLog(input.options, 'slackbotv2_forward_message_serialized', trace, { attachment_count: serializedMessage.attachments.length, raw_slack_attachment_count: serializedMessage.rawSlackAttachmentCount, @@ -956,9 +606,9 @@ async function syncThreadMessageToSession( // restarting the thread out from under an active execution would kill it. harnessType: shouldStartExecution ? overrides.harnessType : undefined, messages: messagesToAppend, - model, + model: overrides.model, provider: overrides.provider, - reasoning, + reasoning: overrides.reasoning, onEventId: eventId => { lastEventId = Math.max(lastEventId, eventId) }, diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 06407df53..93c548a40 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -143,13 +143,6 @@ export type SlackbotV2ThreadState = { renderObligation?: SlackbotV2RenderObligation | null } -export type SlackbotV2SteeringDefaults = { - model?: string - reasoning?: string - updatedAt: string - updatedBy?: string -} - export type SlackbotV2RenderObligation = { afterEventId: number executionId: string diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 7dfda7852..7f27af8ff 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -156,81 +156,6 @@ describe('slackbotv2', () => { expect(codexApi.executes[0]?.threadKey).toBe(threadKey(parent.ts)) }) - it('sets a per-thread reasoning default through a slash command with thread context', async () => { - const parent = await postUserMessage('Thread context for reasoning steering.') - const commandResponse = await bot.app.request( - '/api/slack/commands', - signedSlackCommand('/xhigh', '', { threadTs: parent.ts }) - ) - - expect(commandResponse.status).toBe(200) - const commandBody = await commandResponse.json() as { text: string } - expect(commandBody.text).toContain('Set reasoning to xhigh') - - const mention = await postUserMessage(`<@${BOT_USER_ID}> use the thread default`, parent.ts) - const waits: Promise[] = [] - const response = await bot.app.request( - '/api/webhooks/slack', - signedSlackEvent({ - event_id: 'Ev-slackbotv2-slash-medium-default', - event: { - type: 'app_mention', - user: USER_ID, - channel: CHANNEL_ID, - team: TEAM_ID, - ts: mention.ts, - thread_ts: parent.ts, - text: `<@${BOT_USER_ID}> use the thread default` - } - }), - {}, - waitUntilContext(waits) - ) - - expect(response.status).toBe(200) - await Promise.all(waits) - expect(codexApi.executes).toHaveLength(1) - const input = executeInputLine(codexApi.executes[0]!) - expect(input.reasoning).toBe('xhigh') - }) - - it('lets inline reasoning override a per-thread slash command default', async () => { - const parent = await postUserMessage('Thread context for inline steering.') - await bot.app.request( - '/api/slack/commands', - signedSlackCommand('/medium', slackThreadUrl(parent.ts)) - ) - - const mention = await postUserMessage(`<@${BOT_USER_ID}> use high here -rsn high`, parent.ts) - const waits: Promise[] = [] - const response = await bot.app.request( - '/api/webhooks/slack', - signedSlackEvent({ - event_id: 'Ev-slackbotv2-inline-reasoning-overrides-default', - event: { - type: 'app_mention', - user: USER_ID, - channel: CHANNEL_ID, - team: TEAM_ID, - ts: mention.ts, - thread_ts: parent.ts, - text: `<@${BOT_USER_ID}> use high here -rsn high` - } - }), - {}, - waitUntilContext(waits) - ) - - expect(response.status).toBe(200) - await Promise.all(waits) - expect(codexApi.executes).toHaveLength(1) - const input = executeInputLine(codexApi.executes[0]!) - expect(input.reasoning).toBe('high') - expect(sessionMessageTexts(codexApi.appends[0]!.body.messages).join('\n')).not.toContain( - '-rsn high' - ) - }) - it('does not let a non-actionable message event suppress a later app_mention', async () => { const text = `<@${BOT_USER_ID}> process the app mention after duplicate delivery` const mention = await postUserMessage(text) @@ -3828,16 +3753,6 @@ function threadKey(threadTs: string): string { return `slack:${CHANNEL_ID}:${threadTs}` } -function slackThreadUrl(threadTs: string): string { - return `https://slackbot-v2.slack.com/archives/${CHANNEL_ID}/p${threadTs.replace('.', '')}` -} - -function executeInputLine( - execute: MockSessionRequest -): Record { - return JSON.parse(execute.body.input_lines.at(-1)!) as Record -} - function threadTsFromThreadKey(threadId: string, fallback: string): string { const parts = threadId.split(':') if (parts[0] !== 'slack') return fallback @@ -3932,38 +3847,6 @@ function signedSlackEvent(input: { } } -function signedSlackCommand( - command: string, - text: string, - options: { threadTs?: string } = {} -): RequestInit { - const timestamp = Math.floor(Date.now() / 1000) - const params = new URLSearchParams({ - channel_id: CHANNEL_ID, - command, - response_url: 'https://hooks.slack.test/commands', - team_id: TEAM_ID, - text, - token: 'verification-token', - trigger_id: 'trigger-1', - user_id: USER_ID - }) - if (options.threadTs) params.set('thread_ts', options.threadTs) - const body = params.toString() - const signature = createHmac('sha256', SIGNING_SECRET) - .update(`v0:${timestamp}:${body}`) - .digest('hex') - return { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - 'x-slack-request-timestamp': String(timestamp), - 'x-slack-signature': `v0=${signature}` - }, - body - } -} - function waitUntilContext(waits: Promise[]) { return { waitUntil(promise: Promise) {