From 4e9163fa25363958a8d9efe4fb84c33a304ffc61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=9A=E7=A0=9A?= <26771442+zts212653@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:02:29 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(opencode):=20recover=20post-tool=20outp?= =?UTF-8?q?ut=20gaps=20[=E7=A0=9A=E7=A0=9A/gpt-5.5=F0=9F=90=BE]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bug-report.md | 118 +++++ .../agents/providers/OpenCodeAgentService.ts | 403 +++++++++++++++++- .../api/test/opencode-agent-service.test.js | 130 ++++++ 3 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 docs/bug-report/opencode-post-tool-output-gap/bug-report.md diff --git a/docs/bug-report/opencode-post-tool-output-gap/bug-report.md b/docs/bug-report/opencode-post-tool-output-gap/bug-report.md new file mode 100644 index 0000000000..dee1f0f2ec --- /dev/null +++ b/docs/bug-report/opencode-post-tool-output-gap/bug-report.md @@ -0,0 +1,118 @@ +# OpenCode + DeepSeek no-output recovery + +## Summary + +OpenCode + DeepSeek had two observed "no visible final answer" shapes. They share one root cause: the provider adapter treated CLI/tool completion as turn completion without verifying that the user-visible final assistant text had actually been produced. + +This is not a DeepSeek capability failure. The missing piece is a completion-state check in `OpenCodeAgentService`. + +## Phenotype A: pure silent completion + +Observed shape: + +1. OpenCode stdout emitted `step_start`. +2. stdout emitted no `text` and no `error`. +3. The assistant answer was still present in OpenCode local SQLite state. + +Direct local evidence: + +- The NDJSON sample contained only `type: "step_start"`. +- The `step_start.part` payload carried both `sessionID` and `messageID`. +- Querying OpenCode SQLite table `part` by that `sessionID + messageID` returned the assistant `text` part. + +Recovery rule: + +- When a completed OpenCode run has events but no text, no error, and no tool use, use the `step_start` `sessionID + messageID` as a read-only anchor into OpenCode SQLite. +- If assistant text is found, emit it as the visible response and do not surface `silent_completion`. +- If no text is found or SQLite is unavailable, preserve the existing `silent_completion` diagnostic path. + +## Phenotype B: post-tool gap + +Observed shape: + +1. OpenCode emitted a short prelude text, for example "I will check the actual config first." +2. OpenCode emitted `tool_use`; the tool completed and had output. +3. The run ended without a final assistant `text` after the tool. + +Teammate reproduction confirmation: + +- Ragdoll/OpenCode + DeepSeek observed the event sequence `text -> tool_use -> step_finish/end` with no final text. +- The frontend therefore showed only the incomplete prelude or appeared to have no useful output. + +Why the old logic missed it: + +- `textEventCount > 0` made the turn look like it had user-visible text. +- `toolUseEmitted = true` correctly suppressed the older `silent_completion` warning for pure tool-only turns. +- The adapter did not compare event order, so it missed the specific case where the last meaningful event was a tool and no later text existed. + +Recovery rule: + +- At normal CLI completion, detect `textEventCount > 0 && lastToolEventIndex > lastTextEventIndex && !errorAlreadyYielded`. +- Run exactly one no-tool finalizer in the same OpenCode session using a dedicated `cat-cafe-no-tool-finalizer` agent. +- Deny all tools in the finalizer config. The finalizer may use only existing session state and the latest tool output. +- Emit the first finalizer text with `textMode: "replace"` so it replaces the incomplete prelude. +- If the finalizer produces no text or attempts a tool, emit a deterministic diagnostic fallback with the latest tool output summary. + +## Non-goals and safety boundaries + +- Do not rerun the whole OpenCode turn. A full rerun can repeat tool side effects. +- Do not use word-count thresholds to decide whether a prelude is "too short." The stable signal is event order: last meaningful event is tool and no later text. +- Do not mark pure `tool_use` completions as silent failures. Existing AC-G3 behavior remains valid. +- Do not hide real provider or CLI errors behind no-output recovery. Error paths continue to surface their original diagnostics. +- Do not invent semantic conclusions if the finalizer cannot produce text. Fall back to an explicit recovery diagnostic. + +## Root cause + +The adapter had a completion判定缺口: + +- "A tool finished" is not the same as "the assistant finished its answer." +- "Some text appeared earlier" is not the same as "the final answer appeared after the last tool." +- "No stdout text" is not always "no answer exists," because OpenCode may have persisted the answer in its local SQLite session state. + +The correct completion contract is: + +1. If stdout contains final text after the last tool, stream it normally. +2. If stdout has no text but OpenCode persisted text for the current `sessionID + messageID`, recover that persisted text. +3. If stdout has prelude text, then a tool, then no final text, perform a single no-tool finalizer pass and replace the prelude. +4. If recovery cannot produce text, preserve explicit diagnostics instead of silently ending. + +## Implementation + +Code path: `packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts` + +- Tracks `lastTextEventIndex`, `lastToolEventIndex`, latest tool trace, and latest `step_start` message reference. +- Adds read-only SQLite recovery for phenotype A using `part.session_id + part.message_id`. +- Adds a no-tool finalizer for phenotype B using `--session` and `--agent cat-cafe-no-tool-finalizer`. +- Denies all finalizer tool permissions through `OPENCODE_CONFIG_CONTENT`. +- Preserves deterministic fallback text only for finalizer failure. + +Regression tests: `packages/api/test/opencode-agent-service.test.js` + +- Adds a red/green SQLite recovery case for `step_start`-only NDJSON. +- Updates the post-tool gap case to require a second no-tool finalizer invocation, session resume, deny-all permissions, and `textMode: "replace"`. +- Keeps the older AC-G3 cases for true silent diagnostics and pure tool-only completion. + +## Verification + +Red evidence before the SQLite recovery implementation: + +```text +node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-name-pattern "step_start-only NDJSON recovers" packages/api/test/opencode-agent-service.test.js +FAIL: expected "Recovered from OpenCode SQLite.", actual undefined +``` + +Green verification after the fix: + +```text +pnpm --dir packages/api run build +node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-name-pattern "step_start-only NDJSON recovers" packages/api/test/opencode-agent-service.test.js +node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-name-pattern "post-tool completion gap" packages/api/test/opencode-agent-service.test.js +node --import ./packages/api/test/helpers/setup-cat-registry.js --test packages/api/test/opencode-agent-service.test.js +``` + +Results: + +- `packages/api` build passed. +- Target SQLite recovery test passed. +- Target post-tool finalizer test passed. +- Full `opencode-agent-service.test.js` passed: 41/41. diff --git a/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts index c49dffd01f..d41767b084 100644 --- a/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts @@ -14,6 +14,10 @@ * error → error */ +import Database from 'better-sqlite3'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { type CatId, createCatId } from '@cat-cafe/shared'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; @@ -63,12 +67,16 @@ interface OpenCodeAgentServiceOptions { l0CompilerFn?: (options: { catId: string; userId?: string; dataDir?: string; outPath?: string }) => Promise; /** Test seam for the `opencode run --help` auto-approval capability probe. */ autoApproveProbeFn?: OpenCodeAutoApproveProbeFn; + /** Test seam for OpenCode's local SQLite state used to recover silent completions. */ + opencodeDbPath?: string; } const OPENCODE_API_KEY_ENV = 'OPENCODE_API_KEY'; const ANTHROPIC_API_KEY_ENV = 'ANTHROPIC_API_KEY'; const ANTHROPIC_BASE_URL_ENV = 'ANTHROPIC_BASE_URL'; +const OPENCODE_DB_PATH_ENV = 'OPENCODE_DB_PATH'; const OPENCODE_READ_ONLY_AGENT = 'cat-cafe-read-only'; +const OPENCODE_NO_TOOL_FINALIZER_AGENT = 'cat-cafe-no-tool-finalizer'; const OPENCODE_READ_ONLY_PERMISSION = { '*': 'deny', read: 'allow', @@ -83,6 +91,21 @@ const OPENCODE_READ_ONLY_PERMISSION = { task: 'deny', question: 'deny', } as const; +const OPENCODE_NO_TOOL_PERMISSION = { + '*': 'deny', + read: 'deny', + glob: 'deny', + grep: 'deny', + lsp: 'deny', + skill: 'deny', + webfetch: 'deny', + websearch: 'deny', + edit: 'deny', + bash: 'deny', + task: 'deny', + question: 'deny', +} as const; +const MAX_OPENCODE_VISIBLE_TOOL_OUTPUT_CHARS = 4_000; // Process-wide cache: --auto support is a property of the installed opencode binary. // Restart the API process after upgrading opencode so this capability is re-probed. let sharedOpenCodeAutoApproveProbe: Promise | undefined; @@ -98,6 +121,28 @@ export interface OpenCodeEnvDebugSummary { catCafeOcBaseUrl: string; } +interface OpenCodeToolTrace { + toolName: string; + status?: string; + output?: unknown; +} + +interface OpenCodePostToolFinalizerParams { + command: string; + cwd?: string; + childEnv: Record; + effectiveModel: string; + metadata: MessageMetadata; + sessionId?: string; + trace: OpenCodeToolTrace | null; + options?: AgentServiceOptions; +} + +interface OpenCodeMessageRef { + sessionId?: string; + messageId?: string; +} + function isPermanentOpenCodeProviderFailure(event: unknown, reasonCode: string | undefined): boolean { if (typeof event !== 'object' || event === null) return false; const rawError = (event as Record).error; @@ -122,6 +167,101 @@ function isPermanentOpenCodeProviderFailure(event: unknown, reasonCode: string | ); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function truncateForVisibleText(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n...[truncated ${value.length - maxChars} chars]`; +} + +function stringifyForVisibleText(value: unknown, maxChars: number): string { + if (value == null) return ''; + if (typeof value === 'string') return truncateForVisibleText(value, maxChars); + const jsonText = JSON.stringify(value, null, 2); + return truncateForVisibleText(typeof jsonText === 'string' ? jsonText : String(value), maxChars); +} + +function extractOpenCodeToolTrace(event: unknown): OpenCodeToolTrace | null { + if (!isRecord(event)) return null; + if (event.type !== 'tool_use') return null; + const part = isRecord(event.part) ? event.part : {}; + const state = isRecord(part.state) ? part.state : {}; + const toolName = typeof part.tool === 'string' && part.tool.length > 0 ? part.tool : 'unknown'; + const trace: OpenCodeToolTrace = { toolName }; + if (typeof state.status === 'string' && state.status.length > 0) { + trace.status = state.status; + } + if ('output' in state) { + trace.output = state.output; + } + return trace; +} + +function buildOpenCodePostToolFinalizerPrompt(trace: OpenCodeToolTrace | null): string { + const toolName = trace ? trace.toolName : 'a tool'; + const outputText = stringifyForVisibleText(trace ? trace.output : undefined, MAX_OPENCODE_VISIBLE_TOOL_OUTPUT_CHARS); + return [ + 'The previous OpenCode turn stopped immediately after a tool call and did not produce the final assistant text.', + 'Do not call any tools. Use only the existing session state and the latest tool result below to write the final answer to the user.', + `Latest tool: ${toolName}${trace?.status ? ` (${trace.status})` : ''}.`, + outputText.length > 0 ? `Latest tool output:\n${outputText}` : 'No tool output was captured.', + 'If the available tool result is insufficient, state the limitation briefly instead of inventing details.', + ].join('\n\n'); +} + +function extractOpenCodeMessageRef(event: unknown): OpenCodeMessageRef | null { + if (typeof event !== 'object' || event === null) return null; + const record = event as Record; + if (record.type !== 'step_start') return null; + const part = typeof record.part === 'object' && record.part !== null ? (record.part as Record) : {}; + const sessionId = + typeof part.sessionID === 'string' + ? part.sessionID + : typeof record.sessionID === 'string' + ? record.sessionID + : undefined; + const messageId = + typeof part.messageID === 'string' + ? part.messageID + : typeof record.messageID === 'string' + ? record.messageID + : undefined; + if (!sessionId && !messageId) return null; + return { ...(sessionId ? { sessionId } : {}), ...(messageId ? { messageId } : {}) }; +} + +function extractOpenCodePartText(data: string): string | null { + try { + const parsed = JSON.parse(data) as unknown; + if (typeof parsed !== 'object' || parsed === null) return null; + const part = parsed as Record; + if (part.type !== 'text' || typeof part.text !== 'string') return null; + const text = part.text; + return text.trim().length > 0 ? text : null; + } catch { + return null; + } +} + +function defaultOpenCodeDbPath(): string { + return join(homedir(), '.local', 'share', 'opencode', 'opencode.db'); +} + +function buildOpenCodePostToolFallbackText(trace: OpenCodeToolTrace | null, reason: string): string { + const toolName = trace ? trace.toolName : 'a tool'; + const outputText = stringifyForVisibleText(trace ? trace.output : undefined, MAX_OPENCODE_VISIBLE_TOOL_OUTPUT_CHARS); + const lines = [ + `OpenCode stopped after running \`${toolName}\` but did not produce a final text response.`, + `No-tool finalizer recovery did not produce text: ${reason}.`, + trace?.status ? `Tool status: ${trace.status}.` : undefined, + outputText.length > 0 ? `Latest tool output:\n${outputText}` : 'No tool output was captured.', + 'This is a recovery message; review the tool output before treating the task as complete.', + ]; + return lines.filter((line): line is string => Boolean(line)).join('\n\n'); +} + function summarizeDebugValue(value: string | null | undefined): string { if (value === null) return '(cleared)'; if (!value) return '(unset)'; @@ -171,6 +311,7 @@ export class OpenCodeAgentService implements L0InjectableAgentService { /** F203 Phase I: injectable L0 compiler (test seam, like Claude/Codex services). */ readonly l0CompilerFn: import('../../types.js').L0CompilerFn | undefined; private readonly autoApproveProbeFn: OpenCodeAutoApproveProbeFn | undefined; + private readonly opencodeDbPath: string | undefined; private autoApproveProbe: Promise | undefined; constructor(options?: OpenCodeAgentServiceOptions) { @@ -182,6 +323,7 @@ export class OpenCodeAgentService implements L0InjectableAgentService { this.rawArchive = options?.rawArchive ?? new CliRawArchive(); this.l0CompilerFn = options?.l0CompilerFn; this.autoApproveProbeFn = options?.autoApproveProbeFn; + this.opencodeDbPath = options?.opencodeDbPath; } /** @@ -313,6 +455,10 @@ export class OpenCodeAgentService implements L0InjectableAgentService { let eventCount = 0; let textEventCount = 0; + let lastTextEventIndex = 0; + let lastToolEventIndex = 0; + let lastToolTrace: OpenCodeToolTrace | null = null; + let lastAssistantMessageId: string | undefined; // F212 Phase G (AC-G3, clowder-ai#875): track unique event types so the // silent_completion diagnostic can surface them when textEventCount===0. const uniqueEventTypes = new Set(); @@ -343,6 +489,9 @@ export class OpenCodeAgentService implements L0InjectableAgentService { ? String((event as Record).type) : '__unknown'; uniqueEventTypes.add(evtType); + const messageRef = extractOpenCodeMessageRef(event); + if (messageRef?.sessionId) metadata.sessionId = messageRef.sessionId; + if (messageRef?.messageId) lastAssistantMessageId = messageRef.messageId; log.debug({ catId: this.catId, eventIndex: eventCount, type: evtType }, 'CLI event received'); if (isCliTimeout(event)) { yield { @@ -410,8 +559,18 @@ export class OpenCodeAgentService implements L0InjectableAgentService { const result = transformOpenCodeEvent(event, this.catId); if (result !== null) { let terminateAfterYield = false; - if (result.type === 'text') textEventCount++; - if (result.type === 'tool_use') toolUseEmitted = true; + if (result.type === 'text') { + textEventCount++; + lastTextEventIndex = eventCount; + } + if (result.type === 'tool_use') { + toolUseEmitted = true; + lastToolEventIndex = eventCount; + const toolTrace = extractOpenCodeToolTrace(event); + if (toolTrace !== null) { + lastToolTrace = toolTrace; + } + } // F212 Phase A AC-A8: enrich stream `error` event yield with cliDiagnostics so // frontend folded panel (Phase B) sees reasonCode / safeExcerpt / publicHint // even when CLI never exits non-zero (some providers emit error events then exit 0). @@ -487,6 +646,59 @@ export class OpenCodeAgentService implements L0InjectableAgentService { { catId: this.catId, totalEvents: eventCount, textEvents: textEventCount, sessionId: metadata.sessionId }, 'OpenCode CLI invocation completed', ); + if (eventCount > 0 && textEventCount === 0 && !errorAlreadyYielded && !toolUseEmitted) { + const recoveredText = this.recoverSilentCompletionText(metadata.sessionId, lastAssistantMessageId); + if (recoveredText) { + log.info( + { + catId: this.catId, + sessionId: metadata.sessionId, + messageId: lastAssistantMessageId, + textLength: recoveredText.length, + }, + 'Recovered OpenCode silent completion text from local SQLite state', + ); + textEventCount++; + yield { + type: 'text' as const, + catId: this.catId, + content: recoveredText, + metadata, + timestamp: Date.now(), + }; + } + } + if (textEventCount > 0 && lastToolEventIndex > lastTextEventIndex && !errorAlreadyYielded) { + log.warn( + { + catId: this.catId, + totalEvents: eventCount, + textEvents: textEventCount, + eventTypes: Array.from(uniqueEventTypes), + lastTextEventIndex, + lastToolEventIndex, + latestTool: lastToolTrace?.toolName, + }, + 'OpenCode CLI stopped after tool_use without final text - running no-tool finalizer', + ); + for await (const finalizerMsg of this.runPostToolFinalizer({ + command: opencodeCommand, + ...(cwd ? { cwd } : {}), + childEnv, + effectiveModel, + metadata, + ...(metadata.sessionId ?? options?.sessionId ? { sessionId: metadata.sessionId ?? options?.sessionId } : {}), + trace: lastToolTrace, + options, + })) { + if (finalizerMsg.type === 'text') textEventCount++; + if (finalizerMsg.metadata?.usage != null && resolveCurrentContextUsage(finalizerMsg.metadata.usage) != null) { + usageTelemetryReceived = true; + } + yield finalizerMsg; + } + } + // F212 Phase G (AC-G3, clowder-ai#875): surface silent_completion via cliDiagnostics. // Only when eventCount > 0 (CLI actually produced events) AND no other diagnostic // already surfaced (don't double-yield on cli error / stream error / timeout — they @@ -555,6 +767,193 @@ export class OpenCodeAgentService implements L0InjectableAgentService { } } + private async *runPostToolFinalizer(params: OpenCodePostToolFinalizerParams): AsyncIterable { + const finalizerPrompt = buildOpenCodePostToolFinalizerPrompt(params.trace); + const finalizerArgs = this.buildNoToolFinalizerArgs(finalizerPrompt, params.sessionId, params.effectiveModel); + const finalizerEnv = this.buildNoToolFinalizerEnv(params.childEnv); + const cliOpts = { + command: params.command, + args: finalizerArgs, + ...(params.cwd ? { cwd: params.cwd } : {}), + env: finalizerEnv, + ...(params.options?.signal ? { signal: params.options.signal } : {}), + ...(params.options?.invocationId ? { invocationId: params.options.invocationId } : {}), + ...(params.options?.cliSessionId ? { cliSessionId: params.options.cliSessionId } : {}), + ...(params.options?.livenessProbe ? { livenessProbe: params.options.livenessProbe } : {}), + ...(params.options?.parentSpan ? { parentSpan: params.options.parentSpan } : {}), + ...(params.options?.invocationId && this.rawArchive.getPath + ? { rawArchivePath: this.rawArchive.getPath(params.options.invocationId) } + : {}), + }; + + const events = params.options?.spawnCliOverride + ? params.options.spawnCliOverride(cliOpts) + : spawnCli(cliOpts, this.spawnFn ? { spawnFn: this.spawnFn } : undefined); + + let finalizerTextEmitted = false; + let finalizerErrorReason: string | undefined; + const finalizerEventTypes = new Set(); + + for await (const event of events) { + if (params.options?.invocationId) { + this.rawArchive.append(params.options.invocationId, sanitizeRawEvent(event)).catch((err) => { + log.warn( + { catId: this.catId, invocationId: params.options?.invocationId, err }, + 'Post-tool finalizer raw archive write failed', + ); + }); + } + const evtType = + typeof event === 'object' && event !== null && 'type' in event + ? String((event as Record).type) + : '__unknown'; + finalizerEventTypes.add(evtType); + + if (isCliTimeout(event)) { + finalizerErrorReason = 'timeout'; + log.warn( + { catId: this.catId, invocationId: params.options?.invocationId, timeoutMs: event.timeoutMs }, + 'OpenCode no-tool finalizer timed out', + ); + continue; + } + if (isLivenessWarning(event)) { + continue; + } + if (isCliError(event)) { + finalizerErrorReason = event.reasonCode ?? 'cli_error'; + log.warn( + { catId: this.catId, invocationId: params.options?.invocationId, reasonCode: event.reasonCode }, + 'OpenCode no-tool finalizer exited with an error', + ); + continue; + } + + const result = transformOpenCodeEvent(event, this.catId); + if (result === null) continue; + if (result.type === 'session_init') { + if (result.sessionId) params.metadata.sessionId = result.sessionId; + continue; + } + if (result.type === 'tool_use') { + finalizerErrorReason = 'tool_use_blocked'; + log.warn( + { + catId: this.catId, + invocationId: params.options?.invocationId, + toolName: result.toolName, + }, + 'OpenCode no-tool finalizer attempted to use a tool', + ); + continue; + } + if (result.type === 'error') { + finalizerErrorReason = 'provider_error'; + log.warn( + { catId: this.catId, invocationId: params.options?.invocationId, error: result.error }, + 'OpenCode no-tool finalizer returned an error event', + ); + continue; + } + if (result.type === 'text') { + yield { + ...result, + metadata: params.metadata, + textMode: finalizerTextEmitted ? result.textMode : ('replace' as const), + }; + finalizerTextEmitted = true; + continue; + } + if (result.type === 'agent_loop') { + yield { + ...result, + metadata: + result.metadata?.usage != null + ? { ...params.metadata, usage: result.metadata.usage } + : params.metadata, + }; + } + } + + if (!finalizerTextEmitted) { + log.warn( + { + catId: this.catId, + invocationId: params.options?.invocationId, + eventTypes: Array.from(finalizerEventTypes), + reason: finalizerErrorReason ?? 'no_text', + }, + 'OpenCode no-tool finalizer produced no text - yielding deterministic recovery text', + ); + yield { + type: 'text', + catId: this.catId, + content: buildOpenCodePostToolFallbackText(params.trace, finalizerErrorReason ?? 'no_text'), + textMode: 'replace', + metadata: params.metadata, + timestamp: Date.now(), + }; + } + } + + private recoverSilentCompletionText(sessionId: string | undefined, messageId: string | undefined): string | null { + if (!sessionId || !messageId) return null; + const dbPath = this.opencodeDbPath ?? process.env[OPENCODE_DB_PATH_ENV] ?? defaultOpenCodeDbPath(); + const dbPathSource = this.opencodeDbPath ? 'override' : process.env[OPENCODE_DB_PATH_ENV] ? 'env' : 'default'; + if (!existsSync(dbPath)) return null; + + let db: Database.Database | undefined; + try { + db = new Database(dbPath, { readonly: true, fileMustExist: true }); + const rows = db + .prepare( + ` + SELECT data + FROM part + WHERE session_id = ? AND message_id = ? + ORDER BY time_created ASC + `, + ) + .all(sessionId, messageId) as Array<{ data: string }>; + const text = rows + .map((row) => (typeof row.data === 'string' ? extractOpenCodePartText(row.data) : null)) + .filter((partText): partText is string => partText !== null) + .join(''); + return text.trim().length > 0 ? text : null; + } catch (err) { + log.warn( + { catId: this.catId, sessionId, messageId, dbPathSource, err }, + 'Failed to recover OpenCode silent completion text from local SQLite state', + ); + return null; + } finally { + db?.close(); + } + } + + private buildNoToolFinalizerArgs(prompt: string, sessionId: string | undefined, model: string): string[] { + const args = ['run', '--pure', '--agent', OPENCODE_NO_TOOL_FINALIZER_AGENT]; + if (sessionId) args.push('--session', sessionId); + if (model) args.push('-m', model); + args.push('--format', 'json', prompt); + return args; + } + + private buildNoToolFinalizerEnv(childEnv: Record): Record { + return { + ...childEnv, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + permission: OPENCODE_NO_TOOL_PERMISSION, + agent: { + [OPENCODE_NO_TOOL_FINALIZER_AGENT]: { + mode: 'primary', + permission: OPENCODE_NO_TOOL_PERMISSION, + }, + }, + }), + }; + } + private buildArgs( prompt: string, sessionId?: string, diff --git a/packages/api/test/opencode-agent-service.test.js b/packages/api/test/opencode-agent-service.test.js index 160f3b781c..3165195f3e 100644 --- a/packages/api/test/opencode-agent-service.test.js +++ b/packages/api/test/opencode-agent-service.test.js @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { PassThrough } from 'node:stream'; import { describe, mock, test } from 'node:test'; +import Database from 'better-sqlite3'; import { OpenCodeAgentService, summarizeOpenCodeEnvForDebug, @@ -70,6 +74,34 @@ async function collect(iterable) { return messages; } +function createOpenCodeRecoveryDb({ sessionId, messageId, text }) { + const dir = mkdtempSync(join(tmpdir(), 'cat-cafe-opencode-db-')); + const dbPath = join(dir, 'opencode.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE part ( + id text PRIMARY KEY, + message_id text NOT NULL, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); + `); + db.prepare( + 'INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)', + ).run( + 'prt_recovered_text', + messageId, + sessionId, + 1780915410601, + 1780915410687, + JSON.stringify({ type: 'text', text }), + ); + db.close(); + return dbPath; +} + // ── opencode JSON event fixtures ── const STEP_START = { @@ -944,6 +976,33 @@ describe('OpenCodeAgentService', () => { ); }); + test('step_start-only NDJSON recovers assistant text from OpenCode SQLite by session/message id', async () => { + const proc = createMockProcess(); + const spawnFn = mock.fn(() => proc); + const dbPath = createOpenCodeRecoveryDb({ + sessionId: STEP_START.sessionID, + messageId: STEP_START.part.messageID, + text: 'Recovered from OpenCode SQLite.', + }); + const service = new OpenCodeAgentService({ + catId: 'opencode', + spawnFn, + model: 'deepseek-chat', + opencodeDbPath: dbPath, + }); + const promise = collect(service.invoke('Test silent', { invocationId: 'inv-silent-sqlite-recovery' })); + + emitOpenCodeEvents(proc, [STEP_START]); + const messages = await promise; + + const recovered = messages.find((m) => m.type === 'text'); + assert.equal(recovered?.content, 'Recovered from OpenCode SQLite.'); + assert.ok( + !messages.some((m) => m.type === 'system_info' && m.metadata?.cliDiagnostics?.reasonCode === 'silent_completion'), + 'SQLite recovery supplies text, so silent_completion must not fire', + ); + }); + test('AC-G3: text event present → does NOT yield silent_completion (no false positive)', async () => { const proc = createMockProcess(); const spawnFn = mock.fn(() => proc); @@ -1000,6 +1059,77 @@ describe('OpenCodeAgentService', () => { // When the CLI produces events but no step_finish carries tokens, auto-handoff // based on context fill cannot fire. The service must surface a persistent // visible alert so users know automatic handoff is unavailable. + test('post-tool completion gap runs a no-tool finalizer and replaces the incomplete prelude', async () => { + const proc = createMockProcess(); + const finalizerProc = createMockProcess(); + let spawnCalls = 0; + const spawnFn = mock.fn(() => { + spawnCalls++; + if (spawnCalls === 2) { + process.nextTick(() => { + emitOpenCodeEvents(finalizerProc, [ + STEP_START, + { + ...TEXT_RESPONSE, + part: { ...TEXT_RESPONSE.part, text: 'The active model is deepseek-v4-pro.' }, + }, + STEP_FINISH, + ]); + }); + return finalizerProc; + } + return proc; + }); + const service = new OpenCodeAgentService({ catId: 'opencode', spawnFn, model: 'deepseek-v4-pro' }); + const promise = collect(service.invoke('Check the active model and report it')); + + emitOpenCodeEvents(proc, [ + STEP_START, + { + ...TEXT_RESPONSE, + part: { ...TEXT_RESPONSE.part, text: 'Let me verify from the actual config rather than guessing.' }, + }, + { + ...TOOL_USE, + part: { + ...TOOL_USE.part, + tool: 'read', + state: { + status: 'completed', + input: { filePath: '.cat-cafe/cat-catalog.json', offset: 210, limit: 50 }, + output: '"defaultModel": "deepseek-v4-pro"', + }, + }, + }, + STEP_FINISH, + ]); + + const messages = await promise; + const textMsgs = messages.filter((m) => m.type === 'text'); + + assert.equal(spawnCalls, 2, 'post-tool gap should start exactly one no-tool finalizer invocation'); + const finalizerArgs = spawnFn.mock.calls[1].arguments[1]; + assert.ok(finalizerArgs.includes('--session'), `finalizer must resume the same OpenCode session: ${finalizerArgs}`); + assert.equal(finalizerArgs[finalizerArgs.indexOf('--session') + 1], 'ses_test123'); + assert.ok(finalizerArgs.includes('--agent'), `finalizer must use a dedicated no-tool agent: ${finalizerArgs}`); + assert.equal(finalizerArgs[finalizerArgs.indexOf('--agent') + 1], 'cat-cafe-no-tool-finalizer'); + const finalizerConfig = JSON.parse(spawnFn.mock.calls[1].arguments[2].env.OPENCODE_CONFIG_CONTENT); + assert.equal(finalizerConfig.permission['*'], 'deny', 'finalizer env must deny tool execution'); + assert.equal( + finalizerConfig.agent['cat-cafe-no-tool-finalizer'].permission['*'], + 'deny', + 'dedicated finalizer agent must deny tool execution', + ); + assert.equal(textMsgs.length, 2, 'finalizer should add one replacement text after the incomplete prelude'); + assert.equal(textMsgs[0].content, 'Let me verify from the actual config rather than guessing.'); + assert.equal(textMsgs.at(-1)?.textMode, 'replace'); + assert.equal(textMsgs.at(-1)?.content, 'The active model is deepseek-v4-pro.'); + assert.ok( + !messages.some((m) => m.type === 'system_info' && m.metadata?.cliDiagnostics?.reasonCode === 'silent_completion'), + 'post-tool finalizer is a recovery, not silent_completion', + ); + }); + test('issue #1208: opencode events without usage telemetry yield warning alert', async () => { const proc = createMockProcess(); const spawnFn = mock.fn(() => proc); From 41c5ffc1362ada7c91bad4cd58f191c5f146c40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=9A=E7=A0=9A?= Date: Thu, 13 Aug 2026 00:10:52 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(opencode):=20harden=20no-output=20recov?= =?UTF-8?q?ery=20[=E7=A0=9A=E7=A0=9A/gpt-5.5=F0=9F=90=BE]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bug-report.md | 32 +- packages/api/src/config/env-registry.ts | 10 + .../agents/providers/OpenCodeAgentService.ts | 228 ++++--------- .../agents/providers/opencode-recovery.ts | 316 ++++++++++++++++++ .../api/test/opencode-agent-service.test.js | 153 +++++++++ packages/api/test/opencode-recovery.test.js | 150 +++++++++ 6 files changed, 718 insertions(+), 171 deletions(-) create mode 100644 packages/api/src/domains/cats/services/agents/providers/opencode-recovery.ts create mode 100644 packages/api/test/opencode-recovery.test.js diff --git a/docs/bug-report/opencode-post-tool-output-gap/bug-report.md b/docs/bug-report/opencode-post-tool-output-gap/bug-report.md index dee1f0f2ec..36ca7ab20f 100644 --- a/docs/bug-report/opencode-post-tool-output-gap/bug-report.md +++ b/docs/bug-report/opencode-post-tool-output-gap/bug-report.md @@ -1,3 +1,11 @@ +--- +title: OpenCode + DeepSeek no-output recovery +status: external-review +issue: https://github.com/zts212653/clowder-ai/issues/1341 +pr: https://github.com/zts212653/clowder-ai/pull/1342 +created: 2026-08-12 +--- + # OpenCode + DeepSeek no-output recovery ## Summary @@ -49,9 +57,9 @@ Recovery rule: - At normal CLI completion, detect `textEventCount > 0 && lastToolEventIndex > lastTextEventIndex && !errorAlreadyYielded`. - Run exactly one no-tool finalizer in the same OpenCode session using a dedicated `cat-cafe-no-tool-finalizer` agent. -- Deny all tools in the finalizer config. The finalizer may use only existing session state and the latest tool output. +- Deny all tools in the finalizer config. The finalizer may use only existing session state and a sanitized latest-tool-output summary. - Emit the first finalizer text with `textMode: "replace"` so it replaces the incomplete prelude. -- If the finalizer produces no text or attempts a tool, emit a deterministic diagnostic fallback with the latest tool output summary. +- If the finalizer produces no text or attempts a tool, emit a deterministic diagnostic fallback with the sanitized latest-tool-output summary. ## Non-goals and safety boundaries @@ -78,18 +86,30 @@ The correct completion contract is: ## Implementation -Code path: `packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts` +Code paths: + +- `packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts` +- `packages/api/src/domains/cats/services/agents/providers/opencode-recovery.ts` - Tracks `lastTextEventIndex`, `lastToolEventIndex`, latest tool trace, and latest `step_start` message reference. - Adds read-only SQLite recovery for phenotype A using `part.session_id + part.message_id`. +- Resolves OpenCode SQLite through an explicit override test seam, canonical `OPENCODE_DB`, XDG/platform data roots, and channel-named `opencode*.db` files. - Adds a no-tool finalizer for phenotype B using `--session` and `--agent cat-cafe-no-tool-finalizer`. -- Denies all finalizer tool permissions through `OPENCODE_CONFIG_CONTENT`. -- Preserves deterministic fallback text only for finalizer failure. +- Denies all finalizer tool permissions through `OPENCODE_CONFIG_CONTENT` and treats any observed finalizer `tool_use` as a poisoned finalizer result. +- Buffers finalizer text until the finalizer completes without tool/error poison, then emits the first text with `textMode: "replace"`. +- Uses one central safe projector for latest tool output before including it in the finalizer prompt or deterministic fallback. +- Preserves deterministic fallback text only for finalizer failure, with secrets and absolute paths redacted. +- Serializes invocations for the same OpenCode session so a second turn cannot race a first turn's finalization window. + +Regression tests: -Regression tests: `packages/api/test/opencode-agent-service.test.js` +- `packages/api/test/opencode-agent-service.test.js` +- `packages/api/test/opencode-recovery.test.js` - Adds a red/green SQLite recovery case for `step_start`-only NDJSON. - Updates the post-tool gap case to require a second no-tool finalizer invocation, session resume, deny-all permissions, and `textMode: "replace"`. +- Adds fail-closed finalizer poisoning, sanitized fallback, and same-session single-flight regression cases. +- Adds SQLite path-resolution, schema-drift, malformed-part, multi-part, and redaction tests at the recovery boundary. - Keeps the older AC-G3 cases for true silent diagnostics and pure tool-only completion. ## Verification diff --git a/packages/api/src/config/env-registry.ts b/packages/api/src/config/env-registry.ts index 56e596f5f3..0621b2d137 100644 --- a/packages/api/src/config/env-registry.ts +++ b/packages/api/src/config/env-registry.ts @@ -835,6 +835,16 @@ export const ENV_VARS: EnvDefinition[] = [ label: 'CLI 超时', settingsGroup: 'runtime', }, + { + name: 'OPENCODE_DB', + defaultValue: '(OpenCode default data dir)', + description: + 'Optional OpenCode SQLite database override used only for read-only silent-completion recovery. Leave unset to use OpenCode data-dir discovery.', + category: 'cli', + sensitive: false, + runtimeEditable: false, + hubVisible: false, + }, { name: 'CAT_CAFE_SUPERVISOR_PARENT_PID', defaultValue: '(内部注入)', diff --git a/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts index d41767b084..ed14eadb0a 100644 --- a/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts @@ -14,10 +14,6 @@ * error → error */ -import Database from 'better-sqlite3'; -import { existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; import { type CatId, createCatId } from '@cat-cafe/shared'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; @@ -48,6 +44,18 @@ import { userControlsOpenCodeAutoApprove, } from './opencode-auto-approval.js'; import { transformOpenCodeEvent } from './opencode-event-transform.js'; +import { + buildOpenCodePostToolFallbackText, + buildOpenCodePostToolFinalizerPrompt, + extractOpenCodeMessageRef, + extractOpenCodeToolTrace, + identifierPrefix, + OPENCODE_NO_TOOL_FINALIZER_AGENT, + OPENCODE_NO_TOOL_PERMISSION, + type OpenCodeToolTrace, + recoverOpenCodeSilentCompletion, + SessionSingleFlight, +} from './opencode-recovery.js'; const log = createModuleLogger('opencode-agent'); @@ -74,9 +82,7 @@ interface OpenCodeAgentServiceOptions { const OPENCODE_API_KEY_ENV = 'OPENCODE_API_KEY'; const ANTHROPIC_API_KEY_ENV = 'ANTHROPIC_API_KEY'; const ANTHROPIC_BASE_URL_ENV = 'ANTHROPIC_BASE_URL'; -const OPENCODE_DB_PATH_ENV = 'OPENCODE_DB_PATH'; const OPENCODE_READ_ONLY_AGENT = 'cat-cafe-read-only'; -const OPENCODE_NO_TOOL_FINALIZER_AGENT = 'cat-cafe-no-tool-finalizer'; const OPENCODE_READ_ONLY_PERMISSION = { '*': 'deny', read: 'allow', @@ -91,21 +97,6 @@ const OPENCODE_READ_ONLY_PERMISSION = { task: 'deny', question: 'deny', } as const; -const OPENCODE_NO_TOOL_PERMISSION = { - '*': 'deny', - read: 'deny', - glob: 'deny', - grep: 'deny', - lsp: 'deny', - skill: 'deny', - webfetch: 'deny', - websearch: 'deny', - edit: 'deny', - bash: 'deny', - task: 'deny', - question: 'deny', -} as const; -const MAX_OPENCODE_VISIBLE_TOOL_OUTPUT_CHARS = 4_000; // Process-wide cache: --auto support is a property of the installed opencode binary. // Restart the API process after upgrading opencode so this capability is re-probed. let sharedOpenCodeAutoApproveProbe: Promise | undefined; @@ -121,12 +112,6 @@ export interface OpenCodeEnvDebugSummary { catCafeOcBaseUrl: string; } -interface OpenCodeToolTrace { - toolName: string; - status?: string; - output?: unknown; -} - interface OpenCodePostToolFinalizerParams { command: string; cwd?: string; @@ -138,11 +123,6 @@ interface OpenCodePostToolFinalizerParams { options?: AgentServiceOptions; } -interface OpenCodeMessageRef { - sessionId?: string; - messageId?: string; -} - function isPermanentOpenCodeProviderFailure(event: unknown, reasonCode: string | undefined): boolean { if (typeof event !== 'object' || event === null) return false; const rawError = (event as Record).error; @@ -171,97 +151,6 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -function truncateForVisibleText(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}\n...[truncated ${value.length - maxChars} chars]`; -} - -function stringifyForVisibleText(value: unknown, maxChars: number): string { - if (value == null) return ''; - if (typeof value === 'string') return truncateForVisibleText(value, maxChars); - const jsonText = JSON.stringify(value, null, 2); - return truncateForVisibleText(typeof jsonText === 'string' ? jsonText : String(value), maxChars); -} - -function extractOpenCodeToolTrace(event: unknown): OpenCodeToolTrace | null { - if (!isRecord(event)) return null; - if (event.type !== 'tool_use') return null; - const part = isRecord(event.part) ? event.part : {}; - const state = isRecord(part.state) ? part.state : {}; - const toolName = typeof part.tool === 'string' && part.tool.length > 0 ? part.tool : 'unknown'; - const trace: OpenCodeToolTrace = { toolName }; - if (typeof state.status === 'string' && state.status.length > 0) { - trace.status = state.status; - } - if ('output' in state) { - trace.output = state.output; - } - return trace; -} - -function buildOpenCodePostToolFinalizerPrompt(trace: OpenCodeToolTrace | null): string { - const toolName = trace ? trace.toolName : 'a tool'; - const outputText = stringifyForVisibleText(trace ? trace.output : undefined, MAX_OPENCODE_VISIBLE_TOOL_OUTPUT_CHARS); - return [ - 'The previous OpenCode turn stopped immediately after a tool call and did not produce the final assistant text.', - 'Do not call any tools. Use only the existing session state and the latest tool result below to write the final answer to the user.', - `Latest tool: ${toolName}${trace?.status ? ` (${trace.status})` : ''}.`, - outputText.length > 0 ? `Latest tool output:\n${outputText}` : 'No tool output was captured.', - 'If the available tool result is insufficient, state the limitation briefly instead of inventing details.', - ].join('\n\n'); -} - -function extractOpenCodeMessageRef(event: unknown): OpenCodeMessageRef | null { - if (typeof event !== 'object' || event === null) return null; - const record = event as Record; - if (record.type !== 'step_start') return null; - const part = typeof record.part === 'object' && record.part !== null ? (record.part as Record) : {}; - const sessionId = - typeof part.sessionID === 'string' - ? part.sessionID - : typeof record.sessionID === 'string' - ? record.sessionID - : undefined; - const messageId = - typeof part.messageID === 'string' - ? part.messageID - : typeof record.messageID === 'string' - ? record.messageID - : undefined; - if (!sessionId && !messageId) return null; - return { ...(sessionId ? { sessionId } : {}), ...(messageId ? { messageId } : {}) }; -} - -function extractOpenCodePartText(data: string): string | null { - try { - const parsed = JSON.parse(data) as unknown; - if (typeof parsed !== 'object' || parsed === null) return null; - const part = parsed as Record; - if (part.type !== 'text' || typeof part.text !== 'string') return null; - const text = part.text; - return text.trim().length > 0 ? text : null; - } catch { - return null; - } -} - -function defaultOpenCodeDbPath(): string { - return join(homedir(), '.local', 'share', 'opencode', 'opencode.db'); -} - -function buildOpenCodePostToolFallbackText(trace: OpenCodeToolTrace | null, reason: string): string { - const toolName = trace ? trace.toolName : 'a tool'; - const outputText = stringifyForVisibleText(trace ? trace.output : undefined, MAX_OPENCODE_VISIBLE_TOOL_OUTPUT_CHARS); - const lines = [ - `OpenCode stopped after running \`${toolName}\` but did not produce a final text response.`, - `No-tool finalizer recovery did not produce text: ${reason}.`, - trace?.status ? `Tool status: ${trace.status}.` : undefined, - outputText.length > 0 ? `Latest tool output:\n${outputText}` : 'No tool output was captured.', - 'This is a recovery message; review the tool output before treating the task as complete.', - ]; - return lines.filter((line): line is string => Boolean(line)).join('\n\n'); -} - function summarizeDebugValue(value: string | null | undefined): string { if (value === null) return '(cleared)'; if (!value) return '(unset)'; @@ -312,6 +201,7 @@ export class OpenCodeAgentService implements L0InjectableAgentService { readonly l0CompilerFn: import('../../types.js').L0CompilerFn | undefined; private readonly autoApproveProbeFn: OpenCodeAutoApproveProbeFn | undefined; private readonly opencodeDbPath: string | undefined; + private readonly sessionSingleFlight = new SessionSingleFlight(); private autoApproveProbe: Promise | undefined; constructor(options?: OpenCodeAgentServiceOptions) { @@ -359,6 +249,10 @@ export class OpenCodeAgentService implements L0InjectableAgentService { } async *invoke(prompt: string, options?: AgentServiceOptions): AsyncIterable { + yield* this.sessionSingleFlight.run(options?.sessionId, () => this.invokeUnlocked(prompt, options)); + } + + private async *invokeUnlocked(prompt: string, options?: AgentServiceOptions): AsyncIterable { const readOnly = options?.toolExecutionPolicy?.mode === 'read_only'; // P1-2: runtime model override takes precedence over constructor model const effectiveModel = options?.callbackEnv?.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE ?? this.model; @@ -643,7 +537,12 @@ export class OpenCodeAgentService implements L0InjectableAgentService { } log.info( - { catId: this.catId, totalEvents: eventCount, textEvents: textEventCount, sessionId: metadata.sessionId }, + { + catId: this.catId, + totalEvents: eventCount, + textEvents: textEventCount, + sessionIdPrefix: identifierPrefix(metadata.sessionId), + }, 'OpenCode CLI invocation completed', ); if (eventCount > 0 && textEventCount === 0 && !errorAlreadyYielded && !toolUseEmitted) { @@ -652,8 +551,8 @@ export class OpenCodeAgentService implements L0InjectableAgentService { log.info( { catId: this.catId, - sessionId: metadata.sessionId, - messageId: lastAssistantMessageId, + sessionIdPrefix: identifierPrefix(metadata.sessionId), + messageIdPrefix: identifierPrefix(lastAssistantMessageId), textLength: recoveredText.length, }, 'Recovered OpenCode silent completion text from local SQLite state', @@ -687,7 +586,9 @@ export class OpenCodeAgentService implements L0InjectableAgentService { childEnv, effectiveModel, metadata, - ...(metadata.sessionId ?? options?.sessionId ? { sessionId: metadata.sessionId ?? options?.sessionId } : {}), + ...((metadata.sessionId ?? options?.sessionId) + ? { sessionId: metadata.sessionId ?? options?.sessionId } + : {}), trace: lastToolTrace, options, })) { @@ -790,7 +691,8 @@ export class OpenCodeAgentService implements L0InjectableAgentService { ? params.options.spawnCliOverride(cliOpts) : spawnCli(cliOpts, this.spawnFn ? { spawnFn: this.spawnFn } : undefined); - let finalizerTextEmitted = false; + const finalizerTextBuffer: AgentMessage[] = []; + let finalizerPoisoned = false; let finalizerErrorReason: string | undefined; const finalizerEventTypes = new Set(); @@ -810,6 +712,7 @@ export class OpenCodeAgentService implements L0InjectableAgentService { finalizerEventTypes.add(evtType); if (isCliTimeout(event)) { + finalizerPoisoned = true; finalizerErrorReason = 'timeout'; log.warn( { catId: this.catId, invocationId: params.options?.invocationId, timeoutMs: event.timeoutMs }, @@ -821,6 +724,7 @@ export class OpenCodeAgentService implements L0InjectableAgentService { continue; } if (isCliError(event)) { + finalizerPoisoned = true; finalizerErrorReason = event.reasonCode ?? 'cli_error'; log.warn( { catId: this.catId, invocationId: params.options?.invocationId, reasonCode: event.reasonCode }, @@ -836,6 +740,7 @@ export class OpenCodeAgentService implements L0InjectableAgentService { continue; } if (result.type === 'tool_use') { + finalizerPoisoned = true; finalizerErrorReason = 'tool_use_blocked'; log.warn( { @@ -848,6 +753,7 @@ export class OpenCodeAgentService implements L0InjectableAgentService { continue; } if (result.type === 'error') { + finalizerPoisoned = true; finalizerErrorReason = 'provider_error'; log.warn( { catId: this.catId, invocationId: params.options?.invocationId, error: result.error }, @@ -856,34 +762,40 @@ export class OpenCodeAgentService implements L0InjectableAgentService { continue; } if (result.type === 'text') { - yield { + if (finalizerPoisoned) continue; + finalizerTextBuffer.push({ ...result, metadata: params.metadata, - textMode: finalizerTextEmitted ? result.textMode : ('replace' as const), - }; - finalizerTextEmitted = true; + textMode: finalizerTextBuffer.length === 0 ? ('replace' as const) : result.textMode, + }); continue; } if (result.type === 'agent_loop') { yield { ...result, metadata: - result.metadata?.usage != null - ? { ...params.metadata, usage: result.metadata.usage } - : params.metadata, + result.metadata?.usage != null ? { ...params.metadata, usage: result.metadata.usage } : params.metadata, }; } } - if (!finalizerTextEmitted) { + if (!finalizerPoisoned && finalizerTextBuffer.length > 0) { + for (const finalizerText of finalizerTextBuffer) { + yield finalizerText; + } + return; + } + + if (finalizerTextBuffer.length === 0 || finalizerPoisoned) { log.warn( { catId: this.catId, invocationId: params.options?.invocationId, eventTypes: Array.from(finalizerEventTypes), reason: finalizerErrorReason ?? 'no_text', + finalizerPoisoned, }, - 'OpenCode no-tool finalizer produced no text - yielding deterministic recovery text', + 'OpenCode no-tool finalizer produced no usable text - yielding deterministic recovery text', ); yield { type: 'text', @@ -897,38 +809,24 @@ export class OpenCodeAgentService implements L0InjectableAgentService { } private recoverSilentCompletionText(sessionId: string | undefined, messageId: string | undefined): string | null { - if (!sessionId || !messageId) return null; - const dbPath = this.opencodeDbPath ?? process.env[OPENCODE_DB_PATH_ENV] ?? defaultOpenCodeDbPath(); - const dbPathSource = this.opencodeDbPath ? 'override' : process.env[OPENCODE_DB_PATH_ENV] ? 'env' : 'default'; - if (!existsSync(dbPath)) return null; - - let db: Database.Database | undefined; - try { - db = new Database(dbPath, { readonly: true, fileMustExist: true }); - const rows = db - .prepare( - ` - SELECT data - FROM part - WHERE session_id = ? AND message_id = ? - ORDER BY time_created ASC - `, - ) - .all(sessionId, messageId) as Array<{ data: string }>; - const text = rows - .map((row) => (typeof row.data === 'string' ? extractOpenCodePartText(row.data) : null)) - .filter((partText): partText is string => partText !== null) - .join(''); - return text.trim().length > 0 ? text : null; - } catch (err) { + const recovered = recoverOpenCodeSilentCompletion({ + sessionId, + messageId, + ...(this.opencodeDbPath ? { overridePath: this.opencodeDbPath } : {}), + }); + if (recovered.reason && recovered.reason !== 'missing_db' && recovered.reason !== 'no_text') { log.warn( - { catId: this.catId, sessionId, messageId, dbPathSource, err }, + { + catId: this.catId, + sessionIdPrefix: identifierPrefix(sessionId), + messageIdPrefix: identifierPrefix(messageId), + dbPathSource: recovered.source, + reason: recovered.reason, + }, 'Failed to recover OpenCode silent completion text from local SQLite state', ); - return null; - } finally { - db?.close(); } + return recovered.text; } private buildNoToolFinalizerArgs(prompt: string, sessionId: string | undefined, model: string): string[] { diff --git a/packages/api/src/domains/cats/services/agents/providers/opencode-recovery.ts b/packages/api/src/domains/cats/services/agents/providers/opencode-recovery.ts new file mode 100644 index 0000000000..a3c42bcea9 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/opencode-recovery.ts @@ -0,0 +1,316 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { homedir, platform as osPlatform } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { sanitizeCliStderr } from '../../../../../utils/sanitize-cli-stderr.js'; + +export const OPENCODE_DB_ENV = 'OPENCODE_DB'; +export const OPENCODE_NO_TOOL_FINALIZER_AGENT = 'cat-cafe-no-tool-finalizer'; +export const OPENCODE_NO_TOOL_PERMISSION = { + '*': 'deny', + read: 'deny', + glob: 'deny', + grep: 'deny', + lsp: 'deny', + skill: 'deny', + webfetch: 'deny', + websearch: 'deny', + edit: 'deny', + bash: 'deny', + task: 'deny', + question: 'deny', +} as const; + +const MAX_OPENCODE_VISIBLE_TOOL_OUTPUT_CHARS = 4_000; +const DEFAULT_SAFE_TOOL_OUTPUT_CHARS = 1_000; +const OPENCODE_DB_FILE_PATTERN = /^opencode(?:[-_.][\w-]+)?\.db$/; + +export interface OpenCodeToolTrace { + toolName: string; + status?: string; + output?: unknown; +} + +export interface OpenCodeMessageRef { + sessionId?: string; + messageId?: string; +} + +export interface OpenCodeDbCandidate { + path: string; + source: 'override' | 'OPENCODE_DB' | 'xdg' | 'localappdata' | 'darwin' | 'default'; +} + +export interface OpenCodeDbResolutionOptions { + overridePath?: string; + env?: Record; + homeDir?: string; + platform?: NodeJS.Platform; +} + +export interface OpenCodeSilentRecoveryOptions extends OpenCodeDbResolutionOptions { + sessionId?: string; + messageId?: string; +} + +export interface OpenCodeSilentRecoveryResult { + text: string | null; + source?: OpenCodeDbCandidate['source']; + reason?: 'missing_ref' | 'missing_db' | 'schema_unavailable' | 'no_text'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function truncateForVisibleText(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n...[truncated ${value.length - maxChars} chars]`; +} + +function stringifyForVisibleText(value: unknown, maxChars: number): string { + if (value == null) return ''; + if (typeof value === 'string') return truncateForVisibleText(value, maxChars); + const jsonText = JSON.stringify(value, null, 2); + return truncateForVisibleText(typeof jsonText === 'string' ? jsonText : String(value), maxChars); +} + +function redactGenericAbsolutePaths(value: string): string { + return value + .replace(/\b[A-Za-z]:\\(?:[^\s"'<>|]+\\)*[^\s"'<>|]+/g, '[redacted path]') + .replace( + /(^|[\s"'(=[{,:])\/(?:Users|home|var|private|opt|etc|mnt|workspace|tmp)\/[^\s"'<>{}|]+/g, + (_match, prefix: string) => `${prefix}[redacted path]`, + ); +} + +function redactShortProviderSecrets(value: string): string { + return value.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, '[redacted secret]'); +} + +export function projectSafeOpenCodeToolOutput(value: unknown, maxChars = DEFAULT_SAFE_TOOL_OUTPUT_CHARS): string { + const raw = stringifyForVisibleText(value, MAX_OPENCODE_VISIBLE_TOOL_OUTPUT_CHARS); + if (raw.trim().length === 0) return ''; + const sanitized = redactGenericAbsolutePaths(redactShortProviderSecrets(sanitizeCliStderr(raw))); + return truncateForVisibleText(sanitized, maxChars); +} + +export function extractOpenCodeToolTrace(event: unknown): OpenCodeToolTrace | null { + if (!isRecord(event)) return null; + if (event.type !== 'tool_use') return null; + const part = isRecord(event.part) ? event.part : {}; + const state = isRecord(part.state) ? part.state : {}; + const toolName = typeof part.tool === 'string' && part.tool.length > 0 ? part.tool : 'unknown'; + const trace: OpenCodeToolTrace = { toolName }; + if (typeof state.status === 'string' && state.status.length > 0) { + trace.status = state.status; + } + if ('output' in state) { + trace.output = state.output; + } + return trace; +} + +export function buildOpenCodePostToolFinalizerPrompt(trace: OpenCodeToolTrace | null): string { + const toolName = trace ? trace.toolName : 'a tool'; + const outputText = projectSafeOpenCodeToolOutput( + trace ? trace.output : undefined, + MAX_OPENCODE_VISIBLE_TOOL_OUTPUT_CHARS, + ); + return [ + 'The previous OpenCode turn stopped immediately after a tool call and did not produce the final assistant text.', + 'Do not call any tools. Use only the existing session state and the sanitized latest tool result below to write the final answer to the user.', + `Latest tool: ${toolName}${trace?.status ? ` (${trace.status})` : ''}.`, + outputText.length > 0 ? `Latest tool output (sanitized):\n${outputText}` : 'No safe tool output was captured.', + 'If the available tool result is insufficient, state the limitation briefly instead of inventing details.', + ].join('\n\n'); +} + +export function buildOpenCodePostToolFallbackText(trace: OpenCodeToolTrace | null, reason: string): string { + const toolName = trace ? trace.toolName : 'a tool'; + const outputText = projectSafeOpenCodeToolOutput(trace ? trace.output : undefined); + const lines = [ + `OpenCode stopped after running \`${toolName}\` but did not produce a final text response.`, + `No-tool finalizer recovery did not produce text: ${reason}.`, + trace?.status ? `Tool status: ${trace.status}.` : undefined, + outputText.length > 0 ? `Latest tool output (sanitized):\n${outputText}` : 'No safe tool output was captured.', + 'This is a recovery message; review the sanitized tool output before treating the task as complete.', + ]; + return lines.filter((line): line is string => Boolean(line)).join('\n\n'); +} + +export function extractOpenCodeMessageRef(event: unknown): OpenCodeMessageRef | null { + if (!isRecord(event)) return null; + if (event.type !== 'step_start') return null; + const part = isRecord(event.part) ? event.part : {}; + const sessionId = + typeof part.sessionID === 'string' + ? part.sessionID + : typeof event.sessionID === 'string' + ? event.sessionID + : undefined; + const messageId = + typeof part.messageID === 'string' + ? part.messageID + : typeof event.messageID === 'string' + ? event.messageID + : undefined; + if (!sessionId && !messageId) return null; + return { ...(sessionId ? { sessionId } : {}), ...(messageId ? { messageId } : {}) }; +} + +function extractOpenCodePartText(data: string): string | null { + try { + const parsed = JSON.parse(data) as unknown; + if (!isRecord(parsed)) return null; + if (parsed.type !== 'text' || typeof parsed.text !== 'string') return null; + const text = parsed.text; + return text.trim().length > 0 ? text : null; + } catch { + return null; + } +} + +function addCandidate( + candidates: OpenCodeDbCandidate[], + path: string | undefined, + source: OpenCodeDbCandidate['source'], +) { + if (!path) return; + if (candidates.some((candidate) => candidate.path === path)) return; + candidates.push({ path, source }); +} + +function addDbFilesFromDirectory( + candidates: OpenCodeDbCandidate[], + dir: string | undefined, + source: OpenCodeDbCandidate['source'], +) { + if (!dir || !existsSync(dir)) return; + try { + for (const entry of readdirSync(dir).sort()) { + if (OPENCODE_DB_FILE_PATTERN.test(entry)) { + addCandidate(candidates, join(dir, entry), source); + } + } + } catch { + return; + } +} + +export function resolveOpenCodeDbCandidates(options: OpenCodeDbResolutionOptions = {}): OpenCodeDbCandidate[] { + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? homedir(); + const runtimePlatform = options.platform ?? osPlatform(); + const candidates: OpenCodeDbCandidate[] = []; + + addCandidate(candidates, options.overridePath, 'override'); + addCandidate(candidates, env[OPENCODE_DB_ENV], 'OPENCODE_DB'); + + const xdgOpenCodeDir = env.XDG_DATA_HOME ? join(env.XDG_DATA_HOME, 'opencode') : undefined; + addCandidate(candidates, xdgOpenCodeDir ? join(xdgOpenCodeDir, 'opencode.db') : undefined, 'xdg'); + addDbFilesFromDirectory(candidates, xdgOpenCodeDir, 'xdg'); + + const localAppDataOpenCodeDir = env.LOCALAPPDATA ? join(env.LOCALAPPDATA, 'opencode') : undefined; + if (runtimePlatform === 'win32' || localAppDataOpenCodeDir) { + addCandidate( + candidates, + localAppDataOpenCodeDir ? join(localAppDataOpenCodeDir, 'opencode.db') : undefined, + 'localappdata', + ); + addDbFilesFromDirectory(candidates, localAppDataOpenCodeDir, 'localappdata'); + } + + if (runtimePlatform === 'darwin') { + const darwinOpenCodeDir = join(homeDir, 'Library', 'Application Support', 'opencode'); + addCandidate(candidates, join(darwinOpenCodeDir, 'opencode.db'), 'darwin'); + addDbFilesFromDirectory(candidates, darwinOpenCodeDir, 'darwin'); + } + + const defaultOpenCodeDir = join(homeDir, '.local', 'share', 'opencode'); + addCandidate(candidates, join(defaultOpenCodeDir, 'opencode.db'), 'default'); + addDbFilesFromDirectory(candidates, defaultOpenCodeDir, 'default'); + + return candidates; +} + +export function recoverOpenCodeSilentCompletion(options: OpenCodeSilentRecoveryOptions): OpenCodeSilentRecoveryResult { + if (!options.sessionId || !options.messageId) return { text: null, reason: 'missing_ref' }; + + let sawExistingDb = false; + let sawSchemaError = false; + let schemaErrorSource: OpenCodeDbCandidate['source'] | undefined; + for (const candidate of resolveOpenCodeDbCandidates(options)) { + if (!existsSync(candidate.path)) continue; + sawExistingDb = true; + + let db: Database.Database | undefined; + try { + db = new Database(candidate.path, { readonly: true, fileMustExist: true }); + const rows = db + .prepare( + ` + SELECT data + FROM part + WHERE session_id = ? AND message_id = ? + ORDER BY time_created ASC + `, + ) + .all(options.sessionId, options.messageId) as Array<{ data: unknown }>; + const text = rows + .map((row) => (typeof row.data === 'string' ? extractOpenCodePartText(row.data) : null)) + .filter((partText): partText is string => partText !== null) + .join(''); + if (text.trim().length > 0) return { text, source: candidate.source }; + } catch { + sawSchemaError = true; + schemaErrorSource = candidate.source; + } finally { + db?.close(); + } + } + + if (!sawExistingDb) return { text: null, reason: 'missing_db' }; + if (sawSchemaError) return { text: null, source: schemaErrorSource, reason: 'schema_unavailable' }; + return { text: null, reason: 'no_text' }; +} + +export function identifierPrefix(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.slice(0, 8); +} + +export class SessionSingleFlight { + private readonly tails = new Map>(); + + async *run(key: string | undefined, factory: () => AsyncIterable): AsyncIterable { + const release = await this.acquire(key); + try { + yield* factory(); + } finally { + release(); + } + } + + private async acquire(key: string | undefined): Promise<() => void> { + if (!key) return () => {}; + + const previous = this.tails.get(key) ?? Promise.resolve(); + let releaseCurrent!: () => void; + const current = new Promise((resolve) => { + releaseCurrent = resolve; + }); + const tail = previous.catch(() => undefined).then(() => current); + this.tails.set(key, tail); + + await previous.catch(() => undefined); + + let released = false; + return () => { + if (released) return; + released = true; + releaseCurrent(); + if (this.tails.get(key) === tail) this.tails.delete(key); + }; + } +} diff --git a/packages/api/test/opencode-agent-service.test.js b/packages/api/test/opencode-agent-service.test.js index 3165195f3e..0f30ad7632 100644 --- a/packages/api/test/opencode-agent-service.test.js +++ b/packages/api/test/opencode-agent-service.test.js @@ -74,6 +74,18 @@ async function collect(iterable) { return messages; } +async function delay(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor(predicate, description) { + for (let i = 0; i < 50; i++) { + if (predicate()) return; + await delay(5); + } + assert.fail(`timed out waiting for ${description}`); +} + function createOpenCodeRecoveryDb({ sessionId, messageId, text }) { const dir = mkdtempSync(join(tmpdir(), 'cat-cafe-opencode-db-')); const dbPath = join(dir, 'opencode.db'); @@ -1130,6 +1142,147 @@ describe('OpenCodeAgentService', () => { ); }); + test('post-tool finalizer tool attempt poisons later text and falls back safely', async () => { + const proc = createMockProcess(); + const finalizerProc = createMockProcess(); + let spawnCalls = 0; + const spawnFn = mock.fn(() => { + spawnCalls++; + if (spawnCalls === 2) { + process.nextTick(() => { + emitOpenCodeEvents(finalizerProc, [ + STEP_START, + { + ...TOOL_USE, + part: { ...TOOL_USE.part, tool: 'read', state: { status: 'completed', input: {}, output: 'forbidden' } }, + }, + { + ...TEXT_RESPONSE, + part: { ...TEXT_RESPONSE.part, text: 'accepted-after-tool' }, + }, + STEP_FINISH, + ]); + }); + return finalizerProc; + } + return proc; + }); + const service = new OpenCodeAgentService({ catId: 'opencode', spawnFn, model: 'deepseek-v4-pro' }); + const promise = collect(service.invoke('Check the active model and report it')); + + emitOpenCodeEvents(proc, [ + STEP_START, + { + ...TEXT_RESPONSE, + part: { ...TEXT_RESPONSE.part, text: 'Let me check that.' }, + }, + { + ...TOOL_USE, + part: { ...TOOL_USE.part, tool: 'read', state: { status: 'completed', output: 'model=deepseek-v4-pro' } }, + }, + STEP_FINISH, + ]); + + const messages = await promise; + const textMsgs = messages.filter((m) => m.type === 'text'); + + assert.equal(spawnCalls, 2); + assert.ok( + !textMsgs.some((m) => m.content === 'accepted-after-tool'), + 'any finalizer tool_use must poison the whole finalizer result before later text is accepted', + ); + assert.equal(textMsgs.at(-1)?.textMode, 'replace'); + assert.match(String(textMsgs.at(-1)?.content), /tool_use_blocked/); + }); + + test('post-tool deterministic fallback redacts raw tool output secrets and absolute paths', async () => { + const proc = createMockProcess(); + const finalizerProc = createMockProcess(); + let spawnCalls = 0; + const spawnFn = mock.fn(() => { + spawnCalls++; + if (spawnCalls === 2) { + process.nextTick(() => { + emitOpenCodeEvents(finalizerProc, [STEP_START, STEP_FINISH]); + }); + return finalizerProc; + } + return proc; + }); + const service = new OpenCodeAgentService({ catId: 'opencode', spawnFn, model: 'deepseek-v4-pro' }); + const promise = collect(service.invoke('Read config')); + + emitOpenCodeEvents(proc, [ + STEP_START, + { + ...TEXT_RESPONSE, + part: { ...TEXT_RESPONSE.part, text: 'Reading config.' }, + }, + { + ...TOOL_USE, + part: { + ...TOOL_USE.part, + tool: 'read', + state: { + status: 'completed', + output: + 'token=sk-review-secret-123 path=C:\\Users\\Alice\\secrets\\config.json also /Users/alice/.ssh/id_rsa', + }, + }, + }, + STEP_FINISH, + ]); + + const messages = await promise; + const fallback = messages.filter((m) => m.type === 'text').at(-1); + const content = String(fallback?.content); + + assert.equal(spawnCalls, 2); + assert.equal(fallback?.textMode, 'replace'); + assert.doesNotMatch(content, /sk-review-secret/); + assert.doesNotMatch(content, /C:\\Users\\Alice/); + assert.doesNotMatch(content, /\/Users\/alice/); + assert.match(content, /\[redacted/); + }); + + test('same OpenCode session invocations are serialized through finalization', async () => { + let active = 0; + let maxActive = 0; + let started = 0; + const releases = []; + const spawnCliOverride = async function* () { + active++; + started++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => releases.push(resolve)); + yield STEP_START; + yield TEXT_RESPONSE; + yield STEP_FINISH; + active--; + }; + const service = new OpenCodeAgentService({ + catId: 'opencode', + model: 'deepseek-v4-pro', + spawnFn: mock.fn(() => { + throw new Error('spawnCliOverride must own this test'); + }), + }); + const first = collect(service.invoke('first', { sessionId: 'ses_shared', spawnCliOverride })); + const second = collect(service.invoke('second', { sessionId: 'ses_shared', spawnCliOverride })); + + await waitFor(() => started >= 1, 'first session process to start'); + await delay(20); + assert.equal(maxActive, 1, 'second same-session invoke must not spawn before first completes'); + releases[0](); + await first; + + await waitFor(() => started === 2, 'second session process to start after first completes'); + releases[1](); + await second; + + assert.equal(maxActive, 1, 'same-session invokes must remain single-flight'); + }); + test('issue #1208: opencode events without usage telemetry yield warning alert', async () => { const proc = createMockProcess(); const spawnFn = mock.fn(() => proc); diff --git a/packages/api/test/opencode-recovery.test.js b/packages/api/test/opencode-recovery.test.js new file mode 100644 index 0000000000..cdabb38343 --- /dev/null +++ b/packages/api/test/opencode-recovery.test.js @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, test } from 'node:test'; +import Database from 'better-sqlite3'; +import { + projectSafeOpenCodeToolOutput, + recoverOpenCodeSilentCompletion, + resolveOpenCodeDbCandidates, +} from '../dist/domains/cats/services/agents/providers/opencode-recovery.js'; + +function createPartDb(dbPath, rows) { + mkdirSync(dirname(dbPath), { recursive: true }); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE part ( + id text PRIMARY KEY, + message_id text NOT NULL, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); + `); + const insert = db.prepare( + 'INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)', + ); + rows.forEach((row, index) => { + insert.run(`prt_${index}`, row.messageId, row.sessionId, 1780915410601 + index, 1780915410687 + index, row.data); + }); + db.close(); +} + +describe('opencode recovery boundary', () => { + test('recovers from OPENCODE_DB before default locations', () => { + const root = mkdtempSync(join(tmpdir(), 'cat-cafe-opencode-recovery-')); + const dbPath = join(root, 'custom', 'opencode-custom.db'); + createPartDb(dbPath, [ + { sessionId: 'ses_env', messageId: 'msg_env', data: JSON.stringify({ type: 'text', text: 'from env db' }) }, + ]); + + const recovered = recoverOpenCodeSilentCompletion({ + sessionId: 'ses_env', + messageId: 'msg_env', + env: { OPENCODE_DB: dbPath }, + homeDir: join(root, 'home'), + platform: 'linux', + }); + + assert.equal(recovered.text, 'from env db'); + assert.equal(recovered.source, 'OPENCODE_DB'); + }); + + test('discovers channel database files under XDG data root', () => { + const root = mkdtempSync(join(tmpdir(), 'cat-cafe-opencode-xdg-')); + const xdgRoot = join(root, 'xdg'); + const dbPath = join(xdgRoot, 'opencode', 'opencode-beta.db'); + createPartDb(dbPath, [ + { sessionId: 'ses_beta', messageId: 'msg_beta', data: JSON.stringify({ type: 'text', text: 'from beta db' }) }, + ]); + + const candidates = resolveOpenCodeDbCandidates({ + env: { XDG_DATA_HOME: xdgRoot }, + homeDir: join(root, 'home'), + platform: 'linux', + }); + assert.ok(candidates.some((candidate) => candidate.path === dbPath && candidate.source === 'xdg')); + + const recovered = recoverOpenCodeSilentCompletion({ + sessionId: 'ses_beta', + messageId: 'msg_beta', + env: { XDG_DATA_HOME: xdgRoot }, + homeDir: join(root, 'home'), + platform: 'linux', + }); + + assert.equal(recovered.text, 'from beta db'); + assert.equal(recovered.source, 'xdg'); + }); + + test('fails closed when db is missing or schema drifts', () => { + const root = mkdtempSync(join(tmpdir(), 'cat-cafe-opencode-schema-')); + const missing = recoverOpenCodeSilentCompletion({ + sessionId: 'ses_missing', + messageId: 'msg_missing', + env: {}, + homeDir: join(root, 'home'), + platform: 'linux', + }); + assert.equal(missing.text, null); + assert.equal(missing.reason, 'missing_db'); + + const driftDbPath = join(root, 'drift', 'opencode.db'); + mkdirSync(dirname(driftDbPath), { recursive: true }); + const db = new Database(driftDbPath); + db.exec('CREATE TABLE part_v2 (data text NOT NULL);'); + db.close(); + + const drift = recoverOpenCodeSilentCompletion({ + sessionId: 'ses_missing', + messageId: 'msg_missing', + overridePath: driftDbPath, + env: {}, + homeDir: join(root, 'home'), + platform: 'linux', + }); + assert.equal(drift.text, null); + assert.equal(drift.source, 'override'); + assert.equal(drift.reason, 'schema_unavailable'); + }); + + test('skips malformed and non-text parts while joining same-message text parts', () => { + const root = mkdtempSync(join(tmpdir(), 'cat-cafe-opencode-parts-')); + const dbPath = join(root, 'opencode.db'); + createPartDb(dbPath, [ + { sessionId: 'ses_parts', messageId: 'msg_parts', data: '{not-json' }, + { + sessionId: 'ses_parts', + messageId: 'msg_parts', + data: JSON.stringify({ type: 'reasoning', text: 'hidden reasoning' }), + }, + { sessionId: 'ses_parts', messageId: 'msg_parts', data: JSON.stringify({ type: 'text', text: 'part A ' }) }, + { sessionId: 'ses_parts', messageId: 'msg_parts', data: JSON.stringify({ type: 'text', text: 'part B' }) }, + { sessionId: 'other', messageId: 'msg_parts', data: JSON.stringify({ type: 'text', text: 'wrong session' }) }, + ]); + + const recovered = recoverOpenCodeSilentCompletion({ + sessionId: 'ses_parts', + messageId: 'msg_parts', + overridePath: dbPath, + env: {}, + homeDir: join(root, 'home'), + platform: 'linux', + }); + + assert.equal(recovered.text, 'part A part B'); + }); + + test('safe tool-output projection redacts provider tokens and absolute paths', () => { + const projected = projectSafeOpenCodeToolOutput( + 'token=sk-review-secret-123 C:\\Users\\Alice\\secrets\\config.json /Users/alice/.ssh/id_rsa', + ); + + assert.doesNotMatch(projected, /sk-review-secret/); + assert.doesNotMatch(projected, /C:\\Users\\Alice/); + assert.doesNotMatch(projected, /\/Users\/alice/); + assert.match(projected, /\[redacted/); + }); +}); From 55e467b6fd33e76e43c296e3c981790499d1ce01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=B0=8F=E9=BA=A6=20=28Ragdoll=29?= Date: Thu, 13 Aug 2026 00:56:14 +0800 Subject: [PATCH 3/3] =?UTF-8?q?Improve=20OpenCode=20post-tool=20recovery?= =?UTF-8?q?=20coverage=20[=E7=A0=9A=E7=A0=9A/gpt-5.5=F0=9F=90=BE]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/providers/OpenCodeAgentService.ts | 34 ++++++++++++++++-- .../agents/providers/opencode-recovery.ts | 2 +- .../api/test/opencode-agent-service.test.js | 36 +++++++++++++++---- packages/api/test/opencode-recovery.test.js | 14 +++++++- 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts index ed14eadb0a..df0629a9a7 100644 --- a/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts @@ -120,9 +120,20 @@ interface OpenCodePostToolFinalizerParams { metadata: MessageMetadata; sessionId?: string; trace: OpenCodeToolTrace | null; + textMode: 'append' | 'replace'; options?: AgentServiceOptions; } +function getOpenCodeStepFinishReason(event: unknown): string | undefined { + if (typeof event !== 'object' || event === null) return undefined; + const raw = event as Record; + if (raw.type !== 'step_finish') return undefined; + const part = raw.part; + if (typeof part !== 'object' || part === null) return undefined; + const reason = (part as Record).reason; + return typeof reason === 'string' ? reason : undefined; +} + function isPermanentOpenCodeProviderFailure(event: unknown, reasonCode: string | undefined): boolean { if (typeof event !== 'object' || event === null) return false; const rawError = (event as Record).error; @@ -352,6 +363,8 @@ export class OpenCodeAgentService implements L0InjectableAgentService { let lastTextEventIndex = 0; let lastToolEventIndex = 0; let lastToolTrace: OpenCodeToolTrace | null = null; + let lastStepFinishReason: string | undefined; + let terminalStepFinishAfterLastTool = false; let lastAssistantMessageId: string | undefined; // F212 Phase G (AC-G3, clowder-ai#875): track unique event types so the // silent_completion diagnostic can surface them when textEventCount===0. @@ -460,6 +473,7 @@ export class OpenCodeAgentService implements L0InjectableAgentService { if (result.type === 'tool_use') { toolUseEmitted = true; lastToolEventIndex = eventCount; + terminalStepFinishAfterLastTool = false; const toolTrace = extractOpenCodeToolTrace(event); if (toolTrace !== null) { lastToolTrace = toolTrace; @@ -534,6 +548,13 @@ export class OpenCodeAgentService implements L0InjectableAgentService { break; } } + const stepFinishReason = getOpenCodeStepFinishReason(event); + if (stepFinishReason) { + lastStepFinishReason = stepFinishReason; + if (lastToolEventIndex > lastTextEventIndex && eventCount > lastToolEventIndex) { + terminalStepFinishAfterLastTool = stepFinishReason !== 'tool-calls'; + } + } } log.info( @@ -567,7 +588,10 @@ export class OpenCodeAgentService implements L0InjectableAgentService { }; } } - if (textEventCount > 0 && lastToolEventIndex > lastTextEventIndex && !errorAlreadyYielded) { + if (lastToolEventIndex > lastTextEventIndex && !errorAlreadyYielded) { + // Existing prelude text is incomplete and should be replaced. Pure tool-only + // gaps have no text to replace; append avoids clobbering a later real continuation. + const postToolFinalizerTextMode = textEventCount > 0 ? 'replace' : 'append'; log.warn( { catId: this.catId, @@ -577,6 +601,9 @@ export class OpenCodeAgentService implements L0InjectableAgentService { lastTextEventIndex, lastToolEventIndex, latestTool: lastToolTrace?.toolName, + lastStepFinishReason, + terminalStepFinishAfterLastTool, + textMode: postToolFinalizerTextMode, }, 'OpenCode CLI stopped after tool_use without final text - running no-tool finalizer', ); @@ -590,6 +617,7 @@ export class OpenCodeAgentService implements L0InjectableAgentService { ? { sessionId: metadata.sessionId ?? options?.sessionId } : {}), trace: lastToolTrace, + textMode: postToolFinalizerTextMode, options, })) { if (finalizerMsg.type === 'text') textEventCount++; @@ -766,7 +794,7 @@ export class OpenCodeAgentService implements L0InjectableAgentService { finalizerTextBuffer.push({ ...result, metadata: params.metadata, - textMode: finalizerTextBuffer.length === 0 ? ('replace' as const) : result.textMode, + textMode: finalizerTextBuffer.length === 0 ? params.textMode : result.textMode, }); continue; } @@ -801,7 +829,7 @@ export class OpenCodeAgentService implements L0InjectableAgentService { type: 'text', catId: this.catId, content: buildOpenCodePostToolFallbackText(params.trace, finalizerErrorReason ?? 'no_text'), - textMode: 'replace', + textMode: params.textMode, metadata: params.metadata, timestamp: Date.now(), }; diff --git a/packages/api/src/domains/cats/services/agents/providers/opencode-recovery.ts b/packages/api/src/domains/cats/services/agents/providers/opencode-recovery.ts index a3c42bcea9..9bbb802e3c 100644 --- a/packages/api/src/domains/cats/services/agents/providers/opencode-recovery.ts +++ b/packages/api/src/domains/cats/services/agents/providers/opencode-recovery.ts @@ -79,7 +79,7 @@ function redactGenericAbsolutePaths(value: string): string { return value .replace(/\b[A-Za-z]:\\(?:[^\s"'<>|]+\\)*[^\s"'<>|]+/g, '[redacted path]') .replace( - /(^|[\s"'(=[{,:])\/(?:Users|home|var|private|opt|etc|mnt|workspace|tmp)\/[^\s"'<>{}|]+/g, + /(^|[\s"'(=[{,:])\/(?:Users|home|var|private|opt|etc|mnt|workspace|tmp|usr|root|data|app|srv|projects)\/[^\s"'<>{}|]+/g, (_match, prefix: string) => `${prefix}[redacted path]`, ); } diff --git a/packages/api/test/opencode-agent-service.test.js b/packages/api/test/opencode-agent-service.test.js index 0f30ad7632..562751f26d 100644 --- a/packages/api/test/opencode-agent-service.test.js +++ b/packages/api/test/opencode-agent-service.test.js @@ -1034,17 +1034,37 @@ describe('OpenCodeAgentService', () => { }); // F212 Phase G R1 P1 (cloud codex catch on 1d519e7f2): tool-only turns are legitimate - // task completions per F215 AC-B3. Tool events that complete the user's request without - // a text response MUST NOT be flagged as silent_completion. - test('AC-G3 R1 P1: tool_use event without text → does NOT yield silent_completion', async () => { + // task completions per F215 AC-B3. Tool events must not be mislabeled as + // silent_completion; if the CLI exits after a tool with no final text, recover + // through the no-tool finalizer in append mode so later real text is not replaced. + test('AC-G3 R1 P1: tool_use event without text runs append-mode finalizer, not silent_completion', async () => { const proc = createMockProcess(); - const spawnFn = mock.fn(() => proc); + const finalizerProc = createMockProcess(); + let spawnCalls = 0; + const spawnFn = mock.fn(() => { + spawnCalls++; + if (spawnCalls === 2) { + process.nextTick(() => { + emitOpenCodeEvents(finalizerProc, [ + STEP_START, + { + ...TEXT_RESPONSE, + part: { ...TEXT_RESPONSE.part, text: 'The tool completed and produced file.txt.' }, + }, + STEP_FINISH, + ]); + }); + return finalizerProc; + } + return proc; + }); const service = new OpenCodeAgentService({ catId: 'opencode', spawnFn, model: 'claude-haiku-4-5' }); const promise = collect(service.invoke('Use tools')); - // step_start + tool_use only — no TEXT_RESPONSE. Per F215 AC-B3 this is a valid - // tool-only completion path. silent_completion would mislabel it as a provider error. + // step_start + tool_use only — no TEXT_RESPONSE from the main CLI invocation. + // This is not a provider error, but users still need final assistant text. emitOpenCodeEvents(proc, [STEP_START, TOOL_USE, STEP_FINISH]); const messages = await promise; + const textMsgs = messages.filter((m) => m.type === 'text'); const silentError = messages.find( (m) => m.type === 'error' && m.metadata?.cliDiagnostics?.reasonCode === 'silent_completion', @@ -1065,6 +1085,10 @@ describe('OpenCodeAgentService', () => { messages.some((m) => m.type === 'tool_use'), 'tool_use yield confirms event reached transformer', ); + assert.equal(spawnCalls, 2, 'tool-only completion gap should start one no-tool finalizer invocation'); + assert.equal(textMsgs.length, 1, 'tool-only recovery should append exactly one finalizer answer'); + assert.equal(textMsgs[0].textMode, 'append', 'tool-only recovery must not replace possible later real text'); + assert.equal(textMsgs[0].content, 'The tool completed and produced file.txt.'); }); // Issue #1208: CodeAgent 3.0 → OpenCode translate may drop token usage. diff --git a/packages/api/test/opencode-recovery.test.js b/packages/api/test/opencode-recovery.test.js index cdabb38343..4a222318fc 100644 --- a/packages/api/test/opencode-recovery.test.js +++ b/packages/api/test/opencode-recovery.test.js @@ -139,12 +139,24 @@ describe('opencode recovery boundary', () => { test('safe tool-output projection redacts provider tokens and absolute paths', () => { const projected = projectSafeOpenCodeToolOutput( - 'token=sk-review-secret-123 C:\\Users\\Alice\\secrets\\config.json /Users/alice/.ssh/id_rsa', + [ + 'token=sk-review-secret-123', + 'C:\\Users\\Alice\\secrets\\config.json', + '/Users/alice/.ssh/id_rsa', + '/usr/local/bin/opencode', + '/root/.ssh/id_rsa', + '/data/app/config.env', + '/srv/opencode/runtime.log', + ].join(' '), ); assert.doesNotMatch(projected, /sk-review-secret/); assert.doesNotMatch(projected, /C:\\Users\\Alice/); assert.doesNotMatch(projected, /\/Users\/alice/); + assert.doesNotMatch(projected, /\/usr\/local/); + assert.doesNotMatch(projected, /\/root\/\.ssh/); + assert.doesNotMatch(projected, /\/data\/app/); + assert.doesNotMatch(projected, /\/srv\/opencode/); assert.match(projected, /\[redacted/); }); });