From 1531482363507fa151f0266166f6da723b9d0206 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sat, 30 May 2026 01:47:41 +0900 Subject: [PATCH] refactor: split Claude agent runner entrypoint (#200) --- quality/code-quality-budgets.json | 5 - runners/agent-runner/src/claude-cli.ts | 10 + .../agent-runner/src/claude-query-runner.ts | 596 ++++++++++++++ .../agent-runner/src/compaction-boundary.ts | 29 + runners/agent-runner/src/index.ts | 730 +----------------- runners/agent-runner/src/runner-input.ts | 13 + runners/agent-runner/src/session-command.ts | 151 ++++ .../test/compaction-boundary.test.ts | 35 + 8 files changed, 864 insertions(+), 705 deletions(-) create mode 100644 runners/agent-runner/src/claude-cli.ts create mode 100644 runners/agent-runner/src/claude-query-runner.ts create mode 100644 runners/agent-runner/src/compaction-boundary.ts create mode 100644 runners/agent-runner/src/runner-input.ts create mode 100644 runners/agent-runner/src/session-command.ts create mode 100644 runners/agent-runner/test/compaction-boundary.test.ts diff --git a/quality/code-quality-budgets.json b/quality/code-quality-budgets.json index 7c7a1f3..dae7d95 100644 --- a/quality/code-quality-budgets.json +++ b/quality/code-quality-budgets.json @@ -27,11 +27,6 @@ "maxLines": 4875, "owner": "dashboard stylesheet hotspot" }, - "runners/agent-runner/src/index.ts": { - "maxFunctionLines": 506, - "maxComplexity": 81, - "owner": "agent runner hotspot" - }, "runners/agent-runner/test/reviewer-runtime.test.ts": { "maxFunctionLines": 271, "owner": "agent runner test hotspot" diff --git a/runners/agent-runner/src/claude-cli.ts b/runners/agent-runner/src/claude-cli.ts new file mode 100644 index 0000000..ed7b7a1 --- /dev/null +++ b/runners/agent-runner/src/claude-cli.ts @@ -0,0 +1,10 @@ +import { resolveBundledClaudeCodeExecutable } from './bundled-cli-path.js'; + +let cachedClaudeCliPath: string | null = null; + +export function getClaudeCliPath(log: (message: string) => void): string { + if (cachedClaudeCliPath) return cachedClaudeCliPath; + cachedClaudeCliPath = resolveBundledClaudeCodeExecutable(); + log(`Resolved bundled Claude Code CLI: ${cachedClaudeCliPath}`); + return cachedClaudeCliPath; +} diff --git a/runners/agent-runner/src/claude-query-runner.ts b/runners/agent-runner/src/claude-query-runner.ts new file mode 100644 index 0000000..a225a0c --- /dev/null +++ b/runners/agent-runner/src/claude-query-runner.ts @@ -0,0 +1,596 @@ +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { EJCLAW_ENV, IPC_POLL_MS } from 'ejclaw-runners-shared'; + +import { compactBoundaryFromMessage } from './compaction-boundary.js'; +import { getClaudeCliPath } from './claude-cli.js'; +import { drainIpcInput, shouldClose } from './ipc-input.js'; +import { buildEjclawMcpServerConfig } from './mcp-config.js'; +import { + MessageStream, + buildCompactionOutput, + buildMultimodalContent, + extractAssistantText, + normalizeStructuredOutput, + type RunnerCompaction, + writeOutput, +} from './output-protocol.js'; +import { buildClaudeReadonlySandboxSettings } from './reviewer-runtime.js'; +import { + createPreCompactHook, + createReviewerBashGuardHook, + createSanitizeBashHook, +} from './runner-hooks.js'; +import type { RunnerInput } from './runner-input.js'; +import { + TopLevelAgentTaskTracker, + buildTaskNotificationOutput, + buildTaskProgressOutput, + buildTaskStartedOutput, +} from './task-progress-mapping.js'; + +export interface ClaudeQueryPaths { + groupDir: string; + groupFolder: string; + hostTasksDir: string; + ipcInputCloseSentinel: string; + ipcInputDir: string; + workDir: string; +} + +export interface RunClaudeQueryArgs { + prompt: string; + sessionId: string | undefined; + mcpServerPath: string; + runnerInput: RunnerInput; + sdkEnv: Record; + reviewerRuntime: boolean; + claudeReadonlyReviewerRuntime: boolean; + claudeReadonlySandboxMode: 'strict' | 'best-effort' | null; + abortController: AbortController; + paths: ClaudeQueryPaths; + log: (message: string) => void; +} + +export interface ClaudeQueryResult { + newSessionId?: string; + closedDuringQuery: boolean; + terminalResultObserved: boolean; + compaction?: RunnerCompaction; +} + +class ClaudeQueryRunner { + private readonly stream: MessageStream; + private readonly trackedAgentTasks = new TopLevelAgentTaskTracker(); + private readonly extraDirs: string[] = []; + private readonly effectiveCwd: string; + private ipcPolling = true; + private closedDuringQuery = false; + private newSessionId: string | undefined; + private messageCount = 0; + private resultCount = 0; + private terminalResultObserved = false; + private pendingProgressText: string | null = null; + private compaction: RunnerCompaction | undefined; + + constructor(private readonly args: RunClaudeQueryArgs) { + this.stream = new MessageStream((text) => + buildMultimodalContent(text, args.log), + ); + this.effectiveCwd = args.paths.workDir || args.paths.groupDir; + if (args.paths.workDir && args.paths.workDir !== args.paths.groupDir) { + this.extraDirs.push(args.paths.groupDir); + args.log( + `Work directory override: ${args.paths.workDir} (group dir added to additionalDirectories)`, + ); + } + if (this.extraDirs.length > 0) { + args.log(`Additional directories: ${this.extraDirs.join(', ')}`); + } + } + + async run(): Promise { + this.stream.push(this.args.prompt); + this.startIpcPolling(); + this.logRuntimeConfig(); + + for await (const message of query({ + prompt: this.stream, + options: this.buildQueryOptions(), + })) { + this.messageCount++; + const msgType = + typeof message === 'object' && + message !== null && + (message as { type?: string }).type === 'system' + ? `system/${(message as { subtype?: string }).subtype}` + : (message as { type?: string }).type; + this.args.log(`[msg #${this.messageCount}] type=${msgType}`); + + if (this.handleMessage(message)) break; + } + + this.flushRemainingPendingText(); + this.ipcPolling = false; + this.args.log( + `Query done. Messages: ${this.messageCount}, results: ${this.resultCount}, closedDuringQuery: ${this.closedDuringQuery}`, + ); + return { + newSessionId: this.newSessionId, + closedDuringQuery: this.closedDuringQuery, + terminalResultObserved: this.terminalResultObserved, + compaction: this.compaction, + }; + } + + private startIpcPolling(): void { + const pollIpcDuringQuery = () => { + if (!this.ipcPolling) return; + if (shouldClose(this.args.paths.ipcInputCloseSentinel)) { + this.handleCloseSentinel(); + return; + } + const messages = drainIpcInput( + this.args.paths.ipcInputDir, + this.args.log, + ); + for (const text of messages) { + this.args.log( + `Piping IPC message into active query (${text.length} chars)`, + ); + this.stream.push(text); + } + setTimeout(pollIpcDuringQuery, IPC_POLL_MS); + }; + setTimeout(pollIpcDuringQuery, IPC_POLL_MS); + } + + private handleCloseSentinel(): void { + this.args.log('Close sentinel detected during query, ending stream'); + if (this.pendingProgressText && !this.terminalResultObserved) { + this.args.log( + `Flushing pending text before close (${this.pendingProgressText.length} chars)`, + ); + writeOutput({ + status: 'success', + ...normalizeStructuredOutput(this.pendingProgressText), + newSessionId: this.newSessionId, + }); + this.pendingProgressText = null; + this.terminalResultObserved = true; + this.resultCount++; + } + this.closedDuringQuery = true; + this.stream.end(); + this.ipcPolling = false; + } + + private buildQueryOptions() { + const readonlyReviewerRuntime = + this.args.reviewerRuntime || this.args.claudeReadonlyReviewerRuntime; + const readonlyProtectedPaths = [ + this.effectiveCwd, + ...this.extraDirs, + ].filter((value): value is string => Boolean(value)); + const claudeReadonlySandboxSettings = + this.args.claudeReadonlyReviewerRuntime && + this.args.claudeReadonlySandboxMode + ? buildClaudeReadonlySandboxSettings( + readonlyProtectedPaths, + undefined, + this.args.claudeReadonlySandboxMode, + ) + : undefined; + + return { + pathToClaudeCodeExecutable: getClaudeCliPath(this.args.log), + cwd: this.effectiveCwd, + model: this.resolveModel(), + thinking: this.resolveThinking(), + effort: this.resolveEffort(), + additionalDirectories: + this.extraDirs.length > 0 ? this.extraDirs : undefined, + resume: this.args.sessionId, + allowedTools: this.buildAllowedTools(readonlyReviewerRuntime), + env: this.args.sdkEnv, + permissionMode: 'bypassPermissions' as const, + allowDangerouslySkipPermissions: true, + ...(claudeReadonlySandboxSettings + ? { sandbox: claudeReadonlySandboxSettings } + : {}), + settingSources: ['project', 'user'] as ('project' | 'user')[], + abortController: this.args.abortController, + mcpServers: { + ejclaw: buildEjclawMcpServerConfig(this.args.mcpServerPath, { + chatJid: this.args.runnerInput.chatJid, + groupFolder: this.args.runnerInput.groupFolder, + isMain: this.args.runnerInput.isMain, + agentType: process.env[EJCLAW_ENV.agentType] || 'claude-code', + roomRole: this.args.runnerInput.roomRoleContext?.role || '', + ipcDir: process.env[EJCLAW_ENV.ipcDir], + hostIpcDir: process.env[EJCLAW_ENV.hostIpcDir], + }), + }, + hooks: { + PreCompact: [ + { + hooks: [ + createPreCompactHook({ + assistantName: this.args.runnerInput.assistantName, + groupDir: this.args.paths.groupDir, + groupFolder: this.args.paths.groupFolder, + hostTasksDir: this.args.paths.hostTasksDir, + log: this.args.log, + writeOutput, + }), + ], + }, + ], + PreToolUse: [ + { + matcher: 'Bash', + hooks: readonlyReviewerRuntime + ? [createReviewerBashGuardHook(), createSanitizeBashHook()] + : [createSanitizeBashHook()], + }, + ], + }, + agentProgressSummaries: true, + }; + } + + private logRuntimeConfig(): void { + const model = this.resolveModel(); + const thinking = this.resolveThinking(); + const effort = this.resolveEffort(); + if (model) this.args.log(`Using model: ${model}`); + if (thinking) this.args.log(`Thinking config: ${JSON.stringify(thinking)}`); + if (effort) this.args.log(`Effort: ${effort}`); + if (this.args.reviewerRuntime) { + this.args.log('Reviewer runtime restrictions enabled'); + } + if (this.args.claudeReadonlyReviewerRuntime) { + this.args.log( + `Claude host reviewer read-only sandbox enabled (${this.args.claudeReadonlySandboxMode || 'unknown'})`, + ); + } + if (this.args.claudeReadonlySandboxMode === 'best-effort') { + this.args.log( + 'Claude host reviewer sandbox capability unavailable on this host, using best-effort read-only mode', + ); + } + } + + private resolveModel(): string | undefined { + return process.env.CLAUDE_MODEL || undefined; + } + + private resolveThinking(): + | { type: 'adaptive' } + | { type: 'enabled'; budgetTokens?: number } + | { type: 'disabled' } + | undefined { + const thinkingType = process.env.CLAUDE_THINKING || undefined; + const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET + ? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10) + : undefined; + return thinkingType === 'adaptive' + ? { type: 'adaptive' } + : thinkingType === 'enabled' + ? { type: 'enabled', budgetTokens: thinkingBudget } + : thinkingType === 'disabled' + ? { type: 'disabled' } + : undefined; + } + + private resolveEffort(): 'low' | 'medium' | 'high' | 'max' | undefined { + return ( + (process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') || + undefined + ); + } + + private buildAllowedTools(readonlyRuntime: boolean): string[] { + const readonlyTools = [ + 'Bash', + 'Read', + 'Glob', + 'Grep', + 'WebSearch', + 'WebFetch', + 'Task', + 'TaskOutput', + 'TaskStop', + 'TeamCreate', + 'TeamDelete', + 'SendMessage', + 'TodoWrite', + 'ToolSearch', + 'Skill', + 'mcp__ejclaw__*', + ]; + const writeTools = [ + 'Bash', + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'WebSearch', + 'WebFetch', + 'Task', + 'TaskOutput', + 'TaskStop', + 'TeamCreate', + 'TeamDelete', + 'SendMessage', + 'TodoWrite', + 'ToolSearch', + 'Skill', + 'NotebookEdit', + 'mcp__ejclaw__*', + ]; + return readonlyRuntime ? readonlyTools : writeTools; + } + + private handleMessage(message: unknown): boolean { + const type = (message as { type?: string }).type; + if (type !== 'assistant' && this.pendingProgressText) { + this.flushPendingProgressAsIntermediate(); + } + if (type === 'system') this.handleSystemMessage(message); + if (type === 'tool_progress') this.handleToolProgress(message); + if (type === 'tool_use_summary') this.handleToolUseSummary(message); + if (type === 'result') return this.handleResultMessage(message); + if (type === 'assistant') return this.handleAssistantMessage(message); + return false; + } + + private handleSystemMessage(message: unknown): void { + const subtype = (message as { subtype?: string }).subtype; + if (subtype === 'init') { + this.newSessionId = (message as { session_id?: string }).session_id; + this.args.log(`Session initialized: ${this.newSessionId}`); + } + + this.compaction = + compactBoundaryFromMessage(message, this.args.log) ?? this.compaction; + + if (subtype === 'task_notification') { + const tn = message as { + task_id: string; + tool_use_id?: string; + status: string; + summary: string; + }; + this.args.log( + `Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`, + ); + const mapped = buildTaskNotificationOutput( + this.trackedAgentTasks, + tn, + this.newSessionId, + ); + if (mapped) writeOutput(mapped); + } + + if (subtype === 'task_progress') { + const tp = message as Record; + const description = + typeof tp.description === 'string' ? tp.description : ''; + const mapped = buildTaskProgressOutput( + this.trackedAgentTasks, + tp, + this.newSessionId, + ); + if (mapped) { + writeOutput(mapped); + } else if (description) { + this.args.log( + `Skipping long task_progress description (${description.length} chars)`, + ); + } + } + + if (subtype === 'task_started') { + const ts = message as { task_id: string; description?: string }; + const desc = ts.description || ''; + this.args.log( + `Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`, + ); + const mapped = buildTaskStartedOutput( + this.trackedAgentTasks, + ts, + this.newSessionId, + ); + if (mapped) writeOutput(mapped); + } + } + + private handleToolProgress(message: unknown): void { + const tp = message as { + tool_name: string; + elapsed_time_seconds: number; + }; + const label = `${tp.tool_name} (${Math.round(tp.elapsed_time_seconds)}s)`; + this.args.log(`Tool progress: ${label}`); + writeOutput({ + status: 'success', + phase: 'progress', + ...normalizeStructuredOutput(label), + newSessionId: this.newSessionId, + }); + } + + private handleToolUseSummary(message: unknown): void { + const ts = message as { summary: string }; + this.args.log(`Tool use summary: ${ts.summary.slice(0, 200)}`); + writeOutput({ + status: 'success', + phase: 'progress', + ...normalizeStructuredOutput(ts.summary), + newSessionId: this.newSessionId, + }); + } + + private handleResultMessage(message: unknown): boolean { + const resultMessage = message as { + subtype?: string; + result?: string; + errors?: unknown; + stop_reason?: unknown; + duration_ms?: unknown; + duration_api_ms?: unknown; + session_id?: unknown; + }; + this.resultCount++; + let textResult = resultMessage.result || null; + const isError = resultMessage.subtype?.startsWith('error'); + + if (this.pendingProgressText && textResult === this.pendingProgressText) { + this.args.log('Discarding pending progress (matches result)'); + this.pendingProgressText = null; + } else if (this.pendingProgressText) { + if (!textResult) { + this.args.log( + `Promoting pending progress text to result (${this.pendingProgressText.length} chars)`, + ); + textResult = this.pendingProgressText; + } else { + writeOutput({ + status: 'success', + phase: 'intermediate', + result: this.pendingProgressText, + newSessionId: this.newSessionId, + }); + } + this.pendingProgressText = null; + } + + this.args.log( + `Result #${this.resultCount}: subtype=${resultMessage.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`, + ); + if (isError) { + this.writeErrorResult(resultMessage, textResult); + } else { + this.writeSuccessResult(textResult); + } + this.terminalResultObserved = true; + this.ipcPolling = false; + this.stream.end(); + this.args.log('Terminal result observed, ending query stream'); + return true; + } + + private writeErrorResult( + message: { + subtype?: string; + errors?: unknown; + stop_reason?: unknown; + duration_ms?: unknown; + duration_api_ms?: unknown; + session_id?: unknown; + }, + textResult: string | null, + ): void { + const sdkErrors = Array.isArray(message.errors) + ? (message.errors as string[]) + : []; + this.args.log( + `Error result detail: ${JSON.stringify({ + subtype: message.subtype, + result: textResult?.slice(0, 500), + errors: sdkErrors, + stop_reason: message.stop_reason, + duration_ms: message.duration_ms, + duration_api_ms: message.duration_api_ms, + session_id: message.session_id, + })}`, + ); + writeOutput({ + status: 'error', + result: textResult || null, + newSessionId: this.newSessionId, + error: + sdkErrors.length > 0 + ? sdkErrors.join('; ') + : `Agent error: ${message.subtype}`, + ...buildCompactionOutput(this.compaction), + }); + } + + private writeSuccessResult(textResult: string | null): void { + writeOutput({ + status: 'success', + ...normalizeStructuredOutput(textResult || null), + newSessionId: this.newSessionId, + ...buildCompactionOutput(this.compaction), + }); + } + + private handleAssistantMessage(message: unknown): boolean { + this.trackedAgentTasks.rememberAssistantMessage(message); + const stopReason = (message as { stop_reason?: string }).stop_reason; + const textResult = extractAssistantText(message); + if (textResult || stopReason === 'end_turn') { + this.args.log( + `Assistant: stop=${stopReason} text=${textResult ? textResult.length + ' chars' : 'null'}`, + ); + } + if (stopReason === 'end_turn' && textResult) { + this.resultCount++; + this.args.log( + `Terminal assistant turn observed without result event (${textResult.length} chars), ending query stream`, + ); + writeOutput({ + status: 'success', + ...normalizeStructuredOutput(textResult), + newSessionId: this.newSessionId, + ...buildCompactionOutput(this.compaction), + }); + this.terminalResultObserved = true; + this.ipcPolling = false; + this.stream.end(); + return true; + } + if (stopReason !== 'end_turn' && textResult) { + if (this.pendingProgressText) this.flushPendingProgressAsIntermediate(); + this.pendingProgressText = textResult; + this.args.log( + `Intermediate assistant text buffered (${textResult.length} chars, stop=${stopReason})`, + ); + } + return false; + } + + private flushPendingProgressAsIntermediate(): void { + if (!this.pendingProgressText) return; + writeOutput({ + status: 'success', + phase: 'intermediate', + ...normalizeStructuredOutput(this.pendingProgressText), + newSessionId: this.newSessionId, + }); + this.pendingProgressText = null; + } + + private flushRemainingPendingText(): void { + if (!this.pendingProgressText || this.terminalResultObserved) return; + this.args.log( + `Flushing remaining pending progress text as final output (${this.pendingProgressText.length} chars)`, + ); + writeOutput({ + status: 'success', + ...normalizeStructuredOutput(this.pendingProgressText), + newSessionId: this.newSessionId, + ...buildCompactionOutput(this.compaction), + }); + this.terminalResultObserved = true; + this.resultCount++; + } +} + +export function runClaudeQuery( + args: RunClaudeQueryArgs, +): Promise { + return new ClaudeQueryRunner(args).run(); +} diff --git a/runners/agent-runner/src/compaction-boundary.ts b/runners/agent-runner/src/compaction-boundary.ts new file mode 100644 index 0000000..a0742a3 --- /dev/null +++ b/runners/agent-runner/src/compaction-boundary.ts @@ -0,0 +1,29 @@ +import { + compactBoundaryOutput, + type RunnerCompaction, +} from './output-protocol.js'; + +export function compactBoundaryFromMessage( + message: unknown, + log: (message: string) => void, +): RunnerCompaction | undefined { + if ( + !( + typeof message === 'object' && + message !== null && + (message as { type?: string }).type === 'system' && + (message as { subtype?: string }).subtype === 'compact_boundary' + ) + ) { + return undefined; + } + const meta = ( + message as { + compact_metadata?: { trigger?: string; pre_tokens?: number }; + } + ).compact_metadata; + log( + `Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`, + ); + return compactBoundaryOutput(meta?.trigger); +} diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 5bee5bc..5fc1f3b 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -16,23 +16,16 @@ import fs from 'fs'; import path from 'path'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { EJCLAW_ENV, IPC_CLOSE_SENTINEL, IPC_INPUT_SUBDIR, - IPC_POLL_MS, } from 'ejclaw-runners-shared'; import { fileURLToPath } from 'url'; -import { resolveBundledClaudeCodeExecutable } from './bundled-cli-path.js'; -import { - prependRoomRoleHeader, - type RoomRoleContext, -} from './room-role-context.js'; +import { prependRoomRoleHeader } from './room-role-context.js'; import { assertReadonlyWorkspaceRepoConnectivity, - buildClaudeReadonlySandboxSettings, buildReviewerGitGuardEnv, getClaudeReadonlySandboxMode, isArbiterRuntimeEnvEnabled, @@ -40,43 +33,16 @@ import { isReviewerRuntime, isReviewerRuntimeEnvEnabled, } from './reviewer-runtime.js'; -import { drainIpcInput, shouldClose } from './ipc-input.js'; +import { drainIpcInput } from './ipc-input.js'; import { - MessageStream, buildCompactionOutput, - buildMultimodalContent, - compactBoundaryOutput, - extractAssistantText, - normalizeStructuredOutput, readStdin, - type RunnerCompaction, writeOutput, } from './output-protocol.js'; -import { - TopLevelAgentTaskTracker, - buildTaskNotificationOutput, - buildTaskProgressOutput, - buildTaskStartedOutput, -} from './task-progress-mapping.js'; -import { - createPreCompactHook, - createReviewerBashGuardHook, - createSanitizeBashHook, -} from './runner-hooks.js'; import { buildClaudeSdkEnv } from './sdk-env.js'; -import { buildEjclawMcpServerConfig } from './mcp-config.js'; - -interface RunnerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - secrets?: Record; - roomRoleContext?: RoomRoleContext; -} +import { runSessionCommand } from './session-command.js'; +import { runClaudeQuery } from './claude-query-runner.js'; +import type { RunnerInput } from './runner-input.js'; // Paths configurable via env vars. const GROUP_DIR = process.env[EJCLAW_ENV.groupDir] || '/workspace/group'; @@ -94,41 +60,6 @@ function log(message: string): void { console.error(`[agent-runner] ${message}`); } -function compactBoundaryFromMessage( - message: unknown, -): RunnerCompaction | undefined { - if ( - !( - typeof message === 'object' && - message !== null && - (message as { type?: string }).type === 'system' && - (message as { subtype?: string }).subtype === 'compact_boundary' - ) - ) { - return undefined; - } - const meta = ( - message as { - compact_metadata?: { trigger?: string; pre_tokens?: number }; - } - ).compact_metadata; - log( - `Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`, - ); - return compactBoundaryOutput(meta?.trigger); -} - -// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함. -// (SDK 0.2.114의 W7() 헬퍼는 linux-x64-musl를 linux-x64보다 먼저 시도하므로 -// glibc 호스트에서 musl 패키지가 빈 껍데기로 설치돼 있으면 실패한다.) -let cachedClaudeCliPath: string | null = null; -function getClaudeCliPath(): string { - if (cachedClaudeCliPath) return cachedClaudeCliPath; - cachedClaudeCliPath = resolveBundledClaudeCodeExecutable(); - log(`Resolved bundled Claude Code CLI: ${cachedClaudeCliPath}`); - return cachedClaudeCliPath; -} - // Graceful shutdown: SIGTERM → abort SDK query, allowing cleanup const agentAbortController = new AbortController(); process.on('SIGTERM', () => { @@ -136,509 +67,6 @@ process.on('SIGTERM', () => { agentAbortController.abort(); }); -/** - * Run a single query and stream results via writeOutput. - * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false, - * allowing agent teams subagents to run to completion. - * Also pipes IPC messages into the stream during the query. - */ -async function runQuery( - prompt: string, - sessionId: string | undefined, - mcpServerPath: string, - runnerInput: RunnerInput, - sdkEnv: Record, - reviewerRuntime: boolean, - claudeReadonlyReviewerRuntime: boolean, - claudeReadonlySandboxMode: 'strict' | 'best-effort' | null, -): Promise<{ - newSessionId?: string; - closedDuringQuery: boolean; - terminalResultObserved: boolean; - compaction?: RunnerCompaction; -}> { - const stream = new MessageStream((text) => buildMultimodalContent(text, log)); - stream.push(prompt); - - // Poll IPC for follow-up messages and _close sentinel during the query - let ipcPolling = true; - let closedDuringQuery = false; - const pollIpcDuringQuery = () => { - if (!ipcPolling) return; - if (shouldClose(IPC_INPUT_CLOSE_SENTINEL)) { - log('Close sentinel detected during query, ending stream'); - // Flush any buffered text before closing — the for-await loop may not - // reach the post-loop flush code after stream.end(). - if (pendingProgressText && !terminalResultObserved) { - log( - `Flushing pending text before close (${pendingProgressText.length} chars)`, - ); - writeOutput({ - status: 'success', - ...normalizeStructuredOutput(pendingProgressText), - newSessionId, - }); - pendingProgressText = null; - terminalResultObserved = true; - resultCount++; - } - closedDuringQuery = true; - stream.end(); - ipcPolling = false; - return; - } - const messages = drainIpcInput(IPC_INPUT_DIR, log); - for (const text of messages) { - log(`Piping IPC message into active query (${text.length} chars)`); - stream.push(text); - } - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - }; - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - - let newSessionId: string | undefined; - let messageCount = 0; - let resultCount = 0; - let terminalResultObserved = false; - let pendingProgressText: string | null = null; - let compaction: RunnerCompaction | undefined; - const trackedAgentTasks = new TopLevelAgentTaskTracker(); - - // Discover additional directories - const extraDirs: string[] = []; - - // When WORK_DIR is set, use it as cwd and include GROUP_DIR as additional directory - const effectiveCwd = WORK_DIR || GROUP_DIR; - if (WORK_DIR && WORK_DIR !== GROUP_DIR) { - extraDirs.push(GROUP_DIR); - log( - `Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`, - ); - } - if (extraDirs.length > 0) { - log(`Additional directories: ${extraDirs.join(', ')}`); - } - - // Model and thinking configuration from environment - const model = process.env.CLAUDE_MODEL || undefined; - const thinkingType = process.env.CLAUDE_THINKING || undefined; // 'adaptive' | 'enabled' | 'disabled' - const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET - ? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10) - : undefined; - const effort = - (process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') || - undefined; - const thinking = - thinkingType === 'adaptive' - ? { type: 'adaptive' as const } - : thinkingType === 'enabled' - ? { type: 'enabled' as const, budgetTokens: thinkingBudget } - : thinkingType === 'disabled' - ? { type: 'disabled' as const } - : undefined; - - if (model) log(`Using model: ${model}`); - if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`); - if (effort) log(`Effort: ${effort}`); - if (reviewerRuntime) log('Reviewer runtime restrictions enabled'); - if (claudeReadonlyReviewerRuntime) { - log( - `Claude host reviewer read-only sandbox enabled (${claudeReadonlySandboxMode || 'unknown'})`, - ); - } - if (claudeReadonlySandboxMode === 'best-effort') { - log( - 'Claude host reviewer sandbox capability unavailable on this host, using best-effort read-only mode', - ); - } - - const readonlyReviewerRuntime = - reviewerRuntime || claudeReadonlyReviewerRuntime; - const allowedTools = readonlyReviewerRuntime - ? [ - 'Bash', - 'Read', - 'Glob', - 'Grep', - 'WebSearch', - 'WebFetch', - 'Task', - 'TaskOutput', - 'TaskStop', - 'TeamCreate', - 'TeamDelete', - 'SendMessage', - 'TodoWrite', - 'ToolSearch', - 'Skill', - 'mcp__ejclaw__*', - ] - : [ - 'Bash', - 'Read', - 'Write', - 'Edit', - 'Glob', - 'Grep', - 'WebSearch', - 'WebFetch', - 'Task', - 'TaskOutput', - 'TaskStop', - 'TeamCreate', - 'TeamDelete', - 'SendMessage', - 'TodoWrite', - 'ToolSearch', - 'Skill', - 'NotebookEdit', - 'mcp__ejclaw__*', - ]; - - const readonlyProtectedPaths = [effectiveCwd, ...extraDirs].filter( - (value): value is string => Boolean(value), - ); - const claudeReadonlySandboxSettings = - claudeReadonlyReviewerRuntime && claudeReadonlySandboxMode - ? buildClaudeReadonlySandboxSettings( - readonlyProtectedPaths, - undefined, - claudeReadonlySandboxMode, - ) - : undefined; - - for await (const message of query({ - prompt: stream, - options: { - // 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함. - pathToClaudeCodeExecutable: getClaudeCliPath(), - cwd: effectiveCwd, - model, - thinking, - effort, - additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined, - resume: sessionId, - allowedTools, - env: sdkEnv, - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - ...(claudeReadonlySandboxSettings - ? { - sandbox: claudeReadonlySandboxSettings, - } - : {}), - settingSources: ['project', 'user'], - abortController: agentAbortController, - mcpServers: { - ejclaw: buildEjclawMcpServerConfig(mcpServerPath, { - chatJid: runnerInput.chatJid, - groupFolder: runnerInput.groupFolder, - isMain: runnerInput.isMain, - agentType: process.env[EJCLAW_ENV.agentType] || 'claude-code', - roomRole: runnerInput.roomRoleContext?.role || '', - ipcDir: process.env[EJCLAW_ENV.ipcDir], - hostIpcDir: process.env[EJCLAW_ENV.hostIpcDir], - }), - }, - hooks: { - PreCompact: [ - { - hooks: [ - createPreCompactHook({ - assistantName: runnerInput.assistantName, - groupDir: GROUP_DIR, - groupFolder: GROUP_FOLDER, - hostTasksDir: HOST_TASKS_DIR, - log, - writeOutput, - }), - ], - }, - ], - PreToolUse: [ - { - matcher: 'Bash', - hooks: readonlyReviewerRuntime - ? [createReviewerBashGuardHook(), createSanitizeBashHook()] - : [createSanitizeBashHook()], - }, - ], - }, - agentProgressSummaries: true, - }, - })) { - messageCount++; - const msgType = - message.type === 'system' - ? `system/${(message as { subtype?: string }).subtype}` - : message.type; - log(`[msg #${messageCount}] type=${msgType}`); - - // Flush pending intermediate text as a regular message on non-assistant events. - if (message.type !== 'assistant' && pendingProgressText) { - writeOutput({ - status: 'success', - phase: 'intermediate', - ...normalizeStructuredOutput(pendingProgressText), - newSessionId, - }); - pendingProgressText = null; - } - - if (message.type === 'system' && message.subtype === 'init') { - newSessionId = message.session_id; - log(`Session initialized: ${newSessionId}`); - } - - compaction = compactBoundaryFromMessage(message) ?? compaction; - - if ( - message.type === 'system' && - (message as { subtype?: string }).subtype === 'task_notification' - ) { - const tn = message as { - task_id: string; - tool_use_id?: string; - status: string; - summary: string; - }; - log( - `Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`, - ); - const mapped = buildTaskNotificationOutput( - trackedAgentTasks, - tn, - newSessionId, - ); - if (mapped) { - writeOutput(mapped); - } - } - - if ( - message.type === 'system' && - (message as { subtype?: string }).subtype === 'task_progress' - ) { - const tp = message as Record; - const description = - typeof tp.description === 'string' ? tp.description : ''; - const mapped = buildTaskProgressOutput( - trackedAgentTasks, - tp, - newSessionId, - ); - if (mapped) { - writeOutput(mapped); - } else if (description) { - // Long AI summary → skip (too long for progress sub-line) - log( - `Skipping long task_progress description (${description.length} chars)`, - ); - } - } - - if ( - message.type === 'system' && - (message as { subtype?: string }).subtype === 'task_started' - ) { - const ts = message as { task_id: string; description?: string }; - const desc = ts.description || ''; - log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`); - const mapped = buildTaskStartedOutput( - trackedAgentTasks, - ts, - newSessionId, - ); - if (mapped) { - writeOutput(mapped); - } - } - - if (message.type === 'tool_progress') { - const tp = message as { - tool_name: string; - elapsed_time_seconds: number; - }; - const label = `${tp.tool_name} (${Math.round(tp.elapsed_time_seconds)}s)`; - log(`Tool progress: ${label}`); - const normalized = normalizeStructuredOutput(label); - writeOutput({ - status: 'success', - phase: 'progress', - ...normalized, - newSessionId, - }); - } - - if (message.type === 'tool_use_summary') { - const ts = message as { summary: string }; - log(`Tool use summary: ${ts.summary.slice(0, 200)}`); - const normalized = normalizeStructuredOutput(ts.summary); - writeOutput({ - status: 'success', - phase: 'progress', - ...normalized, - newSessionId, - }); - } - - if (message.type === 'result') { - resultCount++; - let textResult = - 'result' in message ? (message as { result?: string }).result : null; - const isError = message.subtype?.startsWith('error'); - // Discard pending progress if it matches the final result (prevent duplicate) - if ( - pendingProgressText && - textResult && - pendingProgressText === textResult - ) { - log(`Discarding pending progress (matches result)`); - pendingProgressText = null; - } else if (pendingProgressText) { - // If the result has no text, promote pending progress to the result - // so it gets delivered as the final output instead of being lost. - if (!textResult) { - log( - `Promoting pending progress text to result (${pendingProgressText.length} chars)`, - ); - textResult = pendingProgressText; - } else { - writeOutput({ - status: 'success', - phase: 'intermediate', - result: pendingProgressText, - newSessionId, - }); - } - pendingProgressText = null; - } - log( - `Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`, - ); - if (isError) { - // Log full error details for debugging - const msg = message as Record; - const sdkErrors = Array.isArray(msg.errors) - ? (msg.errors as string[]) - : []; - const errorDetail = JSON.stringify({ - subtype: message.subtype, - result: textResult?.slice(0, 500), - errors: sdkErrors, - stop_reason: msg.stop_reason, - duration_ms: msg.duration_ms, - duration_api_ms: msg.duration_api_ms, - session_id: msg.session_id, - }); - log(`Error result detail: ${errorDetail}`); - // Pass SDK errors through so host can detect session issues - const errorText = - sdkErrors.length > 0 ? sdkErrors.join('; ') : undefined; - writeOutput({ - status: 'error', - result: textResult || null, - newSessionId, - error: errorText || `Agent error: ${message.subtype}`, - ...buildCompactionOutput(compaction), - }); - } else { - const normalized = normalizeStructuredOutput(textResult || null); - writeOutput({ - status: 'success', - ...normalized, - newSessionId, - ...buildCompactionOutput(compaction), - }); - } - - // Single-turn runtimes must terminate the query after the first - // terminal result. Leaving the message stream open can keep the SDK - // query alive indefinitely, which pins the host queue after a reply. - terminalResultObserved = true; - ipcPolling = false; - stream.end(); - log('Terminal result observed, ending query stream'); - break; - } - - if (message.type === 'assistant') { - trackedAgentTasks.rememberAssistantMessage(message); - const stopReason = (message as { stop_reason?: string }).stop_reason; - const textResult = extractAssistantText(message); - // Only log when there's something interesting (text or terminal) - if (textResult || stopReason === 'end_turn') { - log( - `Assistant: stop=${stopReason} text=${textResult ? textResult.length + ' chars' : 'null'}`, - ); - } - if (stopReason === 'end_turn' && textResult) { - resultCount++; - log( - `Terminal assistant turn observed without result event (${textResult.length} chars), ending query stream`, - ); - writeOutput({ - status: 'success', - ...normalizeStructuredOutput(textResult), - newSessionId, - ...buildCompactionOutput(compaction), - }); - terminalResultObserved = true; - ipcPolling = false; - stream.end(); - break; - } - // Intermediate assistant text between tool calls → buffer as pending progress. - // Don't emit immediately — if the next message is a result with the same text, - // this would cause a duplicate. The pending text is flushed when the next - // non-result message arrives, or discarded if result matches. - if (stopReason !== 'end_turn' && textResult) { - // Flush previous pending as a regular message (not progress heading) - if (pendingProgressText) { - const normalized = normalizeStructuredOutput(pendingProgressText); - writeOutput({ - status: 'success', - phase: 'intermediate', - ...normalized, - newSessionId, - }); - } - pendingProgressText = textResult; - log( - `Intermediate assistant text buffered (${textResult.length} chars, stop=${stopReason})`, - ); - } - } - } - - // Flush any remaining buffered text that was never followed by a result event. - // This happens when the agent produces a short response without a formal end_turn. - if (pendingProgressText && !terminalResultObserved) { - log( - `Flushing remaining pending progress text as final output (${pendingProgressText.length} chars)`, - ); - writeOutput({ - status: 'success', - ...normalizeStructuredOutput(pendingProgressText), - newSessionId, - ...buildCompactionOutput(compaction), - }); - terminalResultObserved = true; - resultCount++; - } - - ipcPolling = false; - log( - `Query done. Messages: ${messageCount}, results: ${resultCount}, closedDuringQuery: ${closedDuringQuery}`, - ); - return { - newSessionId, - closedDuringQuery, - terminalResultObserved, - compaction, - }; -} - async function main(): Promise { let runnerInput: RunnerInput; @@ -723,126 +151,18 @@ async function main(): Promise { if (isSessionSlashCommand) { log(`Handling session command: ${trimmedPrompt}`); - let slashSessionId: string | undefined; - let compactBoundarySeen = false; - let slashCompaction: RunnerCompaction | undefined; - let hadError = false; - let resultEmitted = false; - - try { - for await (const message of query({ - prompt: trimmedPrompt, - options: { - // 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함. - pathToClaudeCodeExecutable: getClaudeCliPath(), - cwd: mainEffectiveCwd, - resume: sessionId, - systemPrompt: undefined, - allowedTools: [], - env: guardedSdkEnv, - permissionMode: 'bypassPermissions' as const, - allowDangerouslySkipPermissions: true, - settingSources: ['project', 'user'] as const, - abortController: agentAbortController, - hooks: { - PreCompact: [ - { - hooks: [ - createPreCompactHook({ - assistantName: runnerInput.assistantName, - groupDir: GROUP_DIR, - groupFolder: GROUP_FOLDER, - hostTasksDir: HOST_TASKS_DIR, - log, - writeOutput, - }), - ], - }, - ], - }, - }, - })) { - const msgType = - message.type === 'system' - ? `system/${(message as { subtype?: string }).subtype}` - : message.type; - log(`[slash-cmd] type=${msgType}`); - - if (message.type === 'system' && message.subtype === 'init') { - slashSessionId = message.session_id; - log(`Session after slash command: ${slashSessionId}`); - } - - const observedCompaction = compactBoundaryFromMessage(message); - if (observedCompaction) { - compactBoundarySeen = true; - slashCompaction = observedCompaction; - } - - if (message.type === 'result') { - const resultSubtype = (message as { subtype?: string }).subtype; - const textResult = - 'result' in message - ? (message as { result?: string }).result - : null; - - if (resultSubtype?.startsWith('error')) { - hadError = true; - writeOutput({ - status: 'error', - result: null, - error: textResult || 'Session command failed.', - newSessionId: slashSessionId, - ...buildCompactionOutput(slashCompaction), - }); - } else { - writeOutput({ - status: 'success', - result: textResult || 'Conversation compacted.', - newSessionId: slashSessionId, - ...buildCompactionOutput(slashCompaction), - }); - } - resultEmitted = true; - } - } - } catch (err) { - hadError = true; - const errorMsg = err instanceof Error ? err.message : String(err); - log(`Slash command error: ${errorMsg}`); - writeOutput({ status: 'error', result: null, error: errorMsg }); - } - - log( - `Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`, - ); - - // Warn if compact_boundary was never observed — compaction may not have occurred - if (!hadError && !compactBoundarySeen) { - log( - 'WARNING: compact_boundary was not observed. Compaction may not have completed.', - ); - } - - // Only emit final session marker if no result was emitted yet and no error occurred - if (!resultEmitted && !hadError) { - writeOutput({ - status: 'success', - result: compactBoundarySeen - ? 'Conversation compacted.' - : 'Compaction requested but compact_boundary was not observed.', - newSessionId: slashSessionId, - ...buildCompactionOutput(slashCompaction), - }); - } else if (!hadError) { - // Emit session-only marker so host updates session tracking - writeOutput({ - status: 'success', - result: null, - newSessionId: slashSessionId, - ...buildCompactionOutput(slashCompaction), - }); - } + await runSessionCommand({ + prompt: trimmedPrompt, + sessionId, + cwd: mainEffectiveCwd, + sdkEnv: guardedSdkEnv, + abortController: agentAbortController, + assistantName: runnerInput.assistantName, + groupDir: GROUP_DIR, + groupFolder: GROUP_FOLDER, + hostTasksDir: HOST_TASKS_DIR, + log, + }); return; } // --- End slash command handling --- @@ -850,16 +170,26 @@ async function main(): Promise { try { log(`Starting query (session: ${sessionId || 'new'})...`); - const queryResult = await runQuery( + const queryResult = await runClaudeQuery({ prompt, sessionId, mcpServerPath, runnerInput, - guardedSdkEnv, + sdkEnv: guardedSdkEnv, reviewerRuntime, claudeReadonlyReviewerRuntime, claudeReadonlySandboxMode, - ); + abortController: agentAbortController, + paths: { + groupDir: GROUP_DIR, + groupFolder: GROUP_FOLDER, + hostTasksDir: HOST_TASKS_DIR, + ipcInputCloseSentinel: IPC_INPUT_CLOSE_SENTINEL, + ipcInputDir: IPC_INPUT_DIR, + workDir: WORK_DIR, + }, + log, + }); if (queryResult.newSessionId) { sessionId = queryResult.newSessionId; } diff --git a/runners/agent-runner/src/runner-input.ts b/runners/agent-runner/src/runner-input.ts new file mode 100644 index 0000000..9508ee7 --- /dev/null +++ b/runners/agent-runner/src/runner-input.ts @@ -0,0 +1,13 @@ +import type { RoomRoleContext } from './room-role-context.js'; + +export interface RunnerInput { + prompt: string; + sessionId?: string; + groupFolder: string; + chatJid: string; + isMain: boolean; + isScheduledTask?: boolean; + assistantName?: string; + secrets?: Record; + roomRoleContext?: RoomRoleContext; +} diff --git a/runners/agent-runner/src/session-command.ts b/runners/agent-runner/src/session-command.ts new file mode 100644 index 0000000..5fb02fa --- /dev/null +++ b/runners/agent-runner/src/session-command.ts @@ -0,0 +1,151 @@ +import { query } from '@anthropic-ai/claude-agent-sdk'; + +import { compactBoundaryFromMessage } from './compaction-boundary.js'; +import { getClaudeCliPath } from './claude-cli.js'; +import { createPreCompactHook } from './runner-hooks.js'; +import { + buildCompactionOutput, + type RunnerCompaction, + writeOutput, +} from './output-protocol.js'; + +export interface RunSessionCommandArgs { + prompt: string; + sessionId?: string; + cwd: string; + sdkEnv: Record; + abortController: AbortController; + assistantName?: string; + groupDir: string; + groupFolder: string; + hostTasksDir: string; + log: (message: string) => void; +} + +export async function runSessionCommand({ + prompt, + sessionId, + cwd, + sdkEnv, + abortController, + assistantName, + groupDir, + groupFolder, + hostTasksDir, + log, +}: RunSessionCommandArgs): Promise { + let slashSessionId: string | undefined; + let compactBoundarySeen = false; + let slashCompaction: RunnerCompaction | undefined; + let hadError = false; + let resultEmitted = false; + + try { + for await (const message of query({ + prompt, + options: { + pathToClaudeCodeExecutable: getClaudeCliPath(log), + cwd, + resume: sessionId, + systemPrompt: undefined, + allowedTools: [], + env: sdkEnv, + permissionMode: 'bypassPermissions' as const, + allowDangerouslySkipPermissions: true, + settingSources: ['project', 'user'] as const, + abortController, + hooks: { + PreCompact: [ + { + hooks: [ + createPreCompactHook({ + assistantName, + groupDir, + groupFolder, + hostTasksDir, + log, + writeOutput, + }), + ], + }, + ], + }, + }, + })) { + const msgType = + message.type === 'system' + ? `system/${(message as { subtype?: string }).subtype}` + : message.type; + log(`[slash-cmd] type=${msgType}`); + + if (message.type === 'system' && message.subtype === 'init') { + slashSessionId = message.session_id; + log(`Session after slash command: ${slashSessionId}`); + } + + const observedCompaction = compactBoundaryFromMessage(message, log); + if (observedCompaction) { + compactBoundarySeen = true; + slashCompaction = observedCompaction; + } + + if (message.type === 'result') { + const resultSubtype = (message as { subtype?: string }).subtype; + const textResult = + 'result' in message ? (message as { result?: string }).result : null; + + if (resultSubtype?.startsWith('error')) { + hadError = true; + writeOutput({ + status: 'error', + result: null, + error: textResult || 'Session command failed.', + newSessionId: slashSessionId, + ...buildCompactionOutput(slashCompaction), + }); + } else { + writeOutput({ + status: 'success', + result: textResult || 'Conversation compacted.', + newSessionId: slashSessionId, + ...buildCompactionOutput(slashCompaction), + }); + } + resultEmitted = true; + } + } + } catch (err) { + hadError = true; + const errorMsg = err instanceof Error ? err.message : String(err); + log(`Slash command error: ${errorMsg}`); + writeOutput({ status: 'error', result: null, error: errorMsg }); + } + + log( + `Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`, + ); + + if (!hadError && !compactBoundarySeen) { + log( + 'WARNING: compact_boundary was not observed. Compaction may not have completed.', + ); + } + + if (!resultEmitted && !hadError) { + writeOutput({ + status: 'success', + result: compactBoundarySeen + ? 'Conversation compacted.' + : 'Compaction requested but compact_boundary was not observed.', + newSessionId: slashSessionId, + ...buildCompactionOutput(slashCompaction), + }); + } else if (!hadError) { + writeOutput({ + status: 'success', + result: null, + newSessionId: slashSessionId, + ...buildCompactionOutput(slashCompaction), + }); + } +} diff --git a/runners/agent-runner/test/compaction-boundary.test.ts b/runners/agent-runner/test/compaction-boundary.test.ts new file mode 100644 index 0000000..8dce516 --- /dev/null +++ b/runners/agent-runner/test/compaction-boundary.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { compactBoundaryFromMessage } from '../src/compaction-boundary.js'; + +describe('compactBoundaryFromMessage', () => { + it('extracts compact boundary metadata from SDK system messages', () => { + const log = vi.fn(); + + const compaction = compactBoundaryFromMessage( + { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { + trigger: 'manual', + pre_tokens: 1234, + }, + }, + log, + ); + + expect(compaction).toEqual({ + completed: true, + trigger: 'manual', + }); + expect(log).toHaveBeenCalledWith( + 'Compact boundary — trigger=manual pre_tokens=1234', + ); + }); + + it('ignores unrelated SDK messages', () => { + expect( + compactBoundaryFromMessage({ type: 'assistant' }, vi.fn()), + ).toBeUndefined(); + }); +});