diff --git a/src/QueryEngine.test.ts b/src/QueryEngine.test.ts deleted file mode 100644 index a06e3087..00000000 --- a/src/QueryEngine.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test' -import { setIsInteractive } from './bootstrap/state.js' -import { drainSdkEvents, enqueueSdkEvent } from './utils/sdkEventQueue.js' - -process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? 'test-key' - -type Deferred = { - promise: Promise - resolve: (value: T) => void -} - -function createDeferred(): Deferred { - let resolve!: (value: T) => void - const promise = new Promise(value => { - resolve = value - }) - return { promise, resolve } -} - -const processUserInputMock = mock(async () => ({ - messages: [], - shouldQuery: false, - resultText: 'done', -})) - -mock.module('./utils/processUserInput/processUserInput.js', () => ({ - processUserInput: processUserInputMock, -})) - -mock.module('./utils/queryContext.js', () => ({ - fetchSystemPromptParts: mock(async () => ({ - defaultSystemPrompt: [], - userContext: {}, - systemContext: {}, - })), -})) - -mock.module('./utils/messages/systemInit.js', () => ({ - buildSystemInitMessage: mock(() => ({ - type: 'system', - subtype: 'init', - session_id: 'test-session', - uuid: 'init-message', - model: 'test-model', - })), - sdkCompatToolName: (name: string) => name, -})) - -const { QueryEngine } = await import('./QueryEngine.js') - -function makeEngine() { - return new QueryEngine({ - cwd: process.cwd(), - tools: [], - commands: [], - mcpClients: [], - agents: [], - canUseTool: async () => ({ behavior: 'allow' }) as never, - getAppState: () => ({ - fastMode: false, - mcp: { clients: [], tools: [] }, - toolPermissionContext: { - mode: 'default', - alwaysAllowRules: { command: [] }, - additionalWorkingDirectories: new Map(), - }, - }) as never, - setAppState: () => {}, - readFileCache: new Map() as never, - }) -} - -function timeout(ms: number, value: T): Promise { - return new Promise(resolve => setTimeout(() => resolve(value), ms)) -} - -describe('QueryEngine slash command event streaming', () => { - beforeEach(() => { - processUserInputMock.mockClear() - drainSdkEvents() - setIsInteractive(false) - }) - - test('drains SDK task events while pre-query slash command processing is still pending', async () => { - const deferred = createDeferred<{ - messages: [] - shouldQuery: false - resultText: string - }>() - processUserInputMock.mockImplementationOnce(async () => { - enqueueSdkEvent({ - type: 'system', - subtype: 'task_started', - task_id: 'slash-agent-task', - description: 'Agent Explore', - task_type: 'slash_agent', - prompt: 'inspect recent changes', - }) - return deferred.promise - }) - - const iterator = makeEngine().submitMessage('/agent Explore inspect recent changes') - const first = await Promise.race([ - iterator.next(), - timeout(350, null), - ]) - - expect(first).not.toBeNull() - expect(first?.value).toMatchObject({ - type: 'system', - subtype: 'task_started', - task_id: 'slash-agent-task', - description: 'Agent Explore', - }) - - deferred.resolve({ - messages: [], - shouldQuery: false, - resultText: 'done', - }) - for await (const _message of iterator) { - // Drain the generator after releasing the pending slash command. - } - }) -}) diff --git a/src/QueryEngine.ts b/src/QueryEngine.ts index f65f6d2c..5118634f 100644 --- a/src/QueryEngine.ts +++ b/src/QueryEngine.ts @@ -74,12 +74,10 @@ import { } from './utils/processUserInput/processUserInput.js' import { fetchSystemPromptParts } from './utils/queryContext.js' import { setCwd } from './utils/Shell.js' -import { drainSdkEvents } from './utils/sdkEventQueue.js' import { flushSessionStorage, recordTranscript, } from './utils/sessionStorage.js' -import { sleep } from './utils/sleep.js' import { asSystemPrompt } from './utils/systemPromptType.js' import { resolveThemeSetting } from './utils/systemTheme.js' import { @@ -122,8 +120,6 @@ const getCoordinatorUserContext: ( : () => ({}) /* eslint-enable @typescript-eslint/no-require-imports */ -const PRE_QUERY_SDK_EVENT_DRAIN_INTERVAL_MS = 100 - // Dead code elimination: conditional import for snip compaction /* eslint-disable @typescript-eslint/no-require-imports */ const snipModule = feature('HISTORY_SNIP') @@ -414,7 +410,13 @@ export class QueryEngine { } } - const processUserInputPromise = processUserInput({ + const { + messages: messagesFromUserInput, + shouldQuery, + allowedTools, + model: modelFromUserInput, + resultText, + }: ProcessUserInputBaseResult = await processUserInput({ input: prompt, mode: 'prompt', setToolJSX: () => {}, @@ -427,36 +429,6 @@ export class QueryEngine { isMeta: options?.isMeta, querySource: 'sdk', }) - .then( - value => ({ ok: true as const, value }), - error => ({ ok: false as const, error }), - ) - - let processUserInputOutcome: Awaited | null = null - while (!processUserInputOutcome) { - const outcome = await Promise.race([ - processUserInputPromise, - sleep(PRE_QUERY_SDK_EVENT_DRAIN_INTERVAL_MS).then(() => null), - ]) - for (const event of drainSdkEvents()) { - yield event - } - if (outcome) { - processUserInputOutcome = outcome - } - } - - if (!processUserInputOutcome.ok) { - throw processUserInputOutcome.error - } - - const { - messages: messagesFromUserInput, - shouldQuery, - allowedTools, - model: modelFromUserInput, - resultText, - }: ProcessUserInputBaseResult = processUserInputOutcome.value // Push new messages, including user input and any attachments this.mutableMessages.push(...messagesFromUserInput) diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 67f20f31..6d44be62 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -14,9 +14,23 @@ describe('/agent command', () => { expect(parseAgentCommandArgs('debugger')).toBeNull() }) - test('passes only the prompt body to forked execution', async () => { + test('instructs the normal chat loop to use the selected agent', async () => { await expect(agentCommand.getPromptForCommand('debugger inspect auth', {} as never)).resolves.toEqual([ - { type: 'text', text: 'inspect auth' }, + { + type: 'text', + text: [ + 'Use the Agent tool with subagent_type "debugger" to handle this request.', + 'Pass this exact prompt to that agent:', + '', + 'inspect auth', + ].join('\n'), + }, ]) }) + + test('shows usage when building a prompt without an agent prompt', async () => { + await expect(agentCommand.getPromptForCommand('debugger', {} as never)).rejects.toThrow( + 'Usage: /agent ', + ) + }) }) diff --git a/src/commands/agent.ts b/src/commands/agent.ts index c1120c9e..ac1a80c7 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,5 +1,7 @@ import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js' import type { Command } from '../commands.js' +import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js' +import { MalformedCommandError } from '../utils/errors.js' export type ParsedAgentCommandArgs = { agentType: string @@ -26,13 +28,22 @@ const agentCommand: Command = { progressMessage: 'running agent', contentLength: 0, source: 'builtin', - context: 'fork', + allowedTools: [AGENT_TOOL_NAME], async getPromptForCommand(args): Promise { const parsed = parseAgentCommandArgs(args) + if (!parsed) { + throw new MalformedCommandError('Usage: /agent ') + } + return [ { type: 'text', - text: parsed?.prompt ?? args.trim(), + text: [ + `Use the ${AGENT_TOOL_NAME} tool with subagent_type "${parsed.agentType}" to handle this request.`, + 'Pass this exact prompt to that agent:', + '', + parsed.prompt, + ].join('\n'), }, ] }, diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index 5ca7d3b7..d4c518a7 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -395,44 +395,6 @@ describe('WebSocket goal command events', () => { })).toBe(false) }) - it('allows current /agent task events through the pre-turn mute gate', () => { - const shouldForward = createCurrentTurnLocalCommandForwarder( - parseSlashCommand('/agent Explore inspect recent changes'), - ) - - expect(shouldForward({ - type: 'system', - subtype: 'task_started', - task_type: 'slash_agent', - task_id: 'slash-agent-1', - description: 'Agent Explore', - })).toBe(true) - expect(shouldForward({ - type: 'system', - subtype: 'task_progress', - task_type: 'slash_agent', - task_id: 'slash-agent-1', - summary: 'Using Read', - })).toBe(true) - expect(shouldForward({ - type: 'system', - subtype: 'task_notification', - task_type: 'slash_agent', - task_id: 'slash-agent-1', - status: 'completed', - })).toBe(true) - - const unrelatedCommandForwarder = createCurrentTurnLocalCommandForwarder( - parseSlashCommand('/goal ship the smoke test'), - ) - expect(unrelatedCommandForwarder({ - type: 'system', - subtype: 'task_started', - task_type: 'slash_agent', - task_id: 'slash-agent-1', - description: 'Agent Explore', - })).toBe(false) - }) }) describe('WebSocket stream event translation', () => { diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 005719c7..02bc6760 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -1809,9 +1809,6 @@ export function createCurrentTurnLocalCommandForwarder( let awaitingCurrentTurnLocalCommandOutput = false return (cliMsg: any) => { - if (command?.commandName === 'agent' && isSlashAgentTaskEvent(cliMsg)) { - return true - } if (command && isMatchingCurrentTurnLocalCommand(cliMsg, command)) { awaitingCurrentTurnLocalCommandOutput = true return true @@ -1837,17 +1834,6 @@ export function createCurrentTurnLocalCommandForwarder( } } -function isSlashAgentTaskEvent(cliMsg: any): boolean { - if (cliMsg?.type !== 'system' || cliMsg?.task_type !== 'slash_agent') { - return false - } - return ( - cliMsg.subtype === 'task_started' || - cliMsg.subtype === 'task_progress' || - cliMsg.subtype === 'task_notification' - ) -} - function isMatchingCurrentTurnLocalCommand( cliMsg: any, command: NonNullable>, diff --git a/src/utils/processUserInput/processSlashCommand.test.ts b/src/utils/processUserInput/processSlashCommand.test.ts index 9a91828c..6d3ab5e2 100644 --- a/src/utils/processUserInput/processSlashCommand.test.ts +++ b/src/utils/processUserInput/processSlashCommand.test.ts @@ -1,10 +1,8 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test' -import { setIsInteractive } from '../../bootstrap/state.js' import agentCommand from '../../commands/agent.js' import type { ToolUseContext } from '../../Tool.js' import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' import { createAssistantMessage } from '../messages.js' -import { drainSdkEvents } from '../sdkEventQueue.js' process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? 'test-key' @@ -51,11 +49,9 @@ function makeContext(activeAgents: AgentDefinition[]): ToolUseContext { describe('/agent slash command processing', () => { beforeEach(() => { runAgentMock.mockClear() - drainSdkEvents() - setIsInteractive(true) }) - test('runs the selected agent with only the prompt body', async () => { + test('routes the selected agent through the normal chat loop', async () => { const result = await processSlashCommand( '/agent debugger fix failing tests', [], @@ -65,23 +61,28 @@ describe('/agent slash command processing', () => { () => {}, ) - expect(result.shouldQuery).toBe(false) - expect(runAgentMock.mock.calls.length).toBe(1) + expect(result.shouldQuery).toBe(true) + expect(runAgentMock.mock.calls.length).toBe(0) - const params = runAgentMock.mock.calls[0]?.[0] as { - agentDefinition: AgentDefinition - promptMessages: Array<{ message: { content: string } }> - } - expect(params.agentDefinition.agentType).toBe('debugger') - expect(params.promptMessages[0]?.message.content).toBe('fix failing tests') + expect( + result.messages.some( + message => + message.type === 'user' && + typeof message.message.content === 'string' && + message.message.content.includes(''), + ), + ).toBe(false) - const stdout = result.messages.find( - message => - message.type === 'user' && - typeof message.message.content === 'string' && - message.message.content.includes(''), + const metaPrompt = result.messages.find( + message => message.type === 'user' && message.isMeta, ) - expect(stdout?.message.content).toContain('debugger result') + const metaPromptText = Array.isArray(metaPrompt?.message.content) + ? metaPrompt.message.content + .map(block => ('text' in block ? block.text : '')) + .join('\n') + : '' + expect(metaPromptText).toContain('subagent_type "debugger"') + expect(metaPromptText).toContain('fix failing tests') }) test('shows usage when the agent prompt is missing', async () => { @@ -103,37 +104,4 @@ describe('/agent slash command processing', () => { ).toBe(true) }) - test('emits foreground agent task events for desktop streaming', async () => { - setIsInteractive(false) - - await processSlashCommand( - '/agent debugger fix failing tests', - [], - [], - [], - makeContext([makeAgent('general-purpose'), makeAgent('debugger')]), - () => {}, - ) - - const events = drainSdkEvents() - expect(events.map(event => event.subtype)).toEqual([ - 'task_started', - 'task_progress', - 'task_notification', - ]) - expect(events[0]).toMatchObject({ - type: 'system', - subtype: 'task_started', - description: 'Agent debugger', - task_type: 'slash_agent', - prompt: 'fix failing tests', - }) - expect(events[2]).toMatchObject({ - type: 'system', - subtype: 'task_notification', - status: 'completed', - summary: 'Agent debugger completed', - result: 'debugger result', - }) - }) }) diff --git a/src/utils/processUserInput/processSlashCommand.tsx b/src/utils/processUserInput/processSlashCommand.tsx index eadaebb5..b5806732 100644 --- a/src/utils/processUserInput/processSlashCommand.tsx +++ b/src/utils/processUserInput/processSlashCommand.tsx @@ -2,7 +2,6 @@ import { feature } from 'bun:bundle'; import type { ContentBlockParam, TextBlockParam } from '@anthropic-ai/sdk/resources'; import { randomUUID } from 'crypto'; import { setPromptId } from 'src/bootstrap/state.js'; -import { parseAgentCommandArgs } from 'src/commands/agent.js'; import { builtInCommandNames, type Command, type CommandBase, findCommand, getCommand, getCommandName, hasCommand, type PromptCommand } from 'src/commands.js'; import { NO_CONTENT_MESSAGE } from 'src/constants/messages.js'; import type { SetToolJSXFn, ToolUseContext } from 'src/Tool.js'; @@ -39,7 +38,6 @@ import { hasPermissionsToUseTool } from '../permissions/permissions.js'; import { isOfficialMarketplaceName, parsePluginIdentifier } from '../plugins/pluginIdentifier.js'; import { isRestrictedToPluginOnly, isSourceAdminTrusted } from '../settings/pluginOnlyPolicy.js'; import { parseSlashCommand } from '../slashCommandParsing.js'; -import { enqueueSdkEvent } from '../sdkEventQueue.js'; import { sleep } from '../sleep.js'; import { recordSkillUsage } from '../suggestions/skillUsageTracking.js'; import { logOTelEvent, redactIfDisabled } from '../telemetry/events.js'; @@ -63,10 +61,6 @@ const MCP_SETTLE_TIMEOUT_MS = 10_000; */ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, args: string, context: ProcessUserInputContext, precedingInputBlocks: ContentBlockParam[], setToolJSX: SetToolJSXFn, canUseTool: CanUseToolFn): Promise { const agentId = createAgentId(); - const dynamicAgentCommand = command.name === 'agent' ? parseAgentCommandArgs(args) : null; - if (command.name === 'agent' && !dynamicAgentCommand) { - throw new MalformedCommandError('Usage: /agent '); - } const pluginMarketplace = command.pluginInfo ? parsePluginIdentifier(command.pluginInfo.repository).marketplace : undefined; logEvent('tengu_slash_command_forked', { command_name: command.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, @@ -84,10 +78,7 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a modifiedGetAppState, baseAgent, promptMessages - } = await prepareForkedCommandContext(command, args, context, dynamicAgentCommand ? { - agentType: dynamicAgentCommand.agentType, - requireAgentType: true - } : undefined); + } = await prepareForkedCommandContext(command, args, context); // Merge skill's effort into the agent definition so runAgent applies it const agentDefinition = command.effort !== undefined ? { @@ -198,26 +189,6 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a const progressMessages: ProgressMessage[] = []; const parentToolUseID = `forked-command-${command.name}`; let toolUseCounter = 0; - const shouldEmitAgentTaskEvents = command.name === 'agent' && dynamicAgentCommand !== null; - const sdkTaskId = shouldEmitAgentTaskEvents ? `slash-agent-${agentId}` : null; - const sdkToolUseId = shouldEmitAgentTaskEvents ? `${parentToolUseID}-${agentId}` : undefined; - const sdkDescription = dynamicAgentCommand ? `Agent ${dynamicAgentCommand.agentType}` : `/${getCommandName(command)}`; - const sdkPrompt = dynamicAgentCommand?.prompt ?? args; - const sdkStartTime = Date.now(); - let sdkProgressTokens = 0; - let sdkToolUses = 0; - - if (sdkTaskId) { - enqueueSdkEvent({ - type: 'system', - subtype: 'task_started', - task_id: sdkTaskId, - tool_use_id: sdkToolUseId, - description: sdkDescription, - task_type: 'slash_agent', - prompt: sdkPrompt - }); - } // Helper to create a progress message from an agent message const createProgressMessage = (message: AssistantMessage | NormalizedUserMessage): ProgressMessage => { @@ -250,28 +221,6 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a }); }; - const emitAgentTaskProgress = (message: AssistantMessage | NormalizedUserMessage): void => { - if (!sdkTaskId) return; - const summary = summarizeAgentProgressMessage(message); - sdkProgressTokens += getApproximateMessageTokenCount(message); - sdkToolUses += countAgentToolUses(message); - enqueueSdkEvent({ - type: 'system', - subtype: 'task_progress', - task_id: sdkTaskId, - tool_use_id: sdkToolUseId, - task_type: 'slash_agent', - description: sdkDescription, - summary: summary.summary, - last_tool_name: summary.lastToolName, - usage: { - total_tokens: sdkProgressTokens, - tool_uses: sdkToolUses, - duration_ms: Date.now() - sdkStartTime - } - }); - }; - // Show initial "Initializing…" state updateProgress(); @@ -303,7 +252,6 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a const normalizedMsg = normalizedNew[0]; if (normalizedMsg && normalizedMsg.type === 'assistant') { progressMessages.push(createProgressMessage(message)); - emitAgentTaskProgress(message); updateProgress(); } } @@ -313,30 +261,11 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a const normalizedMsg = normalizedNew[0]; if (normalizedMsg && normalizedMsg.type === 'user') { progressMessages.push(createProgressMessage(normalizedMsg)); - emitAgentTaskProgress(normalizedMsg); updateProgress(); } } } } catch (err) { - if (sdkTaskId) { - enqueueSdkEvent({ - type: 'system', - subtype: 'task_notification', - task_id: sdkTaskId, - tool_use_id: sdkToolUseId, - task_type: 'slash_agent', - status: 'failed', - output_file: '', - summary: `${sdkDescription} failed`, - result: err instanceof Error ? err.message : String(err), - usage: { - total_tokens: sdkProgressTokens, - tool_uses: sdkToolUses, - duration_ms: Date.now() - sdkStartTime - } - }); - } throw err; } finally { // Clear the progress display @@ -350,25 +279,6 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a resultText = `[ANT-ONLY] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}\n${resultText}`; } - if (sdkTaskId) { - enqueueSdkEvent({ - type: 'system', - subtype: 'task_notification', - task_id: sdkTaskId, - tool_use_id: sdkToolUseId, - task_type: 'slash_agent', - status: 'completed', - output_file: '', - summary: `${sdkDescription} completed`, - result: resultText, - usage: { - total_tokens: sdkProgressTokens, - tool_uses: sdkToolUses, - duration_ms: Date.now() - sdkStartTime - } - }); - } - // Return the result as a user message (simulates the agent's output) const messages: UserMessage[] = [createUserMessage({ content: prepareUserContent({ @@ -399,61 +309,6 @@ export function looksLikeCommand(commandName: string): boolean { return !/[^a-zA-Z0-9:\-_]/.test(commandName); } -function summarizeAgentProgressMessage(message: AssistantMessage | NormalizedUserMessage): { summary: string; lastToolName?: string } { - const content = message.message.content; - const blocks = Array.isArray(content) ? content : [{ - type: 'text', - text: typeof content === 'string' ? content : '' - }]; - let firstText: string | null = null; - let lastToolName: string | undefined; - - for (const block of blocks) { - if (!block || typeof block !== 'object') continue; - const typedBlock = block as { type?: unknown; text?: unknown; name?: unknown }; - if (typedBlock.type === 'tool_use' && typeof typedBlock.name === 'string') { - lastToolName = typedBlock.name; - } - if (!firstText && typedBlock.type === 'text' && typeof typedBlock.text === 'string') { - firstText = typedBlock.text.trim(); - } - } - - if (lastToolName) return { summary: `Using ${lastToolName}`, lastToolName }; - if (firstText) return { summary: truncateProgressSummary(firstText) }; - return { summary: 'Agent is working' }; -} - -function getApproximateMessageTokenCount(message: AssistantMessage | NormalizedUserMessage): number { - const content = message.message.content; - if (typeof content === 'string') return Math.ceil(content.length / 4); - if (!Array.isArray(content)) return 0; - const textLength = content.reduce((total, block) => { - if (!block || typeof block !== 'object') return total; - const text = (block as { text?: unknown; content?: unknown }).text; - if (typeof text === 'string') return total + text.length; - const nestedContent = (block as { content?: unknown }).content; - if (typeof nestedContent === 'string') return total + nestedContent.length; - return total; - }, 0); - return Math.ceil(textLength / 4); -} - -function countAgentToolUses(message: AssistantMessage | NormalizedUserMessage): number { - const content = message.message.content; - if (!Array.isArray(content)) return 0; - return content.filter(block => { - if (!block || typeof block !== 'object') return false; - return (block as { type?: unknown }).type === 'tool_use'; - }).length; -} - -function truncateProgressSummary(text: string): string { - const normalized = text.replace(/\s+/g, ' ').trim(); - if (normalized.length <= 120) return normalized; - return `${normalized.slice(0, 117)}...`; -} - export async function processSlashCommand(inputString: string, precedingInputBlocks: ContentBlockParam[], imageContentBlocks: ContentBlockParam[], attachmentMessages: AttachmentMessage[], context: ProcessUserInputContext, setToolJSX: SetToolJSXFn, uuid?: string, isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn): Promise { const parsed = parseSlashCommand(inputString); if (!parsed) { diff --git a/src/utils/sdkEventQueue.ts b/src/utils/sdkEventQueue.ts index 05975c74..abdb112f 100644 --- a/src/utils/sdkEventQueue.ts +++ b/src/utils/sdkEventQueue.ts @@ -20,7 +20,6 @@ type TaskProgressEvent = { task_id: string tool_use_id?: string description: string - task_type?: string usage: { total_tokens: number tool_uses: number @@ -44,7 +43,6 @@ type TaskNotificationSdkEvent = { subtype: 'task_notification' task_id: string tool_use_id?: string - task_type?: string status: 'completed' | 'failed' | 'stopped' output_file: string summary: string