diff --git a/packages/junior/src/chat/respond.ts b/packages/junior/src/chat/respond.ts index db0238a3..4cc63334 100644 --- a/packages/junior/src/chat/respond.ts +++ b/packages/junior/src/chat/respond.ts @@ -48,7 +48,7 @@ import { import { createSandboxExecutor } from "@/chat/sandbox/sandbox"; import { getRuntimeMetadata } from "@/chat/config"; import { shouldEmitDevAgentTrace } from "@/chat/runtime/dev-agent-trace"; -import { formatToolStatusWithInput } from "@/chat/runtime/tool-status"; +import type { AssistantStatusSpec } from "@/chat/runtime/assistant-status"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { createAgentTools } from "@/chat/tools/agent-tools"; import { mergeArtifactsState } from "@/chat/runtime/thread-state"; @@ -120,7 +120,7 @@ export interface ReplyRequestContext { toolOverrides?: { imageGenerate?: ImageGenerateToolDeps; }; - onStatus?: (status: string) => void | Promise; + onStatus?: (status: AssistantStatusSpec) => void | Promise; onTextDelta?: (deltaText: string) => void | Promise; } @@ -395,11 +395,6 @@ export async function generateAssistantReply( onArtifactStatePatch: (patch) => { Object.assign(artifactStatePatch, patch); }, - onToolCallStart: async (toolName, input) => { - await context.onStatus?.( - `${formatToolStatusWithInput(toolName, input)}...`, - ); - }, toolOverrides: context.toolOverrides, onSkillLoaded: async (loadedSkill) => { const resolvedSkill = await skillSandbox.loadSkill(loadedSkill.name); diff --git a/packages/junior/src/chat/runtime/assistant-status.ts b/packages/junior/src/chat/runtime/assistant-status.ts new file mode 100644 index 00000000..94b4cef6 --- /dev/null +++ b/packages/junior/src/chat/runtime/assistant-status.ts @@ -0,0 +1,194 @@ +import type { SlackAdapter } from "@chat-adapter/slack"; +import { logWarn } from "@/chat/logging"; +import { getSlackClient } from "@/chat/slack/client"; +import { truncateStatusText } from "@/chat/runtime/status-format"; + +const STATUS_PATTERNS = { + thinking: { + defaultContext: "…", + variants: ["Thinking", "Reasoning", "Considering", "Working through"], + }, + searching: { + defaultContext: "sources", + variants: ["Searching", "Scanning", "Probing", "Trawling"], + }, + reading: { + defaultContext: "task", + variants: ["Reading", "Inspecting", "Parsing", "Skimming"], + }, + reviewing: { + defaultContext: "results", + variants: ["Reviewing", "Checking", "Inspecting", "Auditing"], + }, + loading: { + defaultContext: "task", + variants: ["Loading", "Priming", "Booting", "Spinning up"], + }, + updating: { + defaultContext: "state", + variants: ["Updating", "Patching", "Refreshing", "Adjusting"], + }, + fetching: { + defaultContext: "sources", + variants: ["Fetching", "Pulling", "Retrieving", "Loading"], + }, + creating: { + defaultContext: "draft", + variants: ["Creating", "Building", "Assembling", "Generating"], + }, + listing: { + defaultContext: "items", + variants: ["Listing", "Gathering", "Collecting", "Enumerating"], + }, + posting: { + defaultContext: "reply", + variants: ["Posting", "Sending", "Delivering", "Dispatching"], + }, + adding: { + defaultContext: "details", + variants: ["Adding", "Applying", "Attaching", "Dropping in"], + }, + running: { + defaultContext: "tasks", + variants: ["Running", "Executing", "Launching", "Processing"], + }, +} as const; + +export type AssistantStatusKind = keyof typeof STATUS_PATTERNS; + +export interface AssistantStatusSpec { + kind: AssistantStatusKind; + context?: string; +} + +/** + * Slack assistant status transport contract. + * + * Slack's `assistant.threads.setStatus` API auto-clears after roughly two + * minutes if no message is sent, so callers must refresh non-empty statuses + * periodically during long-running work and clear them explicitly with an + * empty status when the turn ends. + */ +export interface AssistantStatusTransport { + /** Best-effort update for the visible assistant status in a Slack thread. */ + setStatus: ( + channelId: string, + threadTs: string, + status: string, + suggestions?: string[], + ) => Promise; +} + +/** + * Rendered Slack assistant status payload. + * + * Statuses are explicit specs (`kind + context`). Specs use one consistent + * `Verb target` pattern and may rotate verbs within the same kind. + */ +export interface AssistantStatusPresentation { + key: string; + hint: string; + visible: string; + suggestions?: string[]; +} + +/** Build a typed assistant status from a stable kind and optional context. */ +export function makeAssistantStatus( + kind: AssistantStatusKind, + context?: string, +): AssistantStatusSpec { + return { kind, ...(context ? { context } : {}) }; +} + +/** Normalize a typed assistant status context before handing it to Slack. */ +export function normalizeAssistantStatusText(text: string): string { + const trimmed = text.trim(); + if (!trimmed) { + return ""; + } + return truncateStatusText(trimmed.replace(/(?:\.\s*)+$/, "").trim()); +} + +/** + * Render a Slack assistant status from a typed spec. + * + * Typed specs follow a consistent `Verb target` shape and rotate only within + * their declared kind. + */ +export function buildAssistantStatusPresentation(args: { + status: AssistantStatusSpec; + random?: () => number; +}): AssistantStatusPresentation { + const random = args.random ?? Math.random; + const pattern = STATUS_PATTERNS[args.status.kind]; + const context = + normalizeAssistantStatusText(args.status.context ?? "") || + pattern.defaultContext; + const index = Math.floor(random() * pattern.variants.length); + const verb = pattern.variants[index] ?? pattern.variants[0]; + const visible = truncateStatusText(`${verb} ${context}`); + const hint = truncateStatusText(`${pattern.variants[0]} ${context}`); + + return { + key: `${args.status.kind}:${context}`, + hint, + visible, + suggestions: Array.from(new Set([visible, hint])), + }; +} + +/** Create a best-effort Slack adapter transport for assistant status updates. */ +export function createSlackAdapterAssistantStatusTransport(args: { + getSlackAdapter: () => Pick; +}): AssistantStatusTransport { + return { + async setStatus(channelId, threadTs, status, suggestions) { + try { + await args + .getSlackAdapter() + .setAssistantStatus(channelId, threadTs, status, suggestions); + } catch (error) { + logAssistantStatusFailure(status, error); + } + }, + }; +} + +/** + * Create a best-effort Web API transport for assistant status updates. + * + * This is used by flows that do not have a chat adapter instance handy, such + * as OAuth resume handlers, but it still follows the same status semantics and + * `loading_messages` payload shape as the adapter-backed runtime path. + */ +export function createSlackWebApiAssistantStatusTransport(args?: { + getSlackClient?: typeof getSlackClient; +}): AssistantStatusTransport { + const getClient = args?.getSlackClient ?? getSlackClient; + return { + async setStatus(channelId, threadTs, status, suggestions) { + try { + await getClient().assistant.threads.setStatus({ + channel_id: channelId, + thread_ts: threadTs, + status, + ...(suggestions ? { loading_messages: suggestions } : {}), + }); + } catch (error) { + logAssistantStatusFailure(status, error); + } + }, + }; +} + +function logAssistantStatusFailure(status: string, error: unknown): void { + logWarn( + "assistant_status_update_failed", + {}, + { + "app.slack.status_text": status || "(clear)", + "error.message": error instanceof Error ? error.message : String(error), + }, + "Failed to update assistant status", + ); +} diff --git a/packages/junior/src/chat/runtime/progress-reporter.ts b/packages/junior/src/chat/runtime/progress-reporter.ts index c5c23ee6..7d095ea5 100644 --- a/packages/junior/src/chat/runtime/progress-reporter.ts +++ b/packages/junior/src/chat/runtime/progress-reporter.ts @@ -1,30 +1,39 @@ -import { logWarn } from "@/chat/logging"; -import { truncateStatusText } from "@/chat/runtime/status-format"; +import { + buildAssistantStatusPresentation, + makeAssistantStatus, + type AssistantStatusSpec, + type AssistantStatusTransport, +} from "@/chat/runtime/assistant-status"; const STATUS_UPDATE_DEBOUNCE_MS = 1000; const STATUS_MIN_VISIBLE_MS = 1200; +const STATUS_ROTATION_INTERVAL_MS = 30_000; type TimerHandle = ReturnType; export interface ProgressReporter { start: () => Promise; stop: () => Promise; - setStatus: (text: string) => Promise; + setStatus: (status: AssistantStatusSpec) => Promise; } -/** Create a debounced status reporter that drives the Slack "typing" indicator during a turn. */ +/** + * Create a debounced assistant-status reporter for long-running Slack turns. + * + * The runtime emits semantic hints such as tool or sandbox phases. This + * reporter owns the Slack-specific lifecycle on top of those hints: + * start with a non-empty status, debounce rapid phase changes, refresh the + * status before Slack's `assistant.threads.setStatus` timeout window makes it + * disappear, and clear the status explicitly with `""` when the turn stops. + */ export function createProgressReporter(args: { channelId?: string; threadTs?: string; - setAssistantStatus: ( - channelId: string, - threadTs: string, - text: string, - suggestions?: string[], - ) => Promise; + transport: AssistantStatusTransport; now?: () => number; setTimer?: (callback: () => void, delayMs: number) => TimerHandle; clearTimer?: (timer: TimerHandle) => void; + random?: () => number; }): ProgressReporter { const now = args.now ?? (() => Date.now()); const setTimer = @@ -32,55 +41,82 @@ export function createProgressReporter(args: { ((callback: () => void, delayMs: number) => setTimeout(callback, delayMs)); const clearTimer = args.clearTimer ?? ((timer: TimerHandle) => clearTimeout(timer)); + const random = args.random ?? Math.random; let active = false; - let currentStatus = ""; + let currentKey = ""; + let currentStatus: AssistantStatusSpec = makeAssistantStatus("thinking"); + let currentVisibleStatus = ""; let lastStatusAt = 0; - let pendingStatus: string | null = null; + let pendingStatus: AssistantStatusSpec | null = null; + let pendingKey = ""; let pendingTimer: TimerHandle | null = null; + let rotationTimer: TimerHandle | null = null; let inflightStatusUpdate: Promise = Promise.resolve(); - const postStatus = async (text: string): Promise => { + const scheduleRotation = () => { + if (rotationTimer) { + clearTimer(rotationTimer); + rotationTimer = null; + } + + if (!active || !currentVisibleStatus) { + return; + } + + rotationTimer = setTimer(() => { + rotationTimer = null; + if (!active || !currentVisibleStatus) { + return; + } + void postRenderedStatus(currentStatus); + }, STATUS_ROTATION_INTERVAL_MS); + }; + + const postStatus = async ( + text: string, + suggestions?: string[], + ): Promise => { const channelId = args.channelId; const threadTs = args.threadTs; if (!channelId || !threadTs) { return; } - if (!text && !currentStatus) { + if (!text && !currentVisibleStatus) { return; } - currentStatus = text; + currentVisibleStatus = text; lastStatusAt = now(); - const suggestions = text ? [text] : undefined; + scheduleRotation(); const previous = inflightStatusUpdate; const request = (async () => { await previous; - try { - await args.setAssistantStatus(channelId, threadTs, text, suggestions); - } catch (error) { - logWarn( - "assistant_status_update_failed", - {}, - { - "app.slack.status_text": text || "(clear)", - "error.message": - error instanceof Error ? error.message : String(error), - }, - "Failed to update assistant status", - ); - } + await args.transport.setStatus(channelId, threadTs, text, suggestions); })(); inflightStatusUpdate = request; await request; }; + const postRenderedStatus = async ( + status: AssistantStatusSpec, + ): Promise => { + const presentation = buildAssistantStatusPresentation({ + status, + random, + }); + currentStatus = status; + currentKey = presentation.key; + await postStatus(presentation.visible, presentation.suggestions); + }; + const clearPending = () => { if (pendingTimer) { clearTimer(pendingTimer); pendingTimer = null; } pendingStatus = null; + pendingKey = ""; }; const flushPending = async () => { @@ -91,8 +127,12 @@ export function createProgressReporter(args: { const next = pendingStatus; clearPending(); - if (next !== currentStatus) { - await postStatus(next); + const nextPresentation = buildAssistantStatusPresentation({ + status: next, + random, + }); + if (nextPresentation.key !== currentKey) { + await postRenderedStatus(next); } }; @@ -100,21 +140,32 @@ export function createProgressReporter(args: { async start() { active = true; clearPending(); - void postStatus("Thinking..."); + currentStatus = makeAssistantStatus("thinking"); + currentKey = ""; + void postRenderedStatus(currentStatus); }, async stop() { active = false; clearPending(); + if (rotationTimer) { + clearTimer(rotationTimer); + rotationTimer = null; + } + currentKey = ""; await postStatus(""); }, - async setStatus(text: string) { - const truncated = truncateStatusText(text); - if ( - !active || - !truncated || - truncated === currentStatus || - truncated === pendingStatus - ) { + async setStatus(status: AssistantStatusSpec) { + if (!active) { + return; + } + const presentation = buildAssistantStatusPresentation({ + status, + random, + }); + if (!presentation.visible) { + return; + } + if (presentation.key === currentKey || presentation.key === pendingKey) { return; } @@ -127,11 +178,12 @@ export function createProgressReporter(args: { if (waitMs <= 0) { clearPending(); - void postStatus(truncated); + void postRenderedStatus(status); return; } - pendingStatus = truncated; + pendingStatus = status; + pendingKey = presentation.key; if (pendingTimer) { return; } diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index d8ae5d57..50be8529 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -17,6 +17,7 @@ import { } from "@/chat/slack/output"; import { GEN_AI_PROVIDER_NAME } from "@/chat/pi/client"; import { createProgressReporter } from "@/chat/runtime/progress-reporter"; +import { createSlackAdapterAssistantStatusTransport } from "@/chat/runtime/assistant-status"; import { generateAssistantReply as generateAssistantReplyImpl } from "@/chat/respond"; import { shouldEmitDevAgentTrace } from "@/chat/runtime/dev-agent-trace"; import { @@ -225,10 +226,9 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { const progress = createProgressReporter({ channelId, threadTs, - setAssistantStatus: (channel, thread, text, suggestions) => - deps - .getSlackAdapter() - .setAssistantStatus(channel, thread, text, suggestions), + transport: createSlackAdapterAssistantStatusTransport({ + getSlackAdapter: deps.getSlackAdapter, + }), }); const textStream = createTextStreamBridge(); let streamedReplyPromise: Promise | undefined; diff --git a/packages/junior/src/chat/runtime/tool-status.ts b/packages/junior/src/chat/runtime/tool-status.ts index 48a9c592..8c909d8d 100644 --- a/packages/junior/src/chat/runtime/tool-status.ts +++ b/packages/junior/src/chat/runtime/tool-status.ts @@ -1,3 +1,5 @@ +import type { AssistantStatusSpec } from "@/chat/runtime/assistant-status"; +import { makeAssistantStatus } from "@/chat/runtime/assistant-status"; import { compactStatusCommand, compactStatusFilename, @@ -6,46 +8,11 @@ import { extractStatusUrlDomain, } from "@/chat/runtime/status-format"; -/** Return a human-readable status label for a tool call (no input context). */ -export function formatToolStatus(toolName: string): string { - const known: Record = { - loadSkill: "Loading skill instructions", - systemTime: "Reading current system time", - bash: "Working in the shell", - readFile: "Reading a file", - writeFile: "Updating a file", - webSearch: "Searching public sources", - webFetch: "Reading source pages", - slackChannelPostMessage: "Posting message to channel", - slackMessageAddReaction: "Adding emoji reaction", - slackChannelListMessages: "Listing channel messages", - slackCanvasCreate: "Creating detailed brief", - slackCanvasUpdate: "Updating detailed brief", - slackListCreate: "Creating tracking list", - slackListAddItems: "Updating tracking list", - slackListUpdateItem: "Updating tracking list", - imageGenerate: "Generating image", - searchTools: "Searching active tools", - }; - - if (known[toolName]) { - return known[toolName]; - } - - const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(toolName); - if (mcpMatch) { - return `Running ${mcpMatch[1]}/${mcpMatch[2]}`; - } - - const readable = toolName.replaceAll("_", " ").trim(); - return readable.length > 0 ? `Running ${readable}` : "Running tool"; -} - -/** Return a human-readable status label for a tool call, enriched with input details. */ -export function formatToolStatusWithInput( +/** Build a typed assistant status for a tool call. */ +export function buildToolStatus( toolName: string, input: unknown, -): string { +): AssistantStatusSpec { const obj = input && typeof input === "object" ? (input as Record) @@ -61,31 +28,65 @@ export function formatToolStatusWithInput( const provider = obj ? compactStatusText(obj.provider, 20) : undefined; if (command && toolName === "bash") { - return `Running ${command}`; + return makeAssistantStatus("running", command); } if (filename && toolName === "readFile") { - return `Reading file ${filename}`; + return makeAssistantStatus("reading", filename); } if (filename && toolName === "writeFile") { - return `Updating file ${filename}`; + return makeAssistantStatus("updating", filename); } if (path && toolName === "writeFile") { - return `Updating file ${path}`; + return makeAssistantStatus("updating", path); } if (skillName && toolName === "loadSkill") { - return `Loading skill ${skillName}`; + return makeAssistantStatus("loading", skillName); } if (query && toolName === "webSearch") { - return `Searching web for "${query}"`; + return makeAssistantStatus("searching", `"${query}"`); } if (query && provider && toolName === "searchTools") { - return `Searching ${provider} tools for "${query}"`; + return makeAssistantStatus("searching", `${provider} "${query}"`); } if (query && toolName === "searchTools") { - return `Searching tools for "${query}"`; + return makeAssistantStatus("searching", `"${query}"`); } if (domain && toolName === "webFetch") { - return `Fetching page from ${domain}`; + return makeAssistantStatus("fetching", domain); } - return formatToolStatus(toolName); + + const known: Partial> = { + loadSkill: makeAssistantStatus("loading", "skill instructions"), + systemTime: makeAssistantStatus("reading", "system time"), + bash: makeAssistantStatus("running", "shell"), + readFile: makeAssistantStatus("reading", "file"), + writeFile: makeAssistantStatus("updating", "file"), + webSearch: makeAssistantStatus("searching", "sources"), + webFetch: makeAssistantStatus("fetching", "pages"), + slackChannelPostMessage: makeAssistantStatus("posting", "channel"), + slackMessageAddReaction: makeAssistantStatus("adding", "reaction"), + slackChannelListMessages: makeAssistantStatus("listing", "messages"), + slackCanvasCreate: makeAssistantStatus("creating", "brief"), + slackCanvasUpdate: makeAssistantStatus("updating", "brief"), + slackListCreate: makeAssistantStatus("creating", "tracking list"), + slackListAddItems: makeAssistantStatus("updating", "tracking list"), + slackListUpdateItem: makeAssistantStatus("updating", "tracking list"), + imageGenerate: makeAssistantStatus("creating", "image"), + searchTools: makeAssistantStatus( + "searching", + provider ? `${provider} tools` : "tools", + ), + }; + + if (known[toolName]) { + return known[toolName] as AssistantStatusSpec; + } + + const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(toolName); + if (mcpMatch) { + return makeAssistantStatus("running", `${mcpMatch[1]}/${mcpMatch[2]}`); + } + + const readable = toolName.replaceAll("_", " ").trim(); + return makeAssistantStatus("running", readable || "tool"); } diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index 7352be0c..74856ac6 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -26,6 +26,8 @@ import { resolveRuntimeDependencySnapshot, type RuntimeDependencySnapshotProgressPhase, } from "@/chat/sandbox/runtime-dependency-snapshots"; +import type { AssistantStatusSpec } from "@/chat/runtime/assistant-status"; +import { makeAssistantStatus } from "@/chat/runtime/assistant-status"; import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; import type { SkillMetadata } from "@/chat/skills"; @@ -678,7 +680,7 @@ export function createSandboxExecutor(options?: { sandboxDependencyProfileHash?: string; timeoutMs?: number; traceContext?: LogContext; - onStatus?: (status: string) => void | Promise; + onStatus?: (status: AssistantStatusSpec) => void | Promise; runBashCustomCommand?: ( command: string, ) => Promise<{ handled: boolean; result?: BashCustomCommandResult }>; @@ -710,11 +712,11 @@ export function createSandboxExecutor(options?: { projectId?: string; } | undefined, - onStatus?: (status: string) => Promise, + onStatus?: (status: AssistantStatusSpec) => Promise, ): Promise => { for (let attempt = 0; attempt < SNAPSHOT_BOOT_RETRY_COUNT; attempt += 1) { try { - await onStatus?.("Booting up..."); + await onStatus?.(makeAssistantStatus("loading", "sandbox")); return await Sandbox.create({ timeout: timeoutMs, source: { @@ -853,11 +855,18 @@ export function createSandboxExecutor(options?: { const runtime = SANDBOX_RUNTIME; let statusCount = 0; const sentStatuses = new Set(); - const emitSandboxStatus = async (status: string): Promise => { - if (!emitStatus || statusCount >= 4 || sentStatuses.has(status)) { + const emitSandboxStatus = async ( + status: AssistantStatusSpec, + ): Promise => { + const statusKey = `${status.kind}:${status.context ?? ""}`; + if ( + !emitStatus || + statusCount >= 4 || + sentStatuses.has(statusKey) + ) { return; } - sentStatuses.add(status); + sentStatuses.add(statusKey); statusCount += 1; await emitStatus(status); }; @@ -865,19 +874,27 @@ export function createSandboxExecutor(options?: { phase: RuntimeDependencySnapshotProgressPhase, ): Promise => { if (phase === "resolve_start") { - await emitSandboxStatus("Checking sandbox snapshot cache..."); + await emitSandboxStatus( + makeAssistantStatus("loading", "sandbox snapshot cache"), + ); return; } if (phase === "waiting_for_lock") { - await emitSandboxStatus("Waiting for sandbox snapshot build..."); + await emitSandboxStatus( + makeAssistantStatus("loading", "sandbox snapshot build"), + ); return; } if (phase === "building_snapshot") { - await emitSandboxStatus("Building sandbox snapshot..."); + await emitSandboxStatus( + makeAssistantStatus("creating", "sandbox snapshot"), + ); return; } if (phase === "cache_hit") { - await emitSandboxStatus("Using cached sandbox snapshot..."); + await emitSandboxStatus( + makeAssistantStatus("loading", "sandbox snapshot"), + ); } }; @@ -892,7 +909,9 @@ export function createSandboxExecutor(options?: { "app.sandbox.runtime": runtime, }, async () => { - await emitSandboxStatus("Preparing sandbox runtime..."); + await emitSandboxStatus( + makeAssistantStatus("loading", "sandbox runtime"), + ); const snapshot = await resolveRuntimeDependencySnapshot({ runtime, timeoutMs, @@ -923,7 +942,9 @@ export function createSandboxExecutor(options?: { }); if (!snapshot.snapshotId) { - await emitSandboxStatus("Booting up..."); + await emitSandboxStatus( + makeAssistantStatus("loading", "sandbox"), + ); return await Sandbox.create({ timeout: timeoutMs, runtime, diff --git a/packages/junior/src/chat/tools/agent-tools.ts b/packages/junior/src/chat/tools/agent-tools.ts index e56f4eff..9bc4ddb5 100644 --- a/packages/junior/src/chat/tools/agent-tools.ts +++ b/packages/junior/src/chat/tools/agent-tools.ts @@ -3,7 +3,8 @@ import { serializeGenAiAttribute } from "@/chat/logging"; import { setSpanAttributes, withSpan, type LogContext } from "@/chat/logging"; import { GEN_AI_PROVIDER_NAME } from "@/chat/pi/client"; import { shouldEmitDevAgentTrace } from "@/chat/runtime/dev-agent-trace"; -import { formatToolStatusWithInput } from "@/chat/runtime/tool-status"; +import type { AssistantStatusSpec } from "@/chat/runtime/assistant-status"; +import { buildToolStatus } from "@/chat/runtime/tool-status"; import type { SkillCapabilityRuntime } from "@/chat/capabilities/runtime"; import type { SandboxExecutor } from "@/chat/sandbox/sandbox"; import type { SkillSandbox } from "@/chat/sandbox/skill-sandbox"; @@ -18,7 +19,7 @@ export function createAgentTools( tools: Record>, sandbox: SkillSandbox, spanContext: LogContext, - onStatus?: (status: string) => void | Promise, + onStatus?: (status: AssistantStatusSpec) => void | Promise, sandboxExecutor?: SandboxExecutor, capabilityRuntime?: SkillCapabilityRuntime, hooks?: { @@ -44,7 +45,7 @@ export function createAgentTools( turnId: spanContext.turnId, agentId: spanContext.agentId, }; - await onStatus?.(`${formatToolStatusWithInput(toolName, params)}...`); + await onStatus?.(buildToolStatus(toolName, params)); return withSpan( `execute_tool ${toolName}`, "gen_ai.execute_tool", diff --git a/packages/junior/src/chat/tools/index.ts b/packages/junior/src/chat/tools/index.ts index c8bd1ddf..21f86106 100644 --- a/packages/junior/src/chat/tools/index.ts +++ b/packages/junior/src/chat/tools/index.ts @@ -72,29 +72,6 @@ function createToolState( export type { ToolHooks, ToolRuntimeContext }; -function wrapToolExecution( - toolName: string, - toolDef: T, - hooks: ToolHooks, -): T { - const maybeExecutable = toolDef as T & { - execute?: (...args: any[]) => Promise | unknown; - }; - - if (!maybeExecutable.execute) { - return toolDef; - } - - const originalExecute = maybeExecutable.execute.bind(toolDef); - maybeExecutable.execute = async (...args: any[]) => { - const input = args[0]; - await hooks.onToolCallStart?.(toolName, input); - return originalExecute(...args); - }; - - return toolDef; -} - export function createTools( availableSkills: SkillMetadata[], hooks: ToolHooks = {}, @@ -102,92 +79,53 @@ export function createTools( ) { const state = createToolState(hooks, context); const tools: Record = { - loadSkill: wrapToolExecution( - "loadSkill", - createLoadSkillTool(context.sandbox, availableSkills, { - onSkillLoaded: hooks.onSkillLoaded, - }), - hooks, - ), - systemTime: wrapToolExecution("systemTime", createSystemTimeTool(), hooks), - bash: wrapToolExecution("bash", createBashTool(), hooks), - attachFile: wrapToolExecution( - "attachFile", - createAttachFileTool(context.sandbox, hooks), - hooks, - ), - readFile: wrapToolExecution("readFile", createReadFileTool(), hooks), - writeFile: wrapToolExecution("writeFile", createWriteFileTool(), hooks), - webSearch: wrapToolExecution("webSearch", createWebSearchTool(), hooks), - webFetch: wrapToolExecution("webFetch", createWebFetchTool(hooks), hooks), - imageGenerate: wrapToolExecution( - "imageGenerate", - createImageGenerateTool(hooks, hooks.toolOverrides?.imageGenerate), - hooks, - ), - slackCanvasUpdate: wrapToolExecution( - "slackCanvasUpdate", - createSlackCanvasUpdateTool(state, context), - hooks, - ), - slackListCreate: wrapToolExecution( - "slackListCreate", - createSlackListCreateTool(state), - hooks, - ), - slackListAddItems: wrapToolExecution( - "slackListAddItems", - createSlackListAddItemsTool(state), - hooks, - ), - slackListGetItems: wrapToolExecution( - "slackListGetItems", - createSlackListGetItemsTool(state), - hooks, - ), - slackListUpdateItem: wrapToolExecution( - "slackListUpdateItem", - createSlackListUpdateItemTool(state), + loadSkill: createLoadSkillTool(context.sandbox, availableSkills, { + onSkillLoaded: hooks.onSkillLoaded, + }), + systemTime: createSystemTimeTool(), + bash: createBashTool(), + attachFile: createAttachFileTool(context.sandbox, hooks), + readFile: createReadFileTool(), + writeFile: createWriteFileTool(), + webSearch: createWebSearchTool(), + webFetch: createWebFetchTool(hooks), + imageGenerate: createImageGenerateTool( hooks, + hooks.toolOverrides?.imageGenerate, ), + slackCanvasUpdate: createSlackCanvasUpdateTool(state, context), + slackListCreate: createSlackListCreateTool(state), + slackListAddItems: createSlackListAddItemsTool(state), + slackListGetItems: createSlackListGetItemsTool(state), + slackListUpdateItem: createSlackListUpdateItemTool(state), }; if (context.mcpToolManager && context.getActiveSkills) { - tools.searchTools = wrapToolExecution( - "searchTools", - createSearchToolsTool(context.mcpToolManager, context.getActiveSkills), - hooks, + tools.searchTools = createSearchToolsTool( + context.mcpToolManager, + context.getActiveSkills, ); } const { channelCapabilities } = context; if (channelCapabilities.canCreateCanvas) { - tools.slackCanvasCreate = wrapToolExecution( - "slackCanvasCreate", - createSlackCanvasCreateTool(context, state), - hooks, - ); + tools.slackCanvasCreate = createSlackCanvasCreateTool(context, state); } if (channelCapabilities.canPostToChannel) { - tools.slackChannelPostMessage = wrapToolExecution( - "slackChannelPostMessage", - createSlackChannelPostMessageTool(context, state), - hooks, - ); - tools.slackChannelListMessages = wrapToolExecution( - "slackChannelListMessages", - createSlackChannelListMessagesTool(context), - hooks, + tools.slackChannelPostMessage = createSlackChannelPostMessageTool( + context, + state, ); + tools.slackChannelListMessages = + createSlackChannelListMessagesTool(context); } if (channelCapabilities.canAddReactions) { - tools.slackMessageAddReaction = wrapToolExecution( - "slackMessageAddReaction", - createSlackMessageAddReactionTool(context, state), - hooks, + tools.slackMessageAddReaction = createSlackMessageAddReactionTool( + context, + state, ); } diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index ae180def..42c53733 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -15,7 +15,6 @@ export interface ToolHooks { onGeneratedArtifactFiles?: (files: FileUpload[]) => void; onGeneratedFiles?: (files: FileUpload[]) => void; onArtifactStatePatch?: (patch: Partial) => void; - onToolCallStart?: (toolName: string, input?: unknown) => void | Promise; onSkillLoaded?: ( skill: Skill, ) => void | LoadSkillMetadata | Promise; diff --git a/packages/junior/src/handlers/oauth-resume.ts b/packages/junior/src/handlers/oauth-resume.ts index b62c24ca..ed1f2bc6 100644 --- a/packages/junior/src/handlers/oauth-resume.ts +++ b/packages/junior/src/handlers/oauth-resume.ts @@ -1,9 +1,10 @@ import { botConfig } from "@/chat/config"; import type { ChannelConfigurationService } from "@/chat/configuration/types"; import { generateAssistantReply, type AssistantReply } from "@/chat/respond"; +import { createSlackWebApiAssistantStatusTransport } from "@/chat/runtime/assistant-status"; +import { createProgressReporter } from "@/chat/runtime/progress-reporter"; import { getSlackClient } from "@/chat/slack/client"; import type { ThreadArtifactsState } from "@/chat/state/artifacts"; -import { truncateStatusText } from "@/chat/runtime/status-format"; import { isRetryableTurnError } from "@/chat/runtime/turn"; function resolveReplyTimeoutMs(explicitTimeoutMs?: number): number | undefined { @@ -36,83 +37,6 @@ export async function postSlackMessage( } } -async function setAssistantStatus( - channelId: string, - threadTs: string, - status: string, -): Promise { - try { - await getSlackClient().assistant.threads.setStatus({ - channel_id: channelId, - thread_ts: threadTs, - status, - }); - } catch { - // Best effort. - } -} - -const STATUS_DEBOUNCE_MS = 1000; - -function createDebouncedStatusPoster(channelId: string, threadTs: string) { - let lastPostAt = 0; - let currentStatus = ""; - let pendingStatus: string | null = null; - let pendingTimer: ReturnType | null = null; - let stopped = false; - - const flush = async () => { - if (stopped || !pendingStatus) return; - const status = pendingStatus; - pendingStatus = null; - pendingTimer = null; - lastPostAt = Date.now(); - currentStatus = status; - await setAssistantStatus(channelId, threadTs, status); - }; - - const post = async (status: string) => { - if (stopped) return; - const truncated = truncateStatusText(status); - if (!truncated || truncated === currentStatus) return; - - const now = Date.now(); - const elapsed = now - lastPostAt; - if (elapsed >= STATUS_DEBOUNCE_MS) { - if (pendingTimer) { - clearTimeout(pendingTimer); - pendingTimer = null; - } - pendingStatus = null; - lastPostAt = now; - currentStatus = truncated; - await setAssistantStatus(channelId, threadTs, truncated); - return; - } - - pendingStatus = truncated; - if (!pendingTimer) { - pendingTimer = setTimeout( - () => { - void flush(); - }, - Math.max(1, STATUS_DEBOUNCE_MS - elapsed), - ); - } - }; - - post.stop = () => { - stopped = true; - if (pendingTimer) { - clearTimeout(pendingTimer); - pendingTimer = null; - } - pendingStatus = null; - }; - - return post; -} - export function createReadOnlyConfigService( values: Record, ): ChannelConfigurationService { @@ -170,9 +94,13 @@ export async function resumeAuthorizedRequest(args: { onAuthPause?: (error: unknown) => Promise; replyTimeoutMs?: number; }) { - const postStatus = createDebouncedStatusPoster(args.channelId, args.threadTs); + const progress = createProgressReporter({ + channelId: args.channelId, + threadTs: args.threadTs, + transport: createSlackWebApiAssistantStatusTransport(), + }); await postSlackMessage(args.channelId, args.threadTs, args.connectedText); - await setAssistantStatus(args.channelId, args.threadTs, "Thinking..."); + await progress.start(); try { const generateReply = args.generateReply ?? generateAssistantReply; @@ -193,7 +121,7 @@ export async function resumeAuthorizedRequest(args: { channelConfiguration: args.configuration ? createReadOnlyConfigService(args.configuration) : undefined, - onStatus: postStatus, + onStatus: (status) => progress.setStatus(status), }); const replyTimeoutMs = resolveReplyTimeoutMs(args.replyTimeoutMs); const reply = @@ -214,8 +142,7 @@ export async function resumeAuthorizedRequest(args: { ]) : await replyPromise; - postStatus.stop(); - await setAssistantStatus(args.channelId, args.threadTs, ""); + await progress.stop(); if (args.onReply) { await args.onReply(reply); } else if (reply.text) { @@ -223,8 +150,7 @@ export async function resumeAuthorizedRequest(args: { } await args.onSuccess?.(reply); } catch (error) { - postStatus.stop(); - await setAssistantStatus(args.channelId, args.threadTs, ""); + await progress.stop(); if (isRetryableTurnError(error, "mcp_auth_resume") && args.onAuthPause) { await args.onAuthPause(error); diff --git a/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts b/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts index 6bcb451b..b4991acb 100644 --- a/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts +++ b/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts @@ -259,7 +259,8 @@ describe("mcp oauth callback slack integration", () => { params: expect.objectContaining({ channel_id: "C123", thread_ts: "1700000000.001", - status: "Thinking...", + status: expect.any(String), + loading_messages: expect.arrayContaining([expect.any(String)]), }), }), expect.objectContaining({ @@ -295,8 +296,7 @@ describe("mcp oauth callback slack integration", () => { params: expect.objectContaining({ channel: "C123", thread_ts: "1700000000.001", - text: - "I couldn't complete this request in this turn due to an execution failure. I've logged the details for debugging.", + text: "I couldn't complete this request in this turn due to an execution failure. I've logged the details for debugging.", }), }), ]), diff --git a/packages/junior/tests/integration/oauth-resume-slack.test.ts b/packages/junior/tests/integration/oauth-resume-slack.test.ts index a7f4b54f..a3badcef 100644 --- a/packages/junior/tests/integration/oauth-resume-slack.test.ts +++ b/packages/junior/tests/integration/oauth-resume-slack.test.ts @@ -25,7 +25,8 @@ describe("oauth resume slack integration", () => { params: expect.objectContaining({ channel_id: "C123", thread_ts: "1700000000.001", - status: "Thinking...", + status: expect.any(String), + loading_messages: expect.arrayContaining([expect.any(String)]), }), }), expect.objectContaining({ diff --git a/packages/junior/tests/integration/slack/new-mention-behavior.test.ts b/packages/junior/tests/integration/slack/new-mention-behavior.test.ts index d9b51507..410c6bbe 100644 --- a/packages/junior/tests/integration/slack/new-mention-behavior.test.ts +++ b/packages/junior/tests/integration/slack/new-mention-behavior.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { makeAssistantStatus } from "@/chat/runtime/assistant-status"; import { FakeSlackAdapter } from "../../fixtures/slack-harness"; import { createTestChatRuntime } from "../../fixtures/chat-runtime"; import { @@ -81,7 +82,7 @@ describe("Slack behavior: new mention", () => { services: { replyExecutor: { generateAssistantReply: async (_prompt, context) => { - await context?.onStatus?.("Running bash..."); + await context?.onStatus?.(makeAssistantStatus("running", "bash")); return { text: "Done.", diagnostics: { @@ -128,7 +129,9 @@ describe("Slack behavior: new mention", () => { services: { replyExecutor: { generateAssistantReply: async (_prompt, context) => { - await context?.onStatus?.("Adding reaction..."); + await context?.onStatus?.( + makeAssistantStatus("adding", "reaction"), + ); return { text: "Done!", deliveryMode: "thread", diff --git a/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts b/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts index 5ba8b8a8..112263df 100644 --- a/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts +++ b/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts @@ -296,11 +296,14 @@ describe("mcp oauth callback handler", () => { thread_ts: "1712345.0001", text: "Your demo MCP access is now connected. Continuing the original request...", }); - expect(setStatusMock).toHaveBeenCalledWith({ - channel_id: "C123", - thread_ts: "1712345.0001", - status: "Thinking...", - }); + expect(setStatusMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel_id: "C123", + thread_ts: "1712345.0001", + status: expect.any(String), + loading_messages: expect.arrayContaining([expect.any(String)]), + }), + ); expect(generateAssistantReplyMock).toHaveBeenCalledWith( "/demo incidents", expect.objectContaining({ diff --git a/packages/junior/tests/unit/handlers/oauth-callback.test.ts b/packages/junior/tests/unit/handlers/oauth-callback.test.ts index ee545230..27f81bba 100644 --- a/packages/junior/tests/unit/handlers/oauth-callback.test.ts +++ b/packages/junior/tests/unit/handlers/oauth-callback.test.ts @@ -93,6 +93,7 @@ vi.mock("@/chat/config", () => ({ vi.mock("@/chat/logging", () => ({ logException: vi.fn(), logInfo: vi.fn(), + logWarn: vi.fn(), })); import { GET } from "@/handlers/oauth-callback"; diff --git a/packages/junior/tests/unit/handlers/oauth-resume.test.ts b/packages/junior/tests/unit/handlers/oauth-resume.test.ts index 5157ac94..3a05dd0f 100644 --- a/packages/junior/tests/unit/handlers/oauth-resume.test.ts +++ b/packages/junior/tests/unit/handlers/oauth-resume.test.ts @@ -69,12 +69,17 @@ describe("resumeAuthorizedRequest", () => { thread_ts: "1700000000.0001", text: "resume failed", }); - expect(setStatusMock).toHaveBeenNthCalledWith(1, { + expect(setStatusMock).toHaveBeenCalledTimes(2); + expect(setStatusMock.mock.calls[0]?.[0]).toMatchObject({ channel_id: "C-test", thread_ts: "1700000000.0001", - status: "Thinking...", + status: expect.any(String), + loading_messages: expect.arrayContaining([expect.any(String)]), }); - expect(setStatusMock).toHaveBeenNthCalledWith(2, { + expect( + (setStatusMock.mock.calls[0]?.[0] as { status?: string }).status, + ).not.toBe(""); + expect(setStatusMock.mock.calls[1]?.[0]).toMatchObject({ channel_id: "C-test", thread_ts: "1700000000.0001", status: "", diff --git a/packages/junior/tests/unit/misc/sandbox-executor.test.ts b/packages/junior/tests/unit/misc/sandbox-executor.test.ts index fdf065ba..102ae904 100644 --- a/packages/junior/tests/unit/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/unit/misc/sandbox-executor.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeAssistantStatus } from "@/chat/runtime/assistant-status"; const { sandboxGetMock, sandboxCreateMock } = vi.hoisted(() => ({ sandboxGetMock: vi.fn(), @@ -722,7 +723,7 @@ describe("createSandboxExecutor", () => { it("emits sandbox phase status updates before booting", async () => { const sandbox = makeSandbox("sbx_status"); sandboxCreateMock.mockResolvedValue(sandbox); - const statuses: string[] = []; + const statuses: ReturnType[] = []; resolveRuntimeDependencySnapshotMock.mockImplementationOnce( async (params: { onProgress?: (phase: string) => Promise }) => { await params.onProgress?.("resolve_start"); @@ -750,10 +751,10 @@ describe("createSandboxExecutor", () => { await executor.createSandbox(); expect(statuses).toEqual([ - "Preparing sandbox runtime...", - "Checking sandbox snapshot cache...", - "Waiting for sandbox snapshot build...", - "Building sandbox snapshot...", + makeAssistantStatus("loading", "sandbox runtime"), + makeAssistantStatus("loading", "sandbox snapshot cache"), + makeAssistantStatus("loading", "sandbox snapshot build"), + makeAssistantStatus("creating", "sandbox snapshot"), ]); }); }); diff --git a/packages/junior/tests/unit/progress-reporter.test.ts b/packages/junior/tests/unit/progress-reporter.test.ts index f7aab27c..a3326e13 100644 --- a/packages/junior/tests/unit/progress-reporter.test.ts +++ b/packages/junior/tests/unit/progress-reporter.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { makeAssistantStatus } from "@/chat/runtime/assistant-status"; import { createProgressReporter } from "@/chat/runtime/progress-reporter"; interface FakeTimer { @@ -60,25 +61,33 @@ function createFakeScheduler() { }; } +const firstPlayfulStatus = "Thinking …"; +const secondSearchingStatus = "Searching sources"; +const secondReadingStatus = "Reading source files"; +const secondReviewingStatus = "Reviewing results"; + describe("createProgressReporter", () => { - it("posts initial Thinking status on start", async () => { + it("posts an initial playful status on start", async () => { const scheduler = createFakeScheduler(); const statuses: string[] = []; const reporter = createProgressReporter({ channelId: "C1", threadTs: "123.45", - setAssistantStatus: async (_channelId, _threadTs, text) => { - statuses.push(text); + transport: { + setStatus: async (_channelId, _threadTs, text) => { + statuses.push(text); + }, }, now: scheduler.now, setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer, + random: () => 0, }); await reporter.start(); await Promise.resolve(); - expect(statuses).toEqual(["Thinking..."]); + expect(statuses).toEqual([firstPlayfulStatus]); }); it("clears the assistant status when stopped", async () => { @@ -87,12 +96,15 @@ describe("createProgressReporter", () => { const reporter = createProgressReporter({ channelId: "C1", threadTs: "123.45", - setAssistantStatus: async (_channelId, _threadTs, text) => { - statuses.push(text); + transport: { + setStatus: async (_channelId, _threadTs, text) => { + statuses.push(text); + }, }, now: scheduler.now, setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer, + random: () => 0, }); await reporter.start(); @@ -100,7 +112,7 @@ describe("createProgressReporter", () => { await reporter.stop(); - expect(statuses).toEqual(["Thinking...", ""]); + expect(statuses).toEqual([firstPlayfulStatus, ""]); }); it("omits loading suggestions when clearing the assistant status", async () => { @@ -109,12 +121,15 @@ describe("createProgressReporter", () => { const reporter = createProgressReporter({ channelId: "C1", threadTs: "123.45", - setAssistantStatus: async (_channelId, _threadTs, text, suggestions) => { - calls.push({ text, suggestions }); + transport: { + setStatus: async (_channelId, _threadTs, text, suggestions) => { + calls.push({ text, suggestions }); + }, }, now: scheduler.now, setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer, + random: () => 0, }); await reporter.start(); @@ -123,7 +138,10 @@ describe("createProgressReporter", () => { await reporter.stop(); expect(calls).toEqual([ - { text: "Thinking...", suggestions: ["Thinking..."] }, + { + text: firstPlayfulStatus, + suggestions: [firstPlayfulStatus], + }, { text: "", suggestions: undefined }, ]); }); @@ -134,23 +152,26 @@ describe("createProgressReporter", () => { const reporter = createProgressReporter({ channelId: "C1", threadTs: "123.45", - setAssistantStatus: async (_channelId, _threadTs, text) => { - statuses.push(text); + transport: { + setStatus: async (_channelId, _threadTs, text) => { + statuses.push(text); + }, }, now: scheduler.now, setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer, + random: () => 0, }); await reporter.start(); await Promise.resolve(); - await reporter.setStatus("Searching"); - await reporter.setStatus("Searching"); + await reporter.setStatus(makeAssistantStatus("searching")); + await reporter.setStatus(makeAssistantStatus("searching")); scheduler.advance(1200); await Promise.resolve(); - expect(statuses).toEqual(["Thinking...", "Searching"]); + expect(statuses).toEqual([firstPlayfulStatus, secondSearchingStatus]); }); it("enforces minimum visible duration before replacement", async () => { @@ -159,25 +180,28 @@ describe("createProgressReporter", () => { const reporter = createProgressReporter({ channelId: "C1", threadTs: "123.45", - setAssistantStatus: async (_channelId, _threadTs, text) => { - statuses.push(text); + transport: { + setStatus: async (_channelId, _threadTs, text) => { + statuses.push(text); + }, }, now: scheduler.now, setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer, + random: () => 0, }); await reporter.start(); await Promise.resolve(); - await reporter.setStatus("Reading source files"); + await reporter.setStatus(makeAssistantStatus("reading", "source files")); scheduler.advance(1000); await Promise.resolve(); - expect(statuses).toEqual(["Thinking..."]); + expect(statuses).toEqual([firstPlayfulStatus]); scheduler.advance(200); await Promise.resolve(); - expect(statuses).toEqual(["Thinking...", "Reading source files"]); + expect(statuses).toEqual([firstPlayfulStatus, secondReadingStatus]); }); it("keeps the latest status when multiple updates arrive before flush", async () => { @@ -186,24 +210,27 @@ describe("createProgressReporter", () => { const reporter = createProgressReporter({ channelId: "C1", threadTs: "123.45", - setAssistantStatus: async (_channelId, _threadTs, text) => { - statuses.push(text); + transport: { + setStatus: async (_channelId, _threadTs, text) => { + statuses.push(text); + }, }, now: scheduler.now, setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer, + random: () => 0, }); await reporter.start(); await Promise.resolve(); - await reporter.setStatus("Searching docs"); - await reporter.setStatus("Reviewing results"); + await reporter.setStatus(makeAssistantStatus("searching", "docs")); + await reporter.setStatus(makeAssistantStatus("reviewing")); scheduler.advance(1200); await Promise.resolve(); - expect(statuses).toEqual(["Thinking...", "Reviewing results"]); + expect(statuses).toEqual([firstPlayfulStatus, secondReviewingStatus]); }); it("serializes status updates so a slow request cannot reorder with the clear", async () => { @@ -213,31 +240,34 @@ describe("createProgressReporter", () => { const reporter = createProgressReporter({ channelId: "C1", threadTs: "123.45", - setAssistantStatus: async (_channelId, _threadTs, text) => { - if (text === "Thinking...") { - await new Promise((resolve) => { - resolveThinking = resolve; - }); - } - statuses.push(text); + transport: { + setStatus: async (_channelId, _threadTs, text) => { + if (text === firstPlayfulStatus) { + await new Promise((resolve) => { + resolveThinking = resolve; + }); + } + statuses.push(text); + }, }, now: scheduler.now, setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer, + random: () => 0, }); await reporter.start(); - // "Thinking..." is now in flight but blocked + // Initial playful status is now in flight but blocked const stopPromise = reporter.stop(); - // stop() should wait for the inflight "Thinking..." before sending "" + // stop() should wait for the inflight status before sending "" - // Unblock the slow "Thinking..." call + // Unblock the slow initial status call resolveThinking!(); await stopPromise; // The clear must always be the last status sent to Slack - expect(statuses).toEqual(["Thinking...", ""]); + expect(statuses).toEqual([firstPlayfulStatus, ""]); }); it("clears after the latest visible status when stopping", async () => { @@ -246,23 +276,52 @@ describe("createProgressReporter", () => { const reporter = createProgressReporter({ channelId: "C1", threadTs: "123.45", - setAssistantStatus: async (_channelId, _threadTs, text) => { - statuses.push(text); + transport: { + setStatus: async (_channelId, _threadTs, text) => { + statuses.push(text); + }, }, now: scheduler.now, setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer, + random: () => 0, }); await reporter.start(); await Promise.resolve(); - await reporter.setStatus("Reviewing results"); + await reporter.setStatus(makeAssistantStatus("reviewing")); scheduler.advance(1200); await Promise.resolve(); await reporter.stop(); - expect(statuses).toEqual(["Thinking...", "Reviewing results", ""]); + expect(statuses).toEqual([firstPlayfulStatus, secondReviewingStatus, ""]); + }); + + it("refreshes the current status during long-running work", async () => { + const scheduler = createFakeScheduler(); + const statuses: string[] = []; + const reporter = createProgressReporter({ + channelId: "C1", + threadTs: "123.45", + transport: { + setStatus: async (_channelId, _threadTs, text) => { + statuses.push(text); + }, + }, + now: scheduler.now, + setTimer: scheduler.setTimer, + clearTimer: scheduler.clearTimer, + random: () => 0, + }); + + await reporter.start(); + await Promise.resolve(); + + scheduler.advance(30_000); + await Promise.resolve(); + + expect(statuses).toEqual([firstPlayfulStatus, firstPlayfulStatus]); }); }); diff --git a/packages/junior/tests/unit/runtime/assistant-status.test.ts b/packages/junior/tests/unit/runtime/assistant-status.test.ts new file mode 100644 index 00000000..ccffb5a5 --- /dev/null +++ b/packages/junior/tests/unit/runtime/assistant-status.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + buildAssistantStatusPresentation, + makeAssistantStatus, + normalizeAssistantStatusText, +} from "@/chat/runtime/assistant-status"; + +describe("assistant status presentation", () => { + it("normalizes raw string statuses before rendering", () => { + expect(normalizeAssistantStatusText(" Reading respond.ts... ")).toBe( + "Reading respond.ts", + ); + }); + + it("renders a typed reading status with a compact target", () => { + expect( + buildAssistantStatusPresentation({ + status: makeAssistantStatus("reading", "respond.ts"), + random: () => 0, + }), + ).toEqual({ + key: "reading:respond.ts", + hint: "Reading respond.ts", + visible: "Reading respond.ts", + suggestions: ["Reading respond.ts"], + }); + }); + + it("renders a standalone typed status when no context is available", () => { + expect( + buildAssistantStatusPresentation({ + status: makeAssistantStatus("thinking"), + random: () => 0, + }), + ).toEqual({ + key: "thinking:…", + hint: "Thinking …", + visible: "Thinking …", + suggestions: ["Thinking …"], + }); + }); + + it("renders variants from the declared status kind", () => { + expect( + buildAssistantStatusPresentation({ + status: makeAssistantStatus("searching", "sources"), + random: () => 0.5, + }), + ).toEqual({ + key: "searching:sources", + hint: "Searching sources", + visible: "Probing sources", + suggestions: ["Probing sources", "Searching sources"], + }); + }); + + it("normalizes typed status context before rendering", () => { + expect( + buildAssistantStatusPresentation({ + status: makeAssistantStatus("reading", " respond.ts... "), + random: () => 0, + }), + ).toEqual({ + key: "reading:respond.ts", + hint: "Reading respond.ts", + visible: "Reading respond.ts", + suggestions: ["Reading respond.ts"], + }); + }); +}); diff --git a/packages/junior/tests/unit/runtime/respond-status-formatters.test.ts b/packages/junior/tests/unit/runtime/respond-status-formatters.test.ts index 02de94ea..2f564c9a 100644 --- a/packages/junior/tests/unit/runtime/respond-status-formatters.test.ts +++ b/packages/junior/tests/unit/runtime/respond-status-formatters.test.ts @@ -1,48 +1,50 @@ import { describe, expect, it } from "vitest"; -import { - formatToolStatus, - formatToolStatusWithInput, -} from "@/chat/runtime/tool-status"; +import { makeAssistantStatus } from "@/chat/runtime/assistant-status"; +import { buildToolStatus } from "@/chat/runtime/tool-status"; describe("tool status formatters", () => { it("avoids infrastructure language in shell statuses", () => { - expect(formatToolStatus("bash")).toBe("Working in the shell"); - expect(formatToolStatusWithInput("bash", { command: "pnpm test" })).toBe( - "Running pnpm", + expect(buildToolStatus("bash", {})).toEqual( + makeAssistantStatus("running", "shell"), + ); + expect(buildToolStatus("bash", { command: "pnpm test" })).toEqual( + makeAssistantStatus("running", "pnpm"), ); expect( - formatToolStatusWithInput("bash", { + buildToolStatus("bash", { command: 'CI=1 DEBUG=1 "/usr/local/bin/pnpm" test', }), - ).toBe("Running pnpm"); + ).toEqual(makeAssistantStatus("running", "pnpm")); }); it("keeps file statuses free of sandbox wording", () => { expect( - formatToolStatusWithInput("readFile", { path: "/workspace/src/app.ts" }), - ).toBe("Reading file app.ts"); + buildToolStatus("readFile", { path: "/workspace/src/app.ts" }), + ).toEqual(makeAssistantStatus("reading", "app.ts")); expect( - formatToolStatusWithInput("writeFile", { path: "/workspace/src/app.ts" }), - ).toBe("Updating file app.ts"); + buildToolStatus("writeFile", { path: "/workspace/src/app.ts" }), + ).toEqual(makeAssistantStatus("updating", "app.ts")); }); it("formats MCP tool names as provider/tool", () => { - expect(formatToolStatus("mcp__notion__notion-search")).toBe( - "Running notion/notion-search", + expect(buildToolStatus("mcp__notion__notion-search", {})).toEqual( + makeAssistantStatus("running", "notion/notion-search"), + ); + expect(buildToolStatus("mcp__demo__ping", {})).toEqual( + makeAssistantStatus("running", "demo/ping"), ); - expect(formatToolStatus("mcp__demo__ping")).toBe("Running demo/ping"); }); it("keeps MCP dispatcher statuses functional", () => { expect( - formatToolStatusWithInput("searchTools", { query: "holiday schedule" }), - ).toBe('Searching tools for "holiday schedule"'); + buildToolStatus("searchTools", { query: "holiday schedule" }), + ).toEqual(makeAssistantStatus("searching", '"holiday schedule"')); expect( - formatToolStatusWithInput("searchTools", { + buildToolStatus("searchTools", { query: "holiday schedule", provider: "notion", }), - ).toBe('Searching notion tools for "holiday schedule"'); + ).toEqual(makeAssistantStatus("searching", 'notion "holiday schedule"')); }); }); diff --git a/packages/junior/tests/unit/slack/bot-handlers.test.ts b/packages/junior/tests/unit/slack/bot-handlers.test.ts index fe9f785a..5bea68c8 100644 --- a/packages/junior/tests/unit/slack/bot-handlers.test.ts +++ b/packages/junior/tests/unit/slack/bot-handlers.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { JuniorRuntimeServiceOverrides } from "@/chat/app/services"; +import { makeAssistantStatus } from "@/chat/runtime/assistant-status"; import { RetryableTurnError } from "@/chat/runtime/turn"; import { FakeSlackAdapter, @@ -580,7 +581,9 @@ describe("bot handlers (integration)", () => { services: { replyExecutor: { generateAssistantReply: async (_prompt, context) => { - await context?.onStatus?.("Listing channel messages..."); + await context?.onStatus?.( + makeAssistantStatus("listing", "channel messages"), + ); return { text: "Done.", diagnostics: {