From 38766ec73e706cc4ee4e6f989573c13d36505cc1 Mon Sep 17 00:00:00 2001 From: VanderClaw Date: Fri, 4 Sep 2026 15:57:06 -0400 Subject: [PATCH 1/2] fix: prevent prose-only agent stalls --- docs/configuration.md | 23 +++ docs/configuration_en.md | 23 +++ packages/cli/src/tests/exec-runner.test.ts | 7 + .../core/src/common/intent-narration-guard.ts | 155 ++++++++++++++++++ packages/core/src/index.ts | 10 ++ packages/core/src/session.ts | 110 +++++++++++-- packages/core/src/settings.ts | 65 ++++++++ .../src/tests/intent-narration-guard.test.ts | 60 +++++++ packages/core/src/tests/session.test.ts | 140 ++++++++++++++-- .../src/tests/settings-and-notify.test.ts | 43 +++++ 10 files changed, 612 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/common/intent-narration-guard.ts create mode 100644 packages/core/src/tests/intent-narration-guard.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 010a2716..c9cf691b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,6 +48,7 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 | `permissions` | object | 权限策略及 `addWorkingDirs` 额外工作目录配置(参见 [permission.md](./permission.md)) | | `enabledSkills` | object | 按 skill 名称启用或禁用 skill 的配置 | | `statusline` | object | 状态栏插件配置(参见 [statusline.md](./statusline.md)) | +| `intentNarrationGuard` | object | 拒绝只有执行意图、没有工具调用的回合,并限制重复停滞(默认启用) | #### `env` 子字段 @@ -62,6 +63,7 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 | `MULTIMODAL` | string | 多模态(图片)能力开关,可选 `"default"`、`"on"` 或 `"off"` | | `DEBUG_LOG_ENABLED` | string | 是否启用调试日志输出 | | `TELEMETRY_ENABLED` | string | 是否启用匿名使用数据上报 | +| `INTENT_NARRATION_GUARD_ENABLED` | string | 是否启用执行意图防停滞保护 | | `<其他任意KEY>` | string | 自定义环境变量 | #### 上下文窗口 @@ -176,6 +178,27 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 - 将某个 skill 设置为 `false` 后,所有项目级和用户级目录中解析名称相同的 skill 都会被隐藏。 - 项目设置会按 skill 覆盖用户设置。如果项目设置没有配置某个 skill,则使用用户设置。 +#### `intentNarrationGuard` — 纯意图文本防停滞保护 + +Deep Code 每个模型步骤最多执行一个工具调用。如果模型返回了已识别的执行意图短语,却没有工具调用,该回合会被丢弃并替换为简短的系统纠正指令。包含真实工具调用的文本回合不受影响。默认情况下,最近六个模型回合中出现四个被拒绝回合时,运行会明确失败,避免无限循环。 + +```json +{ + "intentNarrationGuard": { + "enabled": true, + "additionalPhrases": ["马上调用"], + "instruction": "No prose intent. Emit the tool call now.", + "hardStopRejections": 4, + "hardStopWindow": 6 + } +} +``` + +- `phrases` 替换内置短语列表;`additionalPhrases` 在内置列表上扩展。 +- 将 `hardStopRejections` 设为 `0` 仅关闭硬停止上限。 +- 每次拒绝都会累加 `SessionEntry.intentNarrationRejections`,并在 `~/.deepcode/logs/intent-narration.log` 中记录步骤 ID、文本 SHA-256 哈希和截断预览。 +- 可设置 `DEEPCODE_INTENT_NARRATION_GUARD_ENABLED=false`,在不修改设置文件的情况下为当前进程关闭保护。 + #### `mcpServers` — MCP 服务器 MCP(Model Context Protocol)服务器配置。值是键值对,键为服务名称,值为服务器配置对象。 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 518f69e9..928b300e 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -48,6 +48,7 @@ The following are all the top-level fields supported in `settings.json`, along w | `permissions` | object | Permission policy and additional `addWorkingDirs` workspace roots (see [permission_en.md](./permission_en.md)) | | `enabledSkills` | object | Per-skill enable/disable map, keyed by skill name | | `statusline` | object | Status line plugins (see [statusline_en.md](./statusline_en.md)) | +| `intentNarrationGuard` | object | Reject prose-only intent turns and cap repeated stalls (enabled by default) | #### `env` Sub-fields @@ -62,6 +63,7 @@ The following are all the top-level fields supported in `settings.json`, along w | `MULTIMODAL` | string | Multimodal (image) capability override: `"default"`, `"on"`, or `"off"` | | `DEBUG_LOG_ENABLED`| string| Enable debug log output | | `TELEMETRY_ENABLED`| string| Enable anonymous usage reporting | +| `INTENT_NARRATION_GUARD_ENABLED` | string | Enable or disable the intent narration guard | | `` | string | Custom environment variable | #### Context Windows @@ -176,6 +178,27 @@ Controls whether skills are included during skill scanning. Keys are resolved sk - Setting a skill to `false` hides every skill with that resolved `name`, across project and user skill roots. - Project settings override user settings per skill. If the project setting omits a skill, the user setting is used. +#### `intentNarrationGuard` — Prose-only Stall Protection + +Deep Code enforces one tool call per model step. If a model returns a recognized intent phrase without a tool call, the turn is discarded and replaced with a short corrective system instruction. Prose accompanied by a real tool call passes unchanged. By default, four rejected turns in the last six model turns fail the run instead of allowing an unbounded loop. + +```json +{ + "intentNarrationGuard": { + "enabled": true, + "additionalPhrases": ["about to invoke"], + "instruction": "No prose intent. Emit the tool call now.", + "hardStopRejections": 4, + "hardStopWindow": 6 + } +} +``` + +- `phrases` replaces the built-in phrase list; `additionalPhrases` extends it. +- Set `hardStopRejections` to `0` to disable only the hard cap. +- Rejections increment `SessionEntry.intentNarrationRejections` and are logged to `~/.deepcode/logs/intent-narration.log` with the step ID, a SHA-256 text hash, and a truncated preview. +- `DEEPCODE_INTENT_NARRATION_GUARD_ENABLED=false` disables the guard for a process without editing settings files. + #### `mcpServers` — MCP Servers Configuration for MCP (Model Context Protocol) servers. The value is a key-value pair, where the key is the service name and the value is a server configuration object. diff --git a/packages/cli/src/tests/exec-runner.test.ts b/packages/cli/src/tests/exec-runner.test.ts index 5b86b575..24b69689 100644 --- a/packages/cli/src/tests/exec-runner.test.ts +++ b/packages/cli/src/tests/exec-runner.test.ts @@ -42,6 +42,13 @@ function createSettings( permissions, enabledSkills: {}, statusline: { enabled: false, refreshMs: 1000, separator: " | ", providers: [] }, + intentNarrationGuard: { + enabled: true, + phrases: ["let me run"], + instruction: "No prose intent. Emit the tool call now.", + hardStopRejections: 4, + hardStopWindow: 6, + }, }; } diff --git a/packages/core/src/common/intent-narration-guard.ts b/packages/core/src/common/intent-narration-guard.ts new file mode 100644 index 00000000..aee2dc9a --- /dev/null +++ b/packages/core/src/common/intent-narration-guard.ts @@ -0,0 +1,155 @@ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const INTENT_NARRATION_LOG_FILE = "intent-narration.log"; +const LOG_PREVIEW_LENGTH = 160; + +export const DEFAULT_INTENT_NARRATION_PHRASES = [ + "let me run", + "let me just", + "let me execute", + "I'll run it now", + "I'll just run", + "I'll just call", + "running it now", + "I'm going to run", + "I will run it now", + "doing it now", + "executing now", + "calling it now", + "invoking now", + "I'm running it", + "for real", + "no more loops", +] as const; + +export const DEFAULT_INTENT_NARRATION_INSTRUCTION = "No prose intent. Emit the tool call now."; + +export type IntentNarrationGuardSettings = { + enabled?: boolean; + phrases?: string[]; + additionalPhrases?: string[]; + instruction?: string; + hardStopRejections?: number; + hardStopWindow?: number; +}; + +export type ResolvedIntentNarrationGuardSettings = { + enabled: boolean; + phrases: string[]; + instruction: string; + hardStopRejections: number; + hardStopWindow: number; +}; + +export const DEFAULT_INTENT_NARRATION_GUARD_SETTINGS: ResolvedIntentNarrationGuardSettings = { + enabled: true, + phrases: [...DEFAULT_INTENT_NARRATION_PHRASES], + instruction: DEFAULT_INTENT_NARRATION_INSTRUCTION, + hardStopRejections: 4, + hardStopWindow: 6, +}; + +export type IntentNarrationRejectionEvent = { + timestamp: string; + sessionId: string; + stepId: string; + matchedPhrase: string; + textHash: string; + textPreview: string; + totalRejections: number; + windowRejections: number; + windowSize: number; + hardStopped: boolean; +}; + +export function findIntentNarrationPhrase( + content: string, + hasToolCall: boolean, + settings: ResolvedIntentNarrationGuardSettings +): string | null { + if (!settings.enabled || hasToolCall) { + return null; + } + + const normalizedContent = normalizeForMatching(content); + if (!normalizedContent) { + return null; + } + + for (const phrase of settings.phrases) { + const normalizedPhrase = normalizeForMatching(phrase); + if (normalizedPhrase && normalizedContent.includes(normalizedPhrase)) { + return phrase; + } + } + return null; +} + +export function recordRejectionInWindow(history: boolean[], rejected: boolean, windowSize: number): boolean[] { + const boundedWindow = Math.max(1, Math.floor(windowSize)); + return [...history, rejected].slice(-boundedWindow); +} + +export function shouldHardStopIntentNarration( + history: boolean[], + settings: ResolvedIntentNarrationGuardSettings +): boolean { + if (settings.hardStopRejections <= 0) { + return false; + } + return history.filter(Boolean).length >= settings.hardStopRejections; +} + +export function createIntentNarrationRejectionEvent(input: { + content: string; + sessionId: string; + stepId: string; + matchedPhrase: string; + totalRejections: number; + rejectionHistory: boolean[]; + windowSize: number; + hardStopped: boolean; +}): IntentNarrationRejectionEvent { + const normalizedPreview = input.content.replace(/\s+/g, " ").trim(); + return { + timestamp: new Date().toISOString(), + sessionId: input.sessionId, + stepId: input.stepId, + matchedPhrase: input.matchedPhrase, + textHash: `sha256:${crypto.createHash("sha256").update(input.content).digest("hex")}`, + textPreview: + normalizedPreview.length > LOG_PREVIEW_LENGTH + ? `${normalizedPreview.slice(0, LOG_PREVIEW_LENGTH)}…` + : normalizedPreview, + totalRejections: input.totalRejections, + windowRejections: input.rejectionHistory.filter(Boolean).length, + windowSize: input.windowSize, + hardStopped: input.hardStopped, + }; +} + +export function logIntentNarrationRejection(event: IntentNarrationRejectionEvent): void { + try { + const logPath = getIntentNarrationLogPath(); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${JSON.stringify(event)}\n`, "utf8"); + } catch { + // Guard diagnostics must never change agent-loop behavior. + } +} + +export function getIntentNarrationLogPath(): string { + return path.join(os.homedir(), ".deepcode", "logs", INTENT_NARRATION_LOG_FILE); +} + +function normalizeForMatching(value: string): string { + return value + .normalize("NFKC") + .replace(/[\u2018\u2019\u02bc]/g, "'") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5fb2c01f..853259fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,6 +39,16 @@ export type { ResolvedStatusLineSettings, StatusLineProviderConfig, } from "./settings"; +export type { + IntentNarrationGuardSettings, + IntentNarrationRejectionEvent, + ResolvedIntentNarrationGuardSettings, +} from "./common/intent-narration-guard"; +export { + DEFAULT_INTENT_NARRATION_GUARD_SETTINGS, + DEFAULT_INTENT_NARRATION_INSTRUCTION, + DEFAULT_INTENT_NARRATION_PHRASES, +} from "./common/intent-narration-guard"; // Session export { SessionManager, getProjectCode, getCompactPromptTokenThreshold } from "./session"; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b2c82bc3..8eabd54b 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -46,6 +46,16 @@ import { } from "./settings"; import { logApiError } from "./common/error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; +import { + DEFAULT_INTENT_NARRATION_GUARD_SETTINGS, + createIntentNarrationRejectionEvent, + findIntentNarrationPhrase, + logIntentNarrationRejection, + recordRejectionInWindow, + shouldHardStopIntentNarration, + type IntentNarrationRejectionEvent, + type ResolvedIntentNarrationGuardSettings, +} from "./common/intent-narration-guard"; import { describeLlmError, getLlmErrorDetails } from "./common/llm-error"; import { killProcessTree } from "./common/process-tree"; import { GitFileHistory, type FileHistoryCheckpointResult } from "./common/file-history"; @@ -287,6 +297,7 @@ export type SessionEntry = { askPermissions?: AskPermissionRequest[]; planMode?: boolean; pluginRateLimitedTool?: PluginRateLimitedTool; + intentNarrationRejections?: number; forkedFrom?: { sessionId: string; messageId: string; @@ -378,12 +389,14 @@ export type SessionManagerOptions = { mcpServers?: Record; permissions?: Required; enabledSkills?: Record; + intentNarrationGuard?: ResolvedIntentNarrationGuardSettings; }; renderMarkdown: (text: string) => string; onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; onSessionEntryUpdated?: (entry: SessionEntry) => void; onLlmStreamProgress?: (progress: LlmStreamProgress) => void; onLlmRetry?: (event: LlmRetryEvent) => void; + onIntentNarrationRejected?: (event: IntentNarrationRejectionEvent) => void; onMcpStatusChanged?: () => void; onProcessStdout?: (pid: number, chunk: string) => void; loadSharp?: SharpLoader; @@ -426,11 +439,13 @@ export class SessionManager { mcpServers?: Record; permissions?: Required; enabledSkills?: Record; + intentNarrationGuard?: ResolvedIntentNarrationGuardSettings; }; private readonly onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void; private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void; private readonly onLlmRetry?: (event: LlmRetryEvent) => void; + private readonly onIntentNarrationRejected?: (event: IntentNarrationRejectionEvent) => void; private readonly onMcpStatusChanged?: () => void; private readonly onProcessStdout?: (pid: number, chunk: string) => void; private readonly nonInteractive: boolean; @@ -454,6 +469,7 @@ export class SessionManager { this.onSessionEntryUpdated = options.onSessionEntryUpdated; this.onLlmStreamProgress = options.onLlmStreamProgress; this.onLlmRetry = options.onLlmRetry; + this.onIntentNarrationRejected = options.onIntentNarrationRejected; this.onMcpStatusChanged = options.onMcpStatusChanged; this.onProcessStdout = options.onProcessStdout; this.nonInteractive = options.nonInteractive === true; @@ -1478,6 +1494,7 @@ ${agentInstructions} usage: null, usagePerModel: null, activeTokens: 0, + intentNarrationRejections: 0, createTime: now, updateTime: now, processes: null, @@ -1701,6 +1718,7 @@ ${agentInstructions} try { const maxIterations = 80000; // about 1K RMB cost let toolCalls: unknown[] | null = null; + let intentNarrationHistory: boolean[] = []; for (let iteration = 0; iteration < maxIterations; iteration++) { if (this.isInterrupted(sessionId)) { @@ -1716,7 +1734,8 @@ ${agentInstructions} this.listSessionMessages(sessionId) ); if (pendingToolCallMessage.toolCalls.length > 0) { - const toolAppendResult = await this.appendToolMessages(sessionId, pendingToolCallMessage.toolCalls, { + const pendingToolCalls = pendingToolCallMessage.toolCalls.slice(0, 1); + const toolAppendResult = await this.appendToolMessages(sessionId, pendingToolCalls, { permissionOverrides: permissionPrompt?.permissions, messagePermissions: pendingToolCallMessage.message?.meta?.permissions, }); @@ -1729,7 +1748,7 @@ ${agentInstructions} if (toolAppendResult.waitingForUser) { this.updateSessionEntry(sessionId, (entry) => ({ ...entry, - toolCalls: pendingToolCallMessage.toolCalls, + toolCalls: pendingToolCalls, status: "waiting_for_user", updateTime: new Date().toISOString(), })); @@ -1832,6 +1851,62 @@ ${agentInstructions} if (this.isInterrupted(sessionId)) { return; } + const intentNarrationGuard = + this.getResolvedSettings().intentNarrationGuard ?? DEFAULT_INTENT_NARRATION_GUARD_SETTINGS; + const matchedIntentPhrase = findIntentNarrationPhrase(content, Boolean(toolCalls), intentNarrationGuard); + intentNarrationHistory = recordRejectionInWindow( + intentNarrationHistory, + Boolean(matchedIntentPhrase), + intentNarrationGuard.hardStopWindow + ); + if (matchedIntentPhrase) { + const responseUsage = response.usage ?? null; + const hardStopped = shouldHardStopIntentNarration(intentNarrationHistory, intentNarrationGuard); + const failReason = hardStopped + ? `Intent narration guard stopped the run after ${intentNarrationHistory.filter(Boolean).length} rejected turns within the last ${intentNarrationGuard.hardStopWindow} model turns.` + : null; + const updatedEntry = this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + assistantReply: hardStopped ? failReason : intentNarrationGuard.instruction, + assistantThinking: thinking, + assistantRefusal: refusal, + toolCalls: null, + usage: accumulateUsage(entry.usage, responseUsage), + usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage), + activeTokens: getTotalTokens(responseUsage), + status: hardStopped ? "failed" : "processing", + failReason, + askPermissions: undefined, + intentNarrationRejections: (entry.intentNarrationRejections ?? 0) + 1, + updateTime: new Date().toISOString(), + })); + const event = createIntentNarrationRejectionEvent({ + content, + sessionId, + stepId: `${sessionId}:${iteration + 1}`, + matchedPhrase: matchedIntentPhrase, + totalRejections: updatedEntry?.intentNarrationRejections ?? 1, + rejectionHistory: intentNarrationHistory, + windowSize: intentNarrationGuard.hardStopWindow, + hardStopped, + }); + logIntentNarrationRejection(event); + this.onIntentNarrationRejected?.(event); + + const correctionMessage = this.buildSystemMessage(sessionId, intentNarrationGuard.instruction, null, true, { + asThinking: true, + }); + this.appendSessionMessage(sessionId, correctionMessage); + this.onAssistantMessage(correctionMessage, true); + + if (hardStopped) { + const failureMessage = this.buildAssistantMessage(sessionId, failReason, null); + this.appendSessionMessage(sessionId, failureMessage); + this.onAssistantMessage(failureMessage, false); + return; + } + continue; + } const assistantMessage = this.buildAssistantMessage(sessionId, content, toolCalls, thinking); const permissionPlan = toolCalls ? computeToolCallPermissions({ @@ -2277,6 +2352,7 @@ ${agentInstructions} usage: null, usagePerModel: null, activeTokens: source.activeTokens, + intentNarrationRejections: 0, createTime: now, updateTime: now, processes: null, @@ -2971,22 +3047,23 @@ ${agentInstructions} return null; } - return rawToolCalls.map((toolCall) => { - if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) { - return toolCall; - } + const [toolCall] = rawToolCalls; + if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) { + return [toolCall]; + } - const record = toolCall as Record; - const id = typeof record.id === "string" ? record.id.trim() : ""; - if (id) { - return toolCall; - } + const record = toolCall as Record; + const id = typeof record.id === "string" ? record.id.trim() : ""; + if (id) { + return [toolCall]; + } - return { + return [ + { ...record, id: this.generateToolCallId(), - }; - }); + }, + ]; } private buildToolMessage( @@ -3080,6 +3157,7 @@ ${agentInstructions} shouldStop: () => this.isInterrupted(sessionId), }; const parsedToolCalls = toolCalls + .slice(0, 1) .map((toolCall) => parseToolCallForPermissions(toolCall)) .filter((toolCall): toolCall is PermissionToolCall => Boolean(toolCall)); const toolExecutions: ToolCallExecution[] = []; @@ -3556,6 +3634,10 @@ ${agentInstructions} askPermissions: normalizeAskPermissions(value.askPermissions), planMode: value.planMode === true, pluginRateLimitedTool: this.normalizePluginRateLimitedTool(value.pluginRateLimitedTool), + intentNarrationRejections: + typeof value.intentNarrationRejections === "number" && value.intentNarrationRejections >= 0 + ? Math.floor(value.intentNarrationRejections) + : 0, forkedFrom: this.normalizeForkedFrom(value.forkedFrom), }; } diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 026d8821..98ae3ab5 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -2,6 +2,11 @@ import { DEEPSEEK_V4_MODELS, defaultsToThinkingMode, type MultimodalMode } from import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { + DEFAULT_INTENT_NARRATION_GUARD_SETTINGS, + type IntentNarrationGuardSettings, + type ResolvedIntentNarrationGuardSettings, +} from "./common/intent-narration-guard"; export type DeepcodingEnv = Record & { MODEL?: string; @@ -107,6 +112,7 @@ export type DeepcodingSettings = { permissions?: PermissionSettings; enabledSkills?: EnabledSkillsSettings; statusline?: StatusLineSettings; + intentNarrationGuard?: IntentNarrationGuardSettings; }; export type ResolvedDeepcodingSettings = { @@ -134,6 +140,7 @@ export type ResolvedDeepcodingSettings = { permissions: Required; enabledSkills: EnabledSkillsSettings; statusline: ResolvedStatusLineSettings; + intentNarrationGuard: ResolvedIntentNarrationGuardSettings; }; export type ModelConfigSelection = { @@ -250,6 +257,63 @@ function trimString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +function normalizeStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const result: string[] = []; + const seen = new Set(); + for (const item of value) { + const phrase = trimString(item); + const key = phrase.toLowerCase(); + if (!phrase || seen.has(key)) { + continue; + } + seen.add(key); + result.push(phrase); + } + return result; +} + +function mergeIntentNarrationGuard( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined, + systemEnv: Record +): ResolvedIntentNarrationGuardSettings { + const userGuard = userSettings?.intentNarrationGuard; + const projectGuard = projectSettings?.intentNarrationGuard; + const basePhrases = + normalizeStringArray(projectGuard?.phrases) ?? + normalizeStringArray(userGuard?.phrases) ?? + DEFAULT_INTENT_NARRATION_GUARD_SETTINGS.phrases; + const additionalPhrases = [ + ...(normalizeStringArray(userGuard?.additionalPhrases) ?? []), + ...(normalizeStringArray(projectGuard?.additionalPhrases) ?? []), + ]; + const phrases = normalizeStringArray([...basePhrases, ...additionalPhrases]) ?? []; + const hardStopWindow = + firstIntegerInRange(1, 100, projectGuard?.hardStopWindow, userGuard?.hardStopWindow) ?? + DEFAULT_INTENT_NARRATION_GUARD_SETTINGS.hardStopWindow; + const configuredHardStopRejections = + firstIntegerInRange(0, 100, projectGuard?.hardStopRejections, userGuard?.hardStopRejections) ?? + DEFAULT_INTENT_NARRATION_GUARD_SETTINGS.hardStopRejections; + + return { + enabled: + parseBoolean(systemEnv.INTENT_NARRATION_GUARD_ENABLED) ?? + parseBoolean(projectGuard?.enabled) ?? + parseBoolean(userGuard?.enabled) ?? + DEFAULT_INTENT_NARRATION_GUARD_SETTINGS.enabled, + phrases, + instruction: + trimString(projectGuard?.instruction) || + trimString(userGuard?.instruction) || + DEFAULT_INTENT_NARRATION_GUARD_SETTINGS.instruction, + hardStopRejections: configuredHardStopRejections === 0 ? 0 : Math.min(configuredHardStopRejections, hardStopWindow), + hardStopWindow, + }; +} + const VALID_PERMISSION_SCOPES = new Set([ "read-in-cwd", "read-in-tmp", @@ -723,6 +787,7 @@ export function resolveSettingsSources( permissions: mergePermissions(userSettings, projectSettings), enabledSkills: mergeEnabledSkills(userSettings, projectSettings), statusline: mergeStatusLine(userSettings, projectSettings), + intentNarrationGuard: mergeIntentNarrationGuard(userSettings, projectSettings, systemEnv), }; } diff --git a/packages/core/src/tests/intent-narration-guard.test.ts b/packages/core/src/tests/intent-narration-guard.test.ts new file mode 100644 index 00000000..e7b9749a --- /dev/null +++ b/packages/core/src/tests/intent-narration-guard.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + DEFAULT_INTENT_NARRATION_GUARD_SETTINGS, + findIntentNarrationPhrase, + recordRejectionInWindow, + shouldHardStopIntentNarration, + type ResolvedIntentNarrationGuardSettings, +} from "../common/intent-narration-guard"; + +const settings: ResolvedIntentNarrationGuardSettings = { + ...DEFAULT_INTENT_NARRATION_GUARD_SETTINGS, + phrases: [...DEFAULT_INTENT_NARRATION_GUARD_SETTINGS.phrases], +}; + +test("intent narration guard rejects replayed prose-only stalls on their first turn", () => { + const replayedStalls = [ + "Let me execute both now.", + "Let me run both directly.", + "I'll just call it now.", + "Running it now.", + "I'm going to run the command now.", + "For real now — UpdatePlan.", + "No more loops. Actually invoke.", + ]; + + for (const content of replayedStalls) { + assert.ok(findIntentNarrationPhrase(content, false, settings), content); + } +}); + +test("intent narration guard allows prose plus a real tool call unchanged", () => { + assert.equal(findIntentNarrationPhrase("Let me run the existing tests.", true, settings), null); +}); + +test("intent narration guard allows a tool-only turn", () => { + assert.equal(findIntentNarrationPhrase("", true, settings), null); +}); + +test("intent narration guard honors an overridden phrase list", () => { + const overridden = { ...settings, phrases: ["ship it now"] }; + + assert.equal(findIntentNarrationPhrase("Let me run it.", false, overridden), null); + assert.equal(findIntentNarrationPhrase("SHIP IT NOW.", false, overridden), "ship it now"); +}); + +test("intent narration guard normalizes curly apostrophes and whitespace", () => { + assert.equal(findIntentNarrationPhrase("I’m going to run the check.", false, settings), "I'm going to run"); +}); + +test("intent narration hard-stops after four rejected turns in the last six", () => { + let history: boolean[] = []; + for (const rejected of [true, false, true, true, false, true]) { + history = recordRejectionInWindow(history, rejected, settings.hardStopWindow); + } + + assert.deepEqual(history, [true, false, true, true, false, true]); + assert.equal(shouldHardStopIntentNarration(history, settings), true); + assert.equal(shouldHardStopIntentNarration(history, { ...settings, hardStopRejections: 0 }), false); +}); diff --git a/packages/core/src/tests/session.test.ts b/packages/core/src/tests/session.test.ts index 98482429..5a92efd2 100644 --- a/packages/core/src/tests/session.test.ts +++ b/packages/core/src/tests/session.test.ts @@ -924,7 +924,7 @@ test("skill tool stores the full document and metadata in its tool message", asy assert.match(missing.error ?? "", /Unknown skill: not-a-real-skill/); }); -test("skill tool avoids duplicate loads within the same tool call batch", async () => { +test("appendToolMessages executes only the first tool in a batch", async () => { const workspace = createTempDir("deepcode-skill-batch-dedupe-workspace-"); const home = createTempDir("deepcode-skill-batch-dedupe-home-"); setHomeDir(home); @@ -946,9 +946,8 @@ test("skill tool avoids duplicate loads within the same tool call batch", async .listSessionMessages(sessionId) .filter((message) => message.role === "tool") .map((message) => JSON.parse(message.content ?? "{}")); - assert.equal(results.length, 2); + assert.equal(results.length, 1); assert.match(results[0]?.output ?? "", / { assert.equal(toolMessage.meta?.paramsMd, path.join("images", "screenshot.png")); }); -test("LLM tool calls without ids receive generated 32 character ids", async () => { +test("SessionManager rejects prose-only intent, retries, and allows the same prose with a tool call", async () => { + const workspace = createTempDir("deepcode-intent-guard-workspace-"); + const home = createTempDir("deepcode-intent-guard-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "note.txt"); + fs.writeFileSync(filePath, "guarded\n", "utf8"); + const responses = [ + createChatResponse("Let me run it now.", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + { + choices: [ + { + message: { + content: "Let me run it now.", + tool_calls: [ + { + id: "call-read-after-rejection", + type: "function", + function: { name: "read", arguments: JSON.stringify({ file_path: filePath }) }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + createChatResponse("Done.", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]; + const events: Array<{ stepId: string; hardStopped: boolean }> = []; + let chatCalls = 0; + const client = { + chat: { + completions: { + create: async () => { + chatCalls += 1; + const response = responses.shift(); + assert.ok(response, "expected a queued chat response"); + return response; + }, + }, + }, + }; + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ + model: "test-model", + intentNarrationGuard: { + enabled: true, + phrases: ["let me run"], + instruction: "No prose intent. Emit the tool call now.", + hardStopRejections: 4, + hardStopWindow: 6, + }, + }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + onIntentNarrationRejected: (event) => events.push(event), + }); + + const sessionId = await manager.createSession({ text: "" }); + const messages = manager.listSessionMessages(sessionId); + const matchingAssistantMessages = messages.filter( + (message) => message.role === "assistant" && message.content === "Let me run it now." + ); + const correction = messages.find( + (message) => message.role === "system" && message.content === "No prose intent. Emit the tool call now." + ); + const toolMessage = messages.find( + (message) => + message.role === "tool" && + (message.messageParams as { tool_call_id?: string } | null)?.tool_call_id === "call-read-after-rejection" + ); + + assert.equal(chatCalls, 3); + assert.equal(matchingAssistantMessages.length, 1, "the rejected prose turn must not be persisted"); + assert.equal(correction?.visible, true); + assert.match(toolMessage?.content ?? "", /guarded/); + assert.equal(manager.getSession(sessionId)?.intentNarrationRejections, 1); + assert.equal(events.length, 1); + assert.equal(events[0]?.stepId, `${sessionId}:1`); + assert.equal(events[0]?.hardStopped, false); + assert.equal(manager.getSession(sessionId)?.status, "completed"); + + const logLines = fs + .readFileSync(path.join(home, ".deepcode", "logs", "intent-narration.log"), "utf8") + .trim() + .split("\n"); + const logEntry = JSON.parse(logLines[0] ?? "{}") as Record; + assert.match(String(logEntry.textHash), /^sha256:[0-9a-f]{64}$/); + assert.equal(logEntry.textPreview, "Let me run it now."); + assert.equal(logEntry.stepId, `${sessionId}:1`); +}); + +test("SessionManager hard-stops repeated intent narration at four of the last six turns", async () => { + const workspace = createTempDir("deepcode-intent-cap-workspace-"); + const home = createTempDir("deepcode-intent-cap-home-"); + setHomeDir(home); + + const responses = Array.from({ length: 4 }, () => + createChatResponse("Let me run the check now.", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }) + ); + const manager = createMockedClientSessionManager(workspace, responses); + const sessionId = await manager.createSession({ text: "" }); + const session = manager.getSession(sessionId); + + assert.equal(responses.length, 0); + assert.equal(session?.status, "failed"); + assert.equal(session?.intentNarrationRejections, 4); + assert.match(session?.failReason ?? "", /4 rejected turns within the last 6 model turns/); + assert.equal( + manager + .listSessionMessages(sessionId) + .filter((message) => message.content === "No prose intent. Emit the tool call now.").length, + 4 + ); +}); + +test("LLM tool calls without ids are limited to one action per step", async () => { const workspace = createTempDir("deepcode-tool-call-id-workspace-"); const home = createTempDir("deepcode-tool-call-id-home-"); setHomeDir(home); @@ -3269,10 +3391,8 @@ test("LLM tool calls without ids receive generated 32 character ids", async () = .find((message) => message.role === "assistant" && (message.messageParams as any)?.tool_calls); const toolCalls = (assistantMessage?.messageParams as { tool_calls?: Array<{ id?: unknown }> } | null)?.tool_calls; - assert.equal(toolCalls?.length, 2); + assert.equal(toolCalls?.length, 1); assert.match(String(toolCalls?.[0]?.id), /^[0-9a-f]{32}$/); - assert.match(String(toolCalls?.[1]?.id), /^[0-9a-f]{32}$/); - assert.notEqual(toolCalls?.[0]?.id, toolCalls?.[1]?.id); const toolMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "tool"); assert.deepEqual( @@ -3280,9 +3400,8 @@ test("LLM tool calls without ids receive generated 32 character ids", async () = toolCalls?.map((toolCall) => toolCall.id) ); - const readToolMessage = toolMessages.find((message) => JSON.parse(message.content ?? "{}").name === "read"); - assert.equal((readToolMessage?.meta?.function as { name?: string } | undefined)?.name, "read"); - assert.equal(readToolMessage?.meta?.paramsMd, "note.txt"); + assert.equal(toolMessages.length, 1); + assert.equal((toolMessages[0]?.meta?.function as { name?: string } | undefined)?.name, "UpdatePlan"); }); test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messages", () => { @@ -4599,6 +4718,7 @@ test("SessionManager.forkSession copies conversation state with fresh usage and assert.equal(forked.usage, null); assert.equal(forked.usagePerModel, null); assert.equal(forked.activeTokens, 15); + assert.equal(forked.intentNarrationRejections, 0); assert.equal(forked.status, "completed"); assert.equal(forked.failReason, null); assert.equal(forked.assistantRefusal, null); diff --git a/packages/core/src/tests/settings-and-notify.test.ts b/packages/core/src/tests/settings-and-notify.test.ts index 6f842438..f006a401 100644 --- a/packages/core/src/tests/settings-and-notify.test.ts +++ b/packages/core/src/tests/settings-and-notify.test.ts @@ -22,6 +22,7 @@ import { resolveSettings, resolveSettingsSources, } from "../settings"; +import { DEFAULT_INTENT_NARRATION_GUARD_SETTINGS } from "../common/intent-narration-guard"; const TEST_PROCESS_ENV = {}; @@ -88,6 +89,48 @@ test("resolveSettings defaults multimodal to default", () => { assert.equal(resolved.multimodal, "default"); }); +test("resolveSettings enables the intent narration guard with safe defaults", () => { + const resolved = resolveSettings( + {}, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + + assert.deepEqual(resolved.intentNarrationGuard, DEFAULT_INTENT_NARRATION_GUARD_SETTINGS); +}); + +test("resolveSettingsSources overrides and extends intent narration phrases", () => { + const resolved = resolveSettingsSources( + { + intentNarrationGuard: { + phrases: ["user phrase"], + additionalPhrases: ["shared phrase"], + hardStopRejections: 5, + hardStopWindow: 8, + }, + }, + { + intentNarrationGuard: { + phrases: ["project phrase"], + additionalPhrases: ["project extension", "shared phrase"], + instruction: "Call the tool.", + hardStopRejections: 9, + hardStopWindow: 7, + }, + }, + { model: "default-model", baseURL: "https://default.example.com" }, + { DEEPCODE_INTENT_NARRATION_GUARD_ENABLED: "false" } + ); + + assert.deepEqual(resolved.intentNarrationGuard, { + enabled: false, + phrases: ["project phrase", "shared phrase", "project extension"], + instruction: "Call the tool.", + hardStopRejections: 7, + hardStopWindow: 7, + }); +}); + test("resolveSettings applies Files API defaults", () => { const resolved = resolveSettings( {}, From 1b964620a3c03046cc6d2e905838ad8195cde56f Mon Sep 17 00:00:00 2001 From: VanderClaw Date: Fri, 4 Sep 2026 16:33:55 -0400 Subject: [PATCH 2/2] fix: avoid replaying long model prefills --- .../core/src/common/intent-narration-guard.ts | 30 +++++++++ packages/core/src/common/llm-retry.ts | 18 +++++- packages/core/src/session.ts | 20 +++--- .../src/tests/intent-narration-guard.test.ts | 9 +++ packages/core/src/tests/llm-retry.test.ts | 2 + packages/core/src/tests/session.test.ts | 63 ++++++++++++++++++- 6 files changed, 129 insertions(+), 13 deletions(-) diff --git a/packages/core/src/common/intent-narration-guard.ts b/packages/core/src/common/intent-narration-guard.ts index aee2dc9a..545ff9f3 100644 --- a/packages/core/src/common/intent-narration-guard.ts +++ b/packages/core/src/common/intent-narration-guard.ts @@ -10,6 +10,36 @@ export const DEFAULT_INTENT_NARRATION_PHRASES = [ "let me run", "let me just", "let me execute", + "let me add", + "let me apply", + "let me build", + "let me check", + "let me close", + "let me commit", + "let me continue", + "let me create", + "let me deploy", + "let me edit", + "let me fetch", + "let me fix", + "let me implement", + "let me inspect", + "let me install", + "let me invoke", + "let me mark", + "let me merge", + "let me open", + "let me patch", + "let me port", + "let me proceed", + "let me push", + "let me read", + "let me restart", + "let me set up", + "let me test", + "let me update", + "let me verify", + "let me write", "I'll run it now", "I'll just run", "I'll just call", diff --git a/packages/core/src/common/llm-retry.ts b/packages/core/src/common/llm-retry.ts index 977854a8..7edb82f8 100644 --- a/packages/core/src/common/llm-retry.ts +++ b/packages/core/src/common/llm-retry.ts @@ -2,6 +2,7 @@ import { getLlmErrorDetails } from "./llm-error"; export const MAX_LLM_RETRIES = 5; export const LLM_STREAM_IDLE_TIMEOUT_MS = 60_000; +export const LLM_STREAM_FIRST_CHUNK_TIMEOUT_MS = 300_000; const BASE_RETRY_DELAY_MS = 800; const RETRYABLE_NETWORK_CODES = new Set([ @@ -18,12 +19,19 @@ const RETRYABLE_NETWORK_CODES = new Set([ ]); export class LlmStreamIdleTimeoutError extends Error { - constructor() { - super(`Model stream was idle for ${LLM_STREAM_IDLE_TIMEOUT_MS / 1000} seconds.`); + constructor(timeoutMs: number = LLM_STREAM_IDLE_TIMEOUT_MS) { + super(`Model stream was idle for ${timeoutMs / 1000} seconds.`); this.name = "LlmStreamIdleTimeoutError"; } } +export class LlmStreamFirstChunkTimeoutError extends Error { + constructor(timeoutMs: number = LLM_STREAM_FIRST_CHUNK_TIMEOUT_MS) { + super(`Model stream produced no first chunk for ${timeoutMs / 1000} seconds.`); + this.name = "LlmStreamFirstChunkTimeoutError"; + } +} + export class LlmStreamDisconnectedError extends Error { constructor() { super("Model stream disconnected before completion."); @@ -57,6 +65,12 @@ export function getLlmRetryAfterMs(error: unknown, now: number = Date.now()): nu } export function isRetryableLlmError(error: unknown): boolean { + // A first-chunk timeout commonly means an expensive local-model prefill is + // still running. Replaying the same large prompt multiplies work without + // improving recovery, so fail once and let route/fallback policy take over. + if (error instanceof LlmStreamFirstChunkTimeoutError) { + return false; + } if (error instanceof LlmStreamIdleTimeoutError || error instanceof LlmStreamDisconnectedError) { return true; } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 8eabd54b..904014c9 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -87,8 +87,10 @@ import { getLlmRetryDelayMs, getLlmRetryAfterMs, isRetryableLlmError, + LLM_STREAM_FIRST_CHUNK_TIMEOUT_MS, LLM_STREAM_IDLE_TIMEOUT_MS, LlmStreamDisconnectedError, + LlmStreamFirstChunkTimeoutError, LlmStreamIdleTimeoutError, MAX_LLM_RETRIES, waitForLlmRetry, @@ -744,7 +746,7 @@ export class SessionManager { const outerSignal = options?.signal as AbortSignal | undefined; const attemptController = new AbortController(); - let idleTimedOut = false; + let timeoutError: Error | null = null; let idleTimer: ReturnType | null = null; let idleTimeoutPromise: Promise; const forwardAbort = () => attemptController.abort(outerSignal?.reason); @@ -755,17 +757,17 @@ export class SessionManager { } outerSignal?.removeEventListener("abort", forwardAbort); }; - const resetIdleTimer = () => { + const resetIdleTimer = (timeoutMs: number, createError: () => Error) => { if (idleTimer) { clearTimeout(idleTimer); } idleTimeoutPromise = new Promise((_, reject) => { idleTimer = setTimeout(() => { - idleTimedOut = true; - const error = new LlmStreamIdleTimeoutError(); + const error = createError(); + timeoutError = error; attemptController.abort(error); reject(error); - }, LLM_STREAM_IDLE_TIMEOUT_MS); + }, timeoutMs); }); }; if (outerSignal?.aborted) { @@ -773,7 +775,7 @@ export class SessionManager { } else { outerSignal?.addEventListener("abort", forwardAbort, { once: true }); } - resetIdleTimer(); + resetIdleTimer(LLM_STREAM_FIRST_CHUNK_TIMEOUT_MS, () => new LlmStreamFirstChunkTimeoutError()); const attemptOptions = { ...options, signal: attemptController.signal, maxRetries: 0 }; const streamRequest = { @@ -797,7 +799,7 @@ export class SessionManager { idleTimeoutPromise!, ]); } catch (error) { - const requestError = idleTimedOut ? new LlmStreamIdleTimeoutError() : error; + const requestError = timeoutError ?? error; this.logChatCompletionDebug(debug, { timestamp: new Date().toISOString(), location: debug?.location ?? "SessionManager.createChatCompletionStream:create", @@ -873,7 +875,7 @@ export class SessionManager { break; } const chunk = item.value; - resetIdleTimer(); + resetIdleTimer(LLM_STREAM_IDLE_TIMEOUT_MS, () => new LlmStreamIdleTimeoutError()); if (debug?.enabled) { responseChunks.push(chunk); } @@ -944,7 +946,7 @@ export class SessionManager { throw new LlmStreamDisconnectedError(); } } catch (error) { - const streamError = idleTimedOut ? new LlmStreamIdleTimeoutError() : error; + const streamError = timeoutError ?? error; this.logChatCompletionDebug(debug, { timestamp: new Date().toISOString(), location: debug?.location ?? "SessionManager.createChatCompletionStream:stream", diff --git a/packages/core/src/tests/intent-narration-guard.test.ts b/packages/core/src/tests/intent-narration-guard.test.ts index e7b9749a..6f41c003 100644 --- a/packages/core/src/tests/intent-narration-guard.test.ts +++ b/packages/core/src/tests/intent-narration-guard.test.ts @@ -22,6 +22,10 @@ test("intent narration guard rejects replayed prose-only stalls on their first t "I'm going to run the command now.", "For real now — UpdatePlan.", "No more loops. Actually invoke.", + "Let me update the plan now.", + "Now let me set up A1c and proceed with the implementation.", + "Let me fetch the policy documents next.", + "A1c is set up. Now let me add the state comment, then port the documents.", ]; for (const content of replayedStalls) { @@ -37,6 +41,11 @@ test("intent narration guard allows a tool-only turn", () => { assert.equal(findIntentNarrationPhrase("", true, settings), null); }); +test("intent narration guard allows conversational let-me phrases", () => { + assert.equal(findIntentNarrationPhrase("Let me know if you want more detail.", false, settings), null); + assert.equal(findIntentNarrationPhrase("Let me explain why the test failed.", false, settings), null); +}); + test("intent narration guard honors an overridden phrase list", () => { const overridden = { ...settings, phrases: ["ship it now"] }; diff --git a/packages/core/src/tests/llm-retry.test.ts b/packages/core/src/tests/llm-retry.test.ts index 044f6586..3710fe3b 100644 --- a/packages/core/src/tests/llm-retry.test.ts +++ b/packages/core/src/tests/llm-retry.test.ts @@ -5,6 +5,7 @@ import { getLlmRetryAfterMs, isRetryableLlmError, LlmStreamDisconnectedError, + LlmStreamFirstChunkTimeoutError, LlmStreamIdleTimeoutError, waitForLlmRetry, } from "../common/llm-retry"; @@ -60,6 +61,7 @@ test("isRetryableLlmError recognizes recoverable HTTP and transport failures", ( true ); assert.equal(isRetryableLlmError(new LlmStreamIdleTimeoutError()), true); + assert.equal(isRetryableLlmError(new LlmStreamFirstChunkTimeoutError()), false); assert.equal(isRetryableLlmError(new LlmStreamDisconnectedError()), true); }); diff --git a/packages/core/src/tests/session.test.ts b/packages/core/src/tests/session.test.ts index 5a92efd2..8a9183ae 100644 --- a/packages/core/src/tests/session.test.ts +++ b/packages/core/src/tests/session.test.ts @@ -3942,10 +3942,11 @@ test("SessionManager treats a clean EOF without a terminal chunk as a disconnect assert.equal(retryError, "Model stream disconnected before completion."); }); -test("SessionManager retries a stream after sixty seconds without data", async (t) => { +test("SessionManager retries a stream that is idle for sixty seconds after its first chunk", async (t) => { t.mock.timers.enable({ apis: ["setTimeout"] }); const controller = new AbortController(); let retryError = ""; + let nextCall = 0; const client = { chat: { completions: { @@ -3954,6 +3955,13 @@ test("SessionManager retries a stream after sixty seconds without data", async ( return this; }, next() { + nextCall += 1; + if (nextCall === 1) { + return Promise.resolve({ + done: false, + value: { choices: [{ delta: { content: "partial" } }] }, + }); + } return new Promise>(() => {}); }, }), @@ -3977,13 +3985,64 @@ test("SessionManager retries a stream after sixty seconds without data", async ( { model: "test-model" }, { signal: controller.signal } ); - await Promise.resolve(); + for (let turn = 0; turn < 10 && nextCall < 2; turn += 1) { + await Promise.resolve(); + } + assert.equal(nextCall, 2); t.mock.timers.tick(60_000); + await Promise.resolve(); await assert.rejects(responsePromise, (error: Error) => error.name === "AbortError"); assert.equal(retryError, "Model stream was idle for 60 seconds."); }); +test("SessionManager gives first-chunk prefill five minutes and does not replay it after timeout", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let retries = 0; + let nextCalls = 0; + const client = { + chat: { + completions: { + create: async () => ({ + [Symbol.asyncIterator]() { + return this; + }, + next() { + nextCalls += 1; + return new Promise>(() => {}); + }, + }), + }, + }, + }; + const manager = new SessionManager({ + projectRoot: process.cwd(), + createOpenAIClient: () => ({ client: client as any, model: "test-model", thinkingEnabled: false }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + onLlmRetry: () => { + retries += 1; + }, + }); + + const responsePromise = (manager as any).createChatCompletionStream(client, { model: "test-model" }); + for (let turn = 0; turn < 10 && nextCalls < 1; turn += 1) { + await Promise.resolve(); + } + assert.equal(nextCalls, 1); + t.mock.timers.tick(300_000); + await Promise.resolve(); + + await assert.rejects( + responsePromise, + (error: Error) => + error.name === "LlmStreamFirstChunkTimeoutError" && + error.message === "Model stream produced no first chunk for 300 seconds." + ); + assert.equal(retries, 0); +}); + test("SessionManager persists session and user message before skill matching is cancelled", async () => { const workspace = createTempDir("deepcode-skill-abort-workspace-"); const home = createTempDir("deepcode-skill-abort-home-");