diff --git a/packages/opencode/src/session/diagnostics.ts b/packages/opencode/src/session/diagnostics.ts index f3ffb02e9..b45405799 100644 --- a/packages/opencode/src/session/diagnostics.ts +++ b/packages/opencode/src/session/diagnostics.ts @@ -5,7 +5,7 @@ import type { MessageID, SessionID } from "./schema" export namespace SessionDiagnostics { const NON_SEMANTIC_KEYS = new Set(["requestid", "request_id", "traceid", "trace_id", "nonce"]) - export type ReminderType = "input_repeat" | "error_repeat" + export type ReminderType = "input_repeat" | "target_repeat" | "error_repeat" export type ReminderStatus = "pending" | "injected" export type Reminder = { @@ -17,6 +17,28 @@ export namespace SessionDiagnostics { injectedAt?: number } + export type SignatureKind = "input" | "target" + export type LoopAction = "observe" | "block" | "stop" + + export type SignatureState = { + kind: SignatureKind + completedFailures: number + recoverEmitted: boolean + blockEmitted: boolean + lastInput?: unknown + lastError?: unknown + } + + export type ParentLoopState = { + autoResumeSpent: boolean + signatures: Record + } + + export type GateDecision = + | { action: "observe" } + | { action: "block"; sigKey: string; kind: SignatureKind; completedFailures: number } + | { action: "stop"; sigKey: string; kind: SignatureKind; completedFailures: number } + export type LoopMetadata = { inputHash?: string inputRepeatCount?: number @@ -36,6 +58,14 @@ export namespace SessionDiagnostics { parentID?: MessageID toolFamily?: string truncated?: boolean + loopAction?: LoopAction + loopType?: SignatureKind + loopCompletedFailures?: number + loopSigKey?: string + loopRecoverFiredFor?: string[] + targetHashIsFallback?: boolean + loopLastInput?: unknown + loopLastError?: unknown } export type Metadata = { @@ -57,7 +87,11 @@ export namespace SessionDiagnostics { sessionID: SessionID parentID: MessageID tool: string + inputHash: string + targetHash?: string errorFingerprint: string + lastInput?: unknown + lastError?: unknown metadata: Metadata } @@ -65,25 +99,65 @@ export namespace SessionDiagnostics { return createHash("sha256").update(value).digest("hex").slice(0, 16) } + const RENDERER_BYTE_LIMIT = 1024 + + export function truncateForRenderer(value: unknown): string { + let s: string + if (typeof value === "string") s = value + else { + try { + s = JSON.stringify(value) ?? String(value) + } catch { + // BigInt, circular, or other non-serializable payloads fall back to String coercion. + s = String(value) + } + } + const buf = Buffer.from(s, "utf8") + if (buf.byteLength <= RENDERER_BYTE_LIMIT) return s + let cutByte = RENDERER_BYTE_LIMIT + while (cutByte > 0) { + const slice = buf.subarray(0, cutByte).toString("utf8") + if (slice.charCodeAt(slice.length - 1) === 0xfffd) { + cutByte -= 1 + continue + } + if (slice.endsWith("%") || /%[0-9A-Fa-f]$/.test(slice)) { + cutByte -= 1 + continue + } + return slice + "…" + } + return "…" + } + export function normalizeInput(input: unknown): { value: unknown; serialized: string; hash: string } { const value = normalizeValue(input) const serialized = JSON.stringify(value) return { value, serialized, hash: hash(serialized) } } - export function targetSummary(tool: string, input: unknown) { + export function targetSummary(tool: string, input: unknown): { summary: string; isFallback: boolean } { const target = findTarget(input) - if (!target) return `${tool}:input:${normalizeInput(input).hash}` - return `${target.kind}:${hash(target.value.trim())}` + if (!target) return { summary: `${tool}:input:${normalizeInput(input).hash}`, isFallback: true } + return { summary: `${target.kind}:${hash(target.value.trim())}`, isFallback: false } + } + + // Coerce `unknown` to a single trimmed first line — used by errorFingerprint and the loop + // renderer's stop-message extraction. Returns "" for nullish/empty. + export function firstLine(value: unknown): string { + if (value === undefined || value === null) return "" + const message = typeof value === "string" ? value : value instanceof Error ? value.message : String(value) + return ( + message + .split(/\r?\n/) + .map((item) => item.trim()) + .find(Boolean) ?? "" + ) } export function errorFingerprint(error: unknown) { - const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error) - const line = message - .split(/\r?\n/) - .map((item) => item.trim()) - .find(Boolean) - const normalized = (line ?? "") + const line = firstLine(error) + const normalized = line .toLowerCase() .replace(/https?:\/\/\S+/g, "") .replace(/['"`][^'"`]*['"`]/g, "") @@ -108,28 +182,16 @@ export namespace SessionDiagnostics { providerID: string }) { const normalized = normalizeInput(input.input) - const summary = targetSummary(input.tool, input.input) + const summaryResult = targetSummary(input.tool, input.input) + const summary = summaryResult.summary const targetHash = hash(summary) - const inputKey = `input:${input.parentID}:${input.tool}:${normalized.hash}` const inputRepeatCount = input.records.filter((record) => record.parentID === input.parentID && record.tool === input.tool && record.inputHash === normalized.hash).length + 1 const targetRepeatCount = - input.records.filter((record) => record.parentID === input.parentID && record.targetHash === targetHash).length + 1 - const hasReminder = input.records.some((record) => - record.metadata.diagnostics?.loop?.reminders?.some((reminder) => reminder.key === inputKey), - ) - const reminders = - inputRepeatCount === 3 && !hasReminder - ? [ - { - key: inputKey, - type: "input_repeat" as const, - status: "pending" as const, - count: inputRepeatCount, - createdAt: Date.now(), - }, - ] - : [] + input.records.filter( + (record) => + record.parentID === input.parentID && record.tool === input.tool && record.targetHash === targetHash, + ).length + 1 const record: ToolCallRecord = { sessionID: input.sessionID, @@ -144,9 +206,10 @@ export namespace SessionDiagnostics { inputRepeatCount, targetSummary: summary, targetHash, + targetHashIsFallback: summaryResult.isFallback, targetRepeatCount, newTarget: targetRepeatCount === 1, - reminders, + reminders: [], modelID: input.modelID, providerID: input.providerID, agent: input.agent, @@ -167,41 +230,127 @@ export namespace SessionDiagnostics { sessionID: SessionID parentID: MessageID tool: string + inputHash?: string + targetHash?: string + originalInput?: unknown error: unknown }) { const fingerprint = errorFingerprint(input.error) - const key = `error:${input.parentID}:${input.tool}:${fingerprint}` - const errorRepeatCount = - input.records.filter( - (record) => - record.parentID === input.parentID && record.tool === input.tool && record.errorFingerprint === fingerprint, - ).length + 1 - const hasReminder = input.records.some((record) => - record.metadata.diagnostics?.loop?.reminders?.some((reminder) => reminder.key === key), - ) - const reminders = - errorRepeatCount === 3 && !hasReminder - ? [ - { - key, - type: "error_repeat" as const, - status: "pending" as const, - count: errorRepeatCount, - createdAt: Date.now(), + + let effectiveInputHash = input.inputHash + if (!effectiveInputHash && input.originalInput !== undefined) { + effectiveInputHash = normalizeInput(input.originalInput).hash + } + + // Recover targetHash symmetrically. If caller skipped both hashes (no inflight metadata) + // but did provide originalInput, recompute target the same way observeToolCall would — + // respecting `isFallback` so generic tools without a recognized target field still skip + // target tracking. Without this, target_repeat / gate escalation silently degrades to + // input-only tracking on the recovery path. + let effectiveTargetHash = input.targetHash + if (!effectiveTargetHash && input.originalInput !== undefined) { + const target = targetSummary(input.tool, input.originalInput) + if (!target.isFallback) effectiveTargetHash = hash(target.summary) + } + + const lastInput = input.originalInput + const lastError = + typeof input.error === "string" + ? input.error + : input.error instanceof Error + ? input.error.message + : String(input.error) + + if (!effectiveInputHash) { + const record: ToolErrorRecord = { + sessionID: input.sessionID, + parentID: input.parentID, + tool: input.tool, + inputHash: "", + errorFingerprint: fingerprint, + lastInput, + lastError, + metadata: { + diagnostics: { + loop: { + errorFingerprint: fingerprint, + reminders: [], + loopLastInput: lastInput, + loopLastError: lastError, }, - ] - : [] + }, + }, + } + return { record } + } + + const real = input.records.filter( + (r) => + r.parentID === input.parentID && + r.tool === input.tool && + r.metadata.diagnostics?.loop?.loopAction !== "block" && + r.metadata.diagnostics?.loop?.loopAction !== "stop", + ) + + const candidates: Array<{ + sigKey: string + kind: SignatureKind + matcher: (r: ToolErrorRecord) => boolean + }> = [] + candidates.push({ + sigKey: `input:${input.tool}:${effectiveInputHash}`, + kind: "input", + matcher: (r) => r.inputHash === effectiveInputHash, + }) + if (effectiveTargetHash) { + const targetHash = effectiveTargetHash + candidates.push({ + sigKey: `target:${input.tool}:${targetHash}`, + kind: "target", + matcher: (r) => r.targetHash === targetHash, + }) + } + + const newReminders: Reminder[] = [] + const recoverFiredFor: string[] = [] + for (const { sigKey, kind, matcher } of candidates) { + const completedFailures = real.filter(matcher).length + 1 + const alreadyFired = real.some((r) => + (r.metadata.diagnostics?.loop?.loopRecoverFiredFor ?? []).includes(sigKey), + ) + if (completedFailures >= 3 && !alreadyFired) { + newReminders.push({ + key: sigKey, + type: kind === "target" ? "target_repeat" : "input_repeat", + status: "pending", + count: completedFailures, + createdAt: Date.now(), + }) + recoverFiredFor.push(sigKey) + } + } + + const errorRepeatCount = + real.filter((r) => r.errorFingerprint === fingerprint).length + 1 + const record: ToolErrorRecord = { sessionID: input.sessionID, parentID: input.parentID, tool: input.tool, + inputHash: effectiveInputHash, + targetHash: effectiveTargetHash, errorFingerprint: fingerprint, + lastInput, + lastError, metadata: { diagnostics: { loop: { errorFingerprint: fingerprint, errorRepeatCount, - reminders, + reminders: newReminders, + loopRecoverFiredFor: recoverFiredFor.length ? recoverFiredFor : undefined, + loopLastInput: lastInput, + loopLastError: lastError, }, }, }, @@ -209,6 +358,89 @@ export namespace SessionDiagnostics { return { record } } + export function deriveParentLoopState(input: { + errorRecords: ToolErrorRecord[] + syntheticBlockSigKeys: string[] + parentID: MessageID + }): ParentLoopState { + const signatures: Record = {} + + const real = input.errorRecords.filter( + (r) => + r.parentID === input.parentID && + r.metadata.diagnostics?.loop?.loopAction !== "block" && + r.metadata.diagnostics?.loop?.loopAction !== "stop" && + r.inputHash !== "", + ) + + for (const r of real) { + const inputSigKey = r.inputHash ? `input:${r.tool}:${r.inputHash}` : null + const targetSigKey = r.targetHash ? `target:${r.tool}:${r.targetHash}` : null + const fired = r.metadata.diagnostics?.loop?.loopRecoverFiredFor ?? [] + for (const [sigKey, kind] of [ + [inputSigKey, "input"] as const, + [targetSigKey, "target"] as const, + ]) { + if (!sigKey) continue + const s = (signatures[sigKey] ??= { + kind, + completedFailures: 0, + recoverEmitted: false, + blockEmitted: false, + }) + s.completedFailures += 1 + if (fired.includes(sigKey)) s.recoverEmitted = true + if (r.lastInput !== undefined) s.lastInput = r.lastInput + if (r.lastError !== undefined) s.lastError = r.lastError + } + } + + for (const sigKey of input.syntheticBlockSigKeys) { + const kind: SignatureKind = sigKey.startsWith("target:") ? "target" : "input" + const s = (signatures[sigKey] ??= { + kind, + completedFailures: 0, + recoverEmitted: false, + blockEmitted: false, + }) + s.blockEmitted = true + } + + return { + autoResumeSpent: input.syntheticBlockSigKeys.length > 0, + signatures, + } + } + + export function queryGateAction(input: { + parentLoopState: ParentLoopState + tool: string + inputHash: string + targetHash?: string + }): GateDecision { + const { parentLoopState: state, tool, inputHash, targetHash } = input + const inputKey = `input:${tool}:${inputHash}` + const targetKey = targetHash ? `target:${tool}:${targetHash}` : null + + // Iteration order is intentional: target is checked first because the spec treats + // same_target as the more general signal (the model is hitting the same goal in + // different ways). When same_input and same_target both reach threshold, we fire on + // the target sigKey only; the input sigKey's blockEmitted stays false. This is fine + // in practice because same_input ⊆ same_target (same input always has same target), + // so the model can't evade by varying targets without also changing input. + for (const sigKey of [targetKey, inputKey] as const) { + if (!sigKey) continue + const s = state.signatures[sigKey] + if (!s) continue + if (s.completedFailures >= 5 && s.recoverEmitted) { + const action: LoopAction = state.autoResumeSpent || s.blockEmitted ? "stop" : "block" + return { action, sigKey, kind: s.kind, completedFailures: s.completedFailures } + } + } + + return { action: "observe" } + } + export function mergeMetadata | undefined>(current: T, update: Metadata): NonNullable & Metadata { if (!current?.diagnostics && !update.diagnostics) { return { ...(current ?? {}), ...update } as NonNullable & Metadata @@ -269,15 +501,25 @@ export namespace SessionDiagnostics { } if (!pending.length) return { parts } - const hasInputRepeat = pending.some((reminder) => reminder.type === "input_repeat") - const hasErrorRepeat = pending.some((reminder) => reminder.type === "error_repeat") - const lines = [""] - if (hasInputRepeat) { + const lines: string[] = [""] + const sawInput = pending.some((r) => r.key.startsWith("input:")) + const sawTarget = pending.some((r) => r.key.startsWith("target:")) + // Backward-compat: v0 reminders persisted with `error:` (or other) prefixes. Without this + // fallback they get silently consumed (status flipped to "injected") with no model-facing + // text, which loses the warning entirely. Emit the legacy generic copy so old sessions still + // surface a reminder during migration. + const sawLegacy = pending.some((r) => !r.key.startsWith("input:") && !r.key.startsWith("target:")) + if (sawInput) { lines.push( "Detected that you have repeated the same tool input 3 times. Do not call the same input again. Reuse the existing result, change strategy, or summarize the current blocker.", ) } - if (hasErrorRepeat) { + if (sawTarget) { + lines.push( + "Detected that you have failed against the same target multiple times even though the errors differ. Do not keep retrying. Change approach, identify why the target is unreachable, or summarize the current blocker.", + ) + } + if (sawLegacy && !sawInput && !sawTarget) { lines.push( "Detected that you have hit the same class of tool error multiple times. Do not keep retrying blindly. Identify the failure layer, change strategy, or summarize the current blocker.", ) @@ -301,11 +543,18 @@ export namespace SessionDiagnostics { function findTarget(input: unknown): { kind: string; value: string } | undefined { if (!input || typeof input !== "object") return undefined const record = input as Record + // Blank/whitespace strings would hash to a stable target across unrelated tool calls and + // poison same_target accumulation. Treat them as "no target" so loop detection skips + // target tracking instead of locking onto an empty signature. for (const key of ["url", "href"]) { - if (typeof record[key] === "string") return { kind: "url", value: record[key] } + const raw = record[key] + if (typeof raw === "string" && raw.trim().length > 0) return { kind: "url", value: raw } } - for (const key of ["query", "search", "pattern", "path", "command", "cmd"]) { - if (typeof record[key] === "string") return { kind: key, value: record[key] } + for (const key of ["query", "search", "pattern", "path", "filePath", "filepath", "command", "cmd"]) { + const raw = record[key] + if (typeof raw === "string" && raw.trim().length > 0) { + return { kind: key === "filePath" || key === "filepath" ? "path" : key, value: raw } + } } return undefined } diff --git a/packages/opencode/src/session/export.ts b/packages/opencode/src/session/export.ts index da2a61d2a..35feadfd0 100644 --- a/packages/opencode/src/session/export.ts +++ b/packages/opencode/src/session/export.ts @@ -130,7 +130,17 @@ export namespace Export { omitted_attachment_count: number } } - diagnostics: Record + diagnostics: { + loop?: { + last?: { + parentID: string + type: "same_input" | "same_target" + action: "block" | "stop" + tool: string + completedFailures: number + } + } + } session: Tree } @@ -139,6 +149,64 @@ export namespace Export { childInfos: Session.Info[] } + export function deriveSnapshotDiagnostics(node: Tree): { + loop?: { + last?: { + parentID: string + type: "same_input" | "same_target" + action: "block" | "stop" + tool: string + completedFailures: number + } + } + } { + let lastAt = -Infinity + let last: + | { + parentID: string + type: "same_input" | "same_target" + action: "block" | "stop" + tool: string + completedFailures: number + } + | undefined + const walk = (t: Tree) => { + for (const message of t.messages ?? []) { + if (message.info.role !== "assistant") continue + for (const part of message.parts) { + if (part.type !== "tool") continue + const metadata = "metadata" in part.state ? part.state.metadata : undefined + const loop = metadata?.diagnostics?.loop as + | { + loopAction?: string + loopType?: string + loopCompletedFailures?: number + } + | undefined + if (!loop || (loop.loopAction !== "block" && loop.loopAction !== "stop")) continue + if (!loop.loopType || typeof loop.loopCompletedFailures !== "number" || !message.info.parentID) continue + let at = -Infinity + if ("time" in part.state) { + const t = part.state.time + at = "end" in t && typeof t.end === "number" ? t.end : t.start + } + if (at < lastAt) continue + lastAt = at + last = { + parentID: message.info.parentID, + type: loop.loopType === "input" ? "same_input" : "same_target", + action: loop.loopAction, + tool: part.tool, + completedFailures: loop.loopCompletedFailures, + } + } + } + for (const child of t.children ?? []) walk(child) + } + walk(node) + return last ? { loop: { last } } : {} + } + const climbToRoot = Effect.fn("Export.climbToRoot")(function* (svc: Session.Interface, id: SessionID) { let current: Session.Info = yield* svc.get(id) while (current.parentID) { @@ -296,7 +364,7 @@ export namespace Export { model_refs, stats: countStats(tree, ctx.count.omitted), }, - diagnostics: {}, + diagnostics: deriveSnapshotDiagnostics(tree), session: tree, } satisfies Snapshot }) diff --git a/packages/opencode/src/session/loop-renderer.ts b/packages/opencode/src/session/loop-renderer.ts new file mode 100644 index 000000000..1942415a8 --- /dev/null +++ b/packages/opencode/src/session/loop-renderer.ts @@ -0,0 +1,126 @@ +import { SessionDiagnostics } from "./diagnostics" + +export namespace LoopRenderer { + export type RenderInput = { + tool: string + state: SessionDiagnostics.SignatureState + locale?: string + } + + export function render(input: RenderInput): string { + const { tool, state, locale } = input + const errorLine = firstErrorLine(state.lastError) + const scrubbedError = errorLine ? scrubErrorText(errorLine) : undefined + const truncatedError = scrubbedError ? SessionDiagnostics.truncateForRenderer(scrubbedError) : undefined + const isZh = (locale ?? "").toLowerCase().startsWith("zh") + + if (tool === "webfetch") { + const rawURL = extractURL(state.lastInput) + const cleanedURL = rawURL ? stripQueryAndFragment(rawURL) : undefined + const truncatedURL = cleanedURL ? SessionDiagnostics.truncateForRenderer(cleanedURL) : undefined + if (state.kind === "input") { + if (isZh) { + return truncatedURL + ? `我重复调用了相同的请求 ${state.completedFailures} 次没成功,已停止。请求:webfetch ${truncatedURL}` + : `我重复调用了相同的请求 ${state.completedFailures} 次没成功,已停止。` + } + return truncatedURL + ? `I made the same request ${state.completedFailures} times without success and stopped. Request: webfetch ${truncatedURL}` + : `I made the same request ${state.completedFailures} times without success and stopped.` + } + if (isZh) { + if (truncatedURL && truncatedError) + return `我重复抓取同一个目标 ${state.completedFailures} 次都失败,已停止。目标:${truncatedURL} 错误:${truncatedError}` + if (truncatedURL) + return `我重复抓取同一个目标 ${state.completedFailures} 次都失败,已停止。目标:${truncatedURL}` + if (truncatedError) + return `我重复抓取同一个目标 ${state.completedFailures} 次都失败,已停止。错误:${truncatedError}` + return `我重复抓取同一个目标 ${state.completedFailures} 次都失败,已停止。` + } + if (truncatedURL && truncatedError) + return `I failed to fetch the same target ${state.completedFailures} times and stopped. Target: ${truncatedURL} Error: ${truncatedError}` + if (truncatedURL) + return `I failed to fetch the same target ${state.completedFailures} times and stopped. Target: ${truncatedURL}` + if (truncatedError) + return `I failed to fetch the same target ${state.completedFailures} times and stopped. Error: ${truncatedError}` + return `I failed to fetch the same target ${state.completedFailures} times and stopped.` + } + + if (state.kind === "target") { + if (isZh) { + return truncatedError + ? `我重复在同一个目标上失败了 ${state.completedFailures} 次,已停止。工具:${tool} 错误:${truncatedError}` + : `我重复在同一个目标上失败了 ${state.completedFailures} 次,已停止。工具:${tool}` + } + return truncatedError + ? `I failed against the same target ${state.completedFailures} times and stopped. Tool: ${tool} Error: ${truncatedError}` + : `I failed against the same target ${state.completedFailures} times and stopped. Tool: ${tool}` + } + + if (isZh) { + return truncatedError + ? `我重复调用了 ${tool} ${state.completedFailures} 次都失败,已停止。最近一次错误:${truncatedError}` + : `我重复调用了 ${tool} ${state.completedFailures} 次都失败,已停止。` + } + return truncatedError + ? `I called ${tool} ${state.completedFailures} times without success and stopped. Last error: ${truncatedError}` + : `I called ${tool} ${state.completedFailures} times without success and stopped.` + } + + function extractURL(value: unknown): string | undefined { + if (typeof value === "string") return value + if (!value || typeof value !== "object") return undefined + const r = value as Record + if (typeof r.url === "string") return r.url + if (typeof r.href === "string") return r.href + return undefined + } + + function stripQueryAndFragment(url: string): string { + try { + const u = new URL(url) + return `${u.protocol}//${u.host}${u.pathname}` + } catch { + const q = url.indexOf("?") + const f = url.indexOf("#") + const cuts = [q, f].filter((i) => i >= 0) + if (!cuts.length) return url + return url.slice(0, Math.min(...cuts)) + } + } + + // Strip query strings and fragments from any URLs embedded in free text (error messages, + // stack traces, etc.). Tokens often live in `?token=...` or `#access_token=...`. The `i` + // flag covers uppercase scheme variants like `HTTPS://`. + function scrubURLsInText(text: string): string { + return text.replace(/(https?:\/\/[^\s,;)]+)/gi, (match) => stripQueryAndFragment(match)) + } + + // Multi-pass scrub for tool error text. Tool errors echo URLs with tokens, fully-qualified + // file paths, quoted user input, and Bearer/Basic auth headers. Mask each class so the + // synthetic stop summary does not become a leak vector. Path detection uses a negative + // lookbehind so `Error:at /tmp/x` and `failed at '/home/alice/key'` get scrubbed too — not + // only paths preceded by start-of-line or whitespace. Path char class allows spaces + // (real paths: `/Users/alice/My Documents/...`) and stops at structural delimiters + // (quote, comma, semicolon, colon, paren/bracket, newline) — over-scrub of trailing + // descriptive text is preferred to under-scrub leaving secrets visible. + function scrubErrorText(text: string): string { + return scrubURLsInText(text) + .replace(/(?:Bearer|Basic|token|api[_-]?key)[\s:=]+[^\s'"`,;)]+/gi, "") + .replace(/['"`][^'"`]*['"`]/g, "") + .replace(/\b[A-Za-z]:\\[^'"`,;:)\]\n]+/g, "") + // Forward-slash Windows paths (`C:/Users/...`) — JS path normalization on Windows can + // produce these, and the general path regex below excludes `:` as preceding char to + // avoid stripping URL paths, so drive-letter forward-slash paths need their own pass. + .replace(/\b[A-Za-z]:\/[^'"`,;:)\]\n]+/g, "") + // Relative paths starting with `./` or `../`. Without this pass the absolute-path + // regex below only catches the `/...` suffix and leaves the leading dots visible. + .replace(/(?") + .replace(/(?") + } + + function firstErrorLine(error: unknown): string | undefined { + const line = SessionDiagnostics.firstLine(error) + return line === "" ? undefined : line + } +} diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 7ebb8c72d..15c2e48f7 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -43,6 +43,31 @@ export interface Handle { }, ) => Effect.Effect readonly process: (streamInput: LLM.StreamInput) => Effect.Effect + readonly errorRecords: (parentID: MessageV2.Assistant["parentID"]) => SessionDiagnostics.ToolErrorRecord[] + readonly syntheticBlockSigKeys: (parentID: MessageV2.Assistant["parentID"]) => string[] + readonly hasStopped: (parentID: MessageV2.Assistant["parentID"]) => boolean + readonly buildLoopContext: (parentID: MessageV2.Assistant["parentID"]) => { + errorRecords: SessionDiagnostics.ToolErrorRecord[] + syntheticBlockSigKeys: string[] + hasStopped: boolean + } + readonly recordSyntheticBlock: (input: { + toolCallId: string + tool: string + sigKey: string + kind: SessionDiagnostics.SignatureKind + completedFailures: number + errorMessage: string + }) => Effect.Effect + readonly recordSyntheticStop: (input: { + toolCallId: string + tool: string + sigKey: string + kind: SessionDiagnostics.SignatureKind + completedFailures: number + renderedText: string + toolErrorMessage: string + }) => Effect.Effect } type Input = { @@ -195,6 +220,11 @@ export const layer: Layer.Layer< }) } + // Surface tool parts that represent a loop-relevant failure: real tool errors (carry + // errorFingerprint) AND synthetic block/stop markers. deriveParentLoopState applies the + // policy filter (loopAction !== "block"|"stop") on top — keeping this filter broad means + // we don't silently drop synthetic markers if a future merge accidentally clears + // errorFingerprint. const errorRecords = (parentID: MessageV2.Assistant["parentID"]) => { if (!parentID) return [] return Array.from(MessageV2.stream(ctx.sessionID)).flatMap((message) => { @@ -202,13 +232,21 @@ export const layer: Layer.Layer< return message.parts.flatMap((part) => { if (part.type !== "tool") return [] const loop = toolDiagnostics(part)?.loop - if (!loop?.errorFingerprint) return [] + if (!loop) return [] + const isLoopRelevant = + !!loop.errorFingerprint || loop.loopAction === "block" || loop.loopAction === "stop" + if (!isLoopRelevant) return [] + const targetHash = loop.targetHashIsFallback ? undefined : loop.targetHash return [ { sessionID: ctx.sessionID, parentID, tool: part.tool, - errorFingerprint: loop.errorFingerprint, + inputHash: loop.inputHash ?? "", + targetHash, + errorFingerprint: loop.errorFingerprint ?? "", + lastInput: loop.loopLastInput, + lastError: loop.loopLastError, metadata: { diagnostics: { loop } }, } satisfies SessionDiagnostics.ToolErrorRecord, ] @@ -216,6 +254,70 @@ export const layer: Layer.Layer< }) } + const syntheticBlockSigKeys = (parentID: MessageV2.Assistant["parentID"]): string[] => { + if (!parentID) return [] + const out: string[] = [] + for (const message of Array.from(MessageV2.stream(ctx.sessionID))) { + if (message.info.role !== "assistant" || message.info.parentID !== parentID) continue + for (const part of message.parts) { + if (part.type !== "tool") continue + const loop = toolDiagnostics(part)?.loop + if (loop?.loopAction !== "block") continue + if (loop.loopSigKey) out.push(loop.loopSigKey) + } + } + return out + } + + const hasStopped = (parentID: MessageV2.Assistant["parentID"]): boolean => { + if (!parentID) return false + for (const message of Array.from(MessageV2.stream(ctx.sessionID))) { + if (message.info.role !== "assistant" || message.info.parentID !== parentID) continue + for (const part of message.parts) { + if (part.type !== "tool") continue + if (toolDiagnostics(part)?.loop?.loopAction === "stop") return true + } + } + return false + } + + // Single-pass aggregator. applyLoopGate runs before every tool execution and would otherwise + // call errorRecords + syntheticBlockSigKeys + hasStopped (three full O(n) scans of the message + // stream); this helper folds them into one scan. + const buildLoopContext = (parentID: MessageV2.Assistant["parentID"]) => { + const errorRecordsOut: SessionDiagnostics.ToolErrorRecord[] = [] + const syntheticBlockSigKeysOut: string[] = [] + let hasStoppedOut = false + if (!parentID) { + return { errorRecords: errorRecordsOut, syntheticBlockSigKeys: syntheticBlockSigKeysOut, hasStopped: hasStoppedOut } + } + for (const message of Array.from(MessageV2.stream(ctx.sessionID))) { + if (message.info.role !== "assistant" || message.info.parentID !== parentID) continue + for (const part of message.parts) { + if (part.type !== "tool") continue + const loop = toolDiagnostics(part)?.loop + if (!loop) continue + if (loop.loopAction === "stop") hasStoppedOut = true + if (loop.loopAction === "block" && loop.loopSigKey) syntheticBlockSigKeysOut.push(loop.loopSigKey) + if (loop.errorFingerprint || loop.loopAction === "block" || loop.loopAction === "stop") { + const targetHash = loop.targetHashIsFallback ? undefined : loop.targetHash + errorRecordsOut.push({ + sessionID: ctx.sessionID, + parentID, + tool: part.tool, + inputHash: loop.inputHash ?? "", + targetHash, + errorFingerprint: loop.errorFingerprint ?? "", + lastInput: loop.loopLastInput, + lastError: loop.loopLastError, + metadata: { diagnostics: { loop } }, + } satisfies SessionDiagnostics.ToolErrorRecord) + } + } + } + return { errorRecords: errorRecordsOut, syntheticBlockSigKeys: syntheticBlockSigKeysOut, hasStopped: hasStoppedOut } + } + const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* ( toolCallID: string, output: { @@ -245,13 +347,23 @@ export const layer: Layer.Layer< const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) { const match = yield* readToolCall(toolCallID) - if (!match || match.part.state.status !== "running") return false + if (!match) return false + if (match.part.state.status !== "running") { + yield* settleToolCall(toolCallID) + return false + } + const inflightLoop = toolDiagnostics(match.part)?.loop + const inputHash = inflightLoop?.inputHash + const targetHash = inflightLoop?.targetHashIsFallback ? undefined : inflightLoop?.targetHash const diagnostics: SessionDiagnostics.Metadata["diagnostics"] | undefined = ctx.assistantMessage.parentID ? SessionDiagnostics.observeToolError({ records: errorRecords(ctx.assistantMessage.parentID), sessionID: ctx.sessionID, parentID: ctx.assistantMessage.parentID, tool: match.part.tool, + inputHash, + targetHash, + originalInput: match.part.state.input, error, }).record.metadata.diagnostics : toolDiagnostics(match.part) @@ -602,7 +714,10 @@ export const layer: Layer.Layer< yield* stream.pipe( Stream.tap((event) => handleEvent(event)), - Stream.takeUntil(() => ctx.needsCompaction), + // Stop draining the stream as soon as the loop gate fires a synthetic stop + // (ctx.blocked) so any trailing model text after the synthetic stop tool-error + // is dropped — the turn ends with the rendered Chinese summary alone. + Stream.takeUntil(() => ctx.needsCompaction || ctx.blocked), Stream.runDrain, ) }).pipe( @@ -640,6 +755,142 @@ export const layer: Layer.Layer< }) }) + const recordSyntheticBlock = Effect.fn("SessionProcessor.recordSyntheticBlock")(function* (input: { + toolCallId: string + tool: string + sigKey: string + kind: SessionDiagnostics.SignatureKind + completedFailures: number + errorMessage: string + }) { + const match = yield* readToolCall(input.toolCallId) + if (!match) return + // Idempotence guard: if the model emits multiple parallel tool calls of the same + // sigKey within one assistant step, applyLoopGate can decide block for several of + // them before any has persisted. Re-check existing block sigKeys here so we record + // at most one synthetic block per sigKey per parentID. This still has a residual + // race window (two parallel writers can both pass this check), but closes the most + // likely path. Full fix would need a per-parent Effect.Mutex; deferred as the + // residual race only produces extra diagnostic parts, not behavioral drift. + const parentID = ctx.assistantMessage.parentID + if (parentID) { + const existing = syntheticBlockSigKeys(parentID) + if (existing.includes(input.sigKey)) { + // Still write a terminal `error` state for THIS part — without the loop marker, + // so deriveParentLoopState only counts one synthetic block per sigKey. Skipping + // the write would leave the part stuck in pending/running forever after settle. + const dupEnd = Date.now() + const dupStart = "time" in match.part.state ? match.part.state.time.start : dupEnd + yield* session.updatePart({ + ...match.part, + state: { + status: "error", + input: match.part.state.input, + error: input.errorMessage, + metadata: toolStateMetadata(match.part), + time: { start: dupStart, end: dupEnd }, + }, + }) + yield* settleToolCall(input.toolCallId) + return + } + } + const existingMeta = toolStateMetadata(match.part) + const merged = SessionDiagnostics.mergeMetadata(existingMeta, { + diagnostics: { + loop: { + loopAction: "block", + loopType: input.kind, + loopSigKey: input.sigKey, + loopCompletedFailures: input.completedFailures, + }, + }, + }) + const end = Date.now() + const startTime = "time" in match.part.state ? match.part.state.time.start : end + yield* session.updatePart({ + ...match.part, + state: { + status: "error", + input: match.part.state.input, + error: input.errorMessage, + metadata: merged, + time: { start: startTime, end }, + }, + }) + yield* settleToolCall(input.toolCallId) + }) + + const recordSyntheticStop = Effect.fn("SessionProcessor.recordSyntheticStop")(function* (input: { + toolCallId: string + tool: string + sigKey: string + kind: SessionDiagnostics.SignatureKind + completedFailures: number + renderedText: string + toolErrorMessage: string + }) { + const match = yield* readToolCall(input.toolCallId) + if (!match) return + // Idempotence guard (see recordSyntheticBlock for the full rationale): re-check + // hasStopped here. The duplicate-stop case writes two Chinese summaries which is + // visible UX noise; this guard closes the most common parallel-call window. + const parentID = ctx.assistantMessage.parentID + if (parentID && hasStopped(parentID)) { + // Same reason as the block-side guard: write a terminal `error` state without the + // loop marker (no duplicate stop summary, no second TextPart) so the part can't be + // left forever pending/running after settle. + const dupEnd = Date.now() + const dupStart = "time" in match.part.state ? match.part.state.time.start : dupEnd + yield* session.updatePart({ + ...match.part, + state: { + status: "error", + input: match.part.state.input, + error: input.toolErrorMessage, + metadata: toolStateMetadata(match.part), + time: { start: dupStart, end: dupEnd }, + }, + }) + yield* settleToolCall(input.toolCallId) + return + } + const existingMeta = toolStateMetadata(match.part) + const merged = SessionDiagnostics.mergeMetadata(existingMeta, { + diagnostics: { + loop: { + loopAction: "stop", + loopType: input.kind, + loopSigKey: input.sigKey, + loopCompletedFailures: input.completedFailures, + }, + }, + }) + const stopEnd = Date.now() + const stopStart = "time" in match.part.state ? match.part.state.time.start : stopEnd + yield* session.updatePart({ + ...match.part, + state: { + status: "error", + input: match.part.state.input, + error: input.toolErrorMessage, + metadata: merged, + time: { start: stopStart, end: stopEnd }, + }, + }) + const textPart: MessageV2.TextPart = { + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: ctx.assistantMessage.id, + type: "text", + text: input.renderedText, + synthetic: true, + } + yield* session.updatePart(textPart) + yield* settleToolCall(input.toolCallId) + ctx.blocked = true + }) + return { get message() { return ctx.assistantMessage @@ -647,6 +898,12 @@ export const layer: Layer.Layer< updateToolCall, completeToolCall, process, + errorRecords, + syntheticBlockSigKeys, + hasStopped, + buildLoopContext, + recordSyntheticBlock, + recordSyntheticStop, } satisfies Handle }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3d6a19a0d..1f1a70dd2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -36,6 +36,7 @@ import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" import { SessionProcessor } from "./processor" import { SessionDiagnostics } from "./diagnostics" +import { LoopRenderer } from "./loop-renderer" import { Tool } from "@/tool/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" @@ -68,6 +69,105 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc const log = Log.create({ service: "session.prompt" }) const elog = EffectLogger.create({ service: "session.prompt" }) + +// Single source of truth for product-name strings used in synthetic tool errors. If the brand +// changes, only this constant + assistant-text Chinese summary need to change. +const LOOP_GATE_BRAND = "PawWork" +const LOOP_GATE_BLOCK_PREFIX = `blocked by ${LOOP_GATE_BRAND}` +const LOOP_GATE_STOP_PREFIX = `halted by ${LOOP_GATE_BRAND}` + +class BlockedLoopError extends Error { + constructor(public readonly userFacing: string) { + super(userFacing) + } +} +class LoopStopError extends Error { + constructor(public readonly toolErrorMessage: string) { + super(toolErrorMessage) + } +} + +type GateOutcome = + | { kind: "observe" } + | { kind: "block"; userFacing: string } + | { kind: "stop"; toolErrorMessage: string } + +const applyLoopGate = Effect.fn("SessionPrompt.applyLoopGate")(function* (input: { + processor: SessionProcessor.Handle + toolId: string + args: unknown + toolCallId: string + locale?: string +}) { + const { processor, toolId, args, toolCallId, locale } = input + const parentID = processor.message.parentID + if (!parentID) return { kind: "observe" } satisfies GateOutcome + + // Single pass over the message stream — folds errorRecords, syntheticBlockSigKeys, and + // hasStopped into one walk. applyLoopGate runs before every tool execution, so this saves + // O(2n) per call vs three independent scans. + const loopCtx = processor.buildLoopContext(parentID) + + // Once a synthetic stop has been recorded under this parentID, keep the gate + // closed for any later tool call ai-sdk auto-resumes into. Returning `observe` + // here would let real tools execute after stop, breaking the "turn ends" contract. + // We propagate stop without re-recording to avoid duplicate Chinese summary. + if (loopCtx.hasStopped) { + return { + kind: "stop", + toolErrorMessage: `${LOOP_GATE_STOP_PREFIX}: stop already recorded for this turn`, + } satisfies GateOutcome + } + + const inputHashRes = SessionDiagnostics.normalizeInput(args) + const targetSummaryRes = SessionDiagnostics.targetSummary(toolId, args) + const targetHash = targetSummaryRes.isFallback ? undefined : SessionDiagnostics.hash(targetSummaryRes.summary) + + const parentLoopState = SessionDiagnostics.deriveParentLoopState({ + errorRecords: loopCtx.errorRecords, + syntheticBlockSigKeys: loopCtx.syntheticBlockSigKeys, + parentID, + }) + + const decision = SessionDiagnostics.queryGateAction({ + parentLoopState, + tool: toolId, + inputHash: inputHashRes.hash, + targetHash, + }) + + if (decision.action === "observe") return { kind: "observe" } satisfies GateOutcome + + const sigState = parentLoopState.signatures[decision.sigKey] + if (!sigState) return { kind: "observe" } satisfies GateOutcome + + if (decision.action === "block") { + const userFacing = `${LOOP_GATE_BLOCK_PREFIX}: this signature has already failed ${decision.completedFailures} times in this turn` + yield* processor.recordSyntheticBlock({ + toolCallId, + tool: toolId, + sigKey: decision.sigKey, + kind: decision.kind, + completedFailures: decision.completedFailures, + errorMessage: userFacing, + }) + return { kind: "block", userFacing } satisfies GateOutcome + } + + const renderedText = LoopRenderer.render({ tool: toolId, state: sigState, locale }) + const toolErrorMessage = `${LOOP_GATE_STOP_PREFIX}: stop after repeated failures (${decision.completedFailures})` + yield* processor.recordSyntheticStop({ + toolCallId, + tool: toolId, + sigKey: decision.sigKey, + kind: decision.kind, + completedFailures: decision.completedFailures, + renderedText, + toolErrorMessage, + }) + return { kind: "stop", toolErrorMessage } satisfies GateOutcome +}) + function officePathOnly(filepath: string) { return OFFICE_EXTS.has(pathSuffix(filepath)) } @@ -383,7 +483,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the model: Provider.Model session: Session.Info tools?: Record - processor: Pick + processor: SessionProcessor.Handle bypassAgentCheck: boolean messages: MessageV2.WithParts[] }) { @@ -391,6 +491,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the const tools: Record = {} const run = yield* runner() const promptOps = yield* ops() + // Locale travels on the user message (set by the UI from `language.intl()`); capture + // once here and let every applyLoopGate call in this resolveTools scope share it. + // Falls back to undefined → English in LoopRenderer. Skip user messages without locale + // (synthetic continuations like subtask-summary or shell-input) so a zh session keeps + // rendering Chinese stop summaries even after a synthetic user hop. + const lastUserMessage = input.messages.findLast( + (m): m is MessageV2.WithParts & { info: MessageV2.User } => + m.info.role === "user" && typeof m.info.locale === "string" && m.info.locale.length > 0, + ) + const lastUserLocale = lastUserMessage?.info.locale const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({ sessionID: input.session.id, @@ -438,6 +548,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the return run.promise( Effect.gen(function* () { const ctx = context(args, options) + const outcome = yield* applyLoopGate({ + processor: input.processor, + toolId: item.id, + args, + toolCallId: options.toolCallId, + locale: lastUserLocale, + }) + if (outcome.kind === "block") return yield* Effect.fail(new BlockedLoopError(outcome.userFacing)) + if (outcome.kind === "stop") return yield* Effect.fail(new LoopStopError(outcome.toolErrorMessage)) yield* plugin.trigger( "tool.execute.before", { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, @@ -479,6 +598,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the run.promise( Effect.gen(function* () { const ctx = context(args, opts) + const outcome = yield* applyLoopGate({ + processor: input.processor, + toolId: key, + args, + toolCallId: opts.toolCallId, + locale: lastUserLocale, + }) + if (outcome.kind === "block") return yield* Effect.fail(new BlockedLoopError(outcome.userFacing)) + if (outcome.kind === "stop") return yield* Effect.fail(new LoopStopError(outcome.toolErrorMessage)) yield* plugin.trigger( "tool.execute.before", { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, @@ -1570,6 +1698,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the onSuccess(output) { structured = output }, + shouldHalt: () => handle.hasStopped(handle.message.parentID), }) } @@ -1972,6 +2101,7 @@ export async function command(input: CommandInput) { export function createStructuredOutputTool(input: { schema: Record onSuccess: (output: unknown) => void + shouldHalt?: () => boolean }): AITool { // Remove $schema property if present (not needed for tool input) const { $schema: _, ...toolSchema } = input.schema @@ -1980,6 +2110,11 @@ export function createStructuredOutputTool(input: { description: STRUCTURED_OUTPUT_DESCRIPTION, inputSchema: jsonSchema(toolSchema as JSONSchema7), async execute(args) { + // After a synthetic stop, ai-sdk auto-resume must not capture a structured output: + // the turn ended, and emitting an answer here contradicts that contract. + if (input.shouldHalt?.()) { + throw new Error(`${LOOP_GATE_STOP_PREFIX}: stop already recorded for this turn`) + } // AI SDK validates args against inputSchema before calling execute() input.onSuccess(args) return { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 9ad143898..798523e36 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -156,6 +156,12 @@ function fake( updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)), completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void), process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)), + errorRecords: () => [], + syntheticBlockSigKeys: () => [], + hasStopped: () => false, + buildLoopContext: () => ({ errorRecords: [], syntheticBlockSigKeys: [], hasStopped: false }), + recordSyntheticBlock: Effect.fn("TestSessionProcessor.recordSyntheticBlock")(() => Effect.void), + recordSyntheticStop: Effect.fn("TestSessionProcessor.recordSyntheticStop")(() => Effect.void), } satisfies SessionProcessorModule.SessionProcessor.Handle } diff --git a/packages/opencode/test/session/diagnostics.test.ts b/packages/opencode/test/session/diagnostics.test.ts index 24b1de3e8..3092f51f8 100644 --- a/packages/opencode/test/session/diagnostics.test.ts +++ b/packages/opencode/test/session/diagnostics.test.ts @@ -40,11 +40,10 @@ describe("SessionDiagnostics.normalizeInput", () => { }) describe("SessionDiagnostics.observeToolCall", () => { - test("creates one pending reminder on the third repeated input in one user block", () => { + test("does not create reminders on repeated calls (firing moved to observeToolError)", () => { let records: SessionDiagnostics.ToolCallRecord[] = [] const input = { url: "https://example.com/article" } - - for (let i = 0; i < 3; i++) { + for (let i = 0; i < 5; i++) { const observed = SessionDiagnostics.observeToolCall({ records, sessionID, @@ -57,29 +56,9 @@ describe("SessionDiagnostics.observeToolCall", () => { }) records = [...records, observed.record] } - - const third = loop(records[2]!.metadata) - expect(third.inputRepeatCount).toBe(3) - expect(third.reminders).toHaveLength(1) - expect(third.reminders?.[0]).toMatchObject({ - type: "input_repeat", - status: "pending", - count: 3, - }) - - const fourth = SessionDiagnostics.observeToolCall({ - records, - sessionID, - parentID, - tool: "webfetch", - input, - agent: "build", - modelID, - providerID, - }) - - expect(loop(fourth.record.metadata).inputRepeatCount).toBe(4) - expect(loop(fourth.record.metadata).reminders ?? []).toHaveLength(0) + for (const record of records) { + expect(record.metadata.diagnostics?.loop?.reminders ?? []).toHaveLength(0) + } }) test("does not count the same input across different user blocks", () => { @@ -144,31 +123,163 @@ describe("SessionDiagnostics.observeToolCall", () => { }) describe("SessionDiagnostics.observeToolError", () => { - test("normalizes equivalent error messages into one error reminder", () => { - let records: SessionDiagnostics.ToolErrorRecord[] = [] - + test("normalizes equivalent error messages so same_input fires once across error variants", () => { + const input = { url: "https://example.com/a" } + const records: SessionDiagnostics.ToolErrorRecord[] = [] for (const error of [ - "GitHub inline review failed: position 12 is outside diff", - "GitHub inline review failed: position 18 is outside diff", - "GitHub inline review failed: position 44 is outside diff", + new Error("Request failed: 504 (id 12345)"), + new Error("Request failed: 504 (id 67890)"), + new Error("Request failed: 504 (id abcdef)"), ]) { const observed = SessionDiagnostics.observeToolError({ records, sessionID, parentID, - tool: "github", + tool: "webfetch", + inputHash: SessionDiagnostics.normalizeInput(input).hash, + targetHash: SessionDiagnostics.hash("url:" + SessionDiagnostics.hash("https://example.com/a")), + originalInput: input, error, }) - records = [...records, observed.record] + records.push(observed.record) } + const fired = records.flatMap((r) => r.metadata.diagnostics?.loop?.loopRecoverFiredFor ?? []) + expect(fired.filter((k) => k.startsWith("input:"))).toHaveLength(1) + }) +}) + +const targetHashFor = (url: string) => SessionDiagnostics.hash("url:" + SessionDiagnostics.hash(url)) +const inputHashFor = (input: unknown) => SessionDiagnostics.normalizeInput(input).hash - const third = loop(records[2]!.metadata) - expect(third.errorRepeatCount).toBe(3) - expect(third.reminders?.[0]).toMatchObject({ - type: "error_repeat", - status: "pending", - count: 3, +describe("SessionDiagnostics.observeToolError v1 firing", () => { + test("fires recover for both same_input and same_target on the third failure", () => { + const input = { url: "https://example.com/a" } + const records: SessionDiagnostics.ToolErrorRecord[] = [] + for (let i = 0; i < 3; i++) { + const observed = SessionDiagnostics.observeToolError({ + records, + sessionID, + parentID, + tool: "webfetch", + inputHash: inputHashFor(input), + targetHash: targetHashFor("https://example.com/a"), + originalInput: input, + error: new Error("404"), + }) + records.push(observed.record) + } + const fired = records.flatMap((r) => r.metadata.diagnostics?.loop?.loopRecoverFiredFor ?? []) + expect(fired.filter((k) => k.startsWith("input:"))).toHaveLength(1) + expect(fired.filter((k) => k.startsWith("target:"))).toHaveLength(1) + }) + + test("persists raw lastInput and string lastError on every record", () => { + const input = { url: "https://example.com/a" } + const observed = SessionDiagnostics.observeToolError({ + records: [], + sessionID, + parentID, + tool: "webfetch", + inputHash: inputHashFor(input), + targetHash: targetHashFor("https://example.com/a"), + originalInput: input, + error: new Error("404 Not Found"), }) + expect(observed.record.lastInput).toEqual(input) + expect(typeof observed.record.lastError).toBe("string") + expect(observed.record.lastError as string).toContain("404") + }) + + test("does not fire same_target when targetHash absent", () => { + const records: SessionDiagnostics.ToolErrorRecord[] = [] + for (let i = 0; i < 3; i++) { + const observed = SessionDiagnostics.observeToolError({ + records, + sessionID, + parentID, + tool: "mystery", + inputHash: SessionDiagnostics.hash("x"), + targetHash: undefined, + originalInput: { foo: "bar" }, + error: new Error("boom"), + }) + records.push(observed.record) + } + const fired = records.flatMap((r) => r.metadata.diagnostics?.loop?.loopRecoverFiredFor ?? []) + expect(fired.some((k) => k.startsWith("target:"))).toBe(false) + expect(fired.filter((k) => k.startsWith("input:"))).toHaveLength(1) + }) + + test("labels input vs target reminders correctly via Reminder.type", () => { + const input = { url: "https://example.com/a" } + const records: SessionDiagnostics.ToolErrorRecord[] = [] + for (let i = 0; i < 3; i++) { + const observed = SessionDiagnostics.observeToolError({ + records, + sessionID, + parentID, + tool: "webfetch", + inputHash: inputHashFor(input), + targetHash: targetHashFor("https://example.com/a"), + originalInput: input, + error: new Error("404"), + }) + records.push(observed.record) + } + const allReminders = records.flatMap((r) => r.metadata.diagnostics?.loop?.reminders ?? []) + const inputReminder = allReminders.find((r) => r.key.startsWith("input:")) + const targetReminder = allReminders.find((r) => r.key.startsWith("target:")) + expect(inputReminder?.type).toBe("input_repeat") + expect(targetReminder?.type).toBe("target_repeat") + }) + + test("recomputes inputHash from originalInput when in-flight metadata missing", () => { + const original = { url: "https://example.com/a" } + const records: SessionDiagnostics.ToolErrorRecord[] = [] + for (let i = 0; i < 3; i++) { + const observed = SessionDiagnostics.observeToolError({ + records, + sessionID, + parentID, + tool: "webfetch", + inputHash: undefined, + targetHash: undefined, + originalInput: original, + error: new Error("404"), + }) + records.push(observed.record) + } + const fired = records.flatMap((r) => r.metadata.diagnostics?.loop?.loopRecoverFiredFor ?? []) + expect(fired.filter((k) => k.startsWith("input:"))).toHaveLength(1) + }) + + test("recomputes targetHash from originalInput when in-flight metadata missing", () => { + // Both hashes missing → recovery path. originalInput has a recognized url field, so + // target tracking must NOT silently degrade to input-only on the recovery path. + const original = { url: "https://example.com/a", q: "x" } + const records: SessionDiagnostics.ToolErrorRecord[] = [] + for (let i = 0; i < 3; i++) { + // Vary a non-target field so input hashes differ but target stays stable; this is the + // exact scenario that's silently broken when targetHash recovery is missing. + const varied = { ...original, q: `q-${i}` } + const observed = SessionDiagnostics.observeToolError({ + records, + sessionID, + parentID, + tool: "webfetch", + inputHash: undefined, + targetHash: undefined, + originalInput: varied, + error: new Error("404"), + }) + records.push(observed.record) + } + const fired = records.flatMap((r) => r.metadata.diagnostics?.loop?.loopRecoverFiredFor ?? []) + expect(fired.filter((k) => k.startsWith("target:"))).toHaveLength(1) + // And the persisted record carries the recovered targetHash for future iterations. + const last = records[records.length - 1] + expect(typeof last?.targetHash).toBe("string") + expect(last?.targetHash).not.toBe("") }) }) @@ -189,10 +300,10 @@ describe("SessionDiagnostics metadata helpers", () => { test("summarizes known targets without storing readable values", () => { const summary = SessionDiagnostics.targetSummary("webfetch", { url: "https://example.com/private?token=secret-token&query=visible", - }) + }).summary const command = SessionDiagnostics.targetSummary("bash", { - command: "curl -H 'Authorization: Bearer short-token' https://internal.example", - }) + command: "curl -H 'Authorization: Bearer ' https://internal.example", + }).summary expect(summary).toMatch(/^url:[a-f0-9]{16}$/) expect(summary).not.toContain("example.com") @@ -204,16 +315,164 @@ describe("SessionDiagnostics metadata helpers", () => { expect(command).not.toContain("internal") }) + test("normalizes filePath / filepath into the same path hash across file tools (target accumulation is still tool-scoped)", () => { + const a = SessionDiagnostics.targetSummary("read", { filePath: "/tmp/a.txt" }) + const b = SessionDiagnostics.targetSummary("edit", { filePath: "/tmp/a.txt", oldString: "x", newString: "y" }) + const c = SessionDiagnostics.targetSummary("write", { filepath: "/tmp/a.txt", content: "..." }) + expect(a.isFallback).toBe(false) + expect(b.isFallback).toBe(false) + expect(c.isFallback).toBe(false) + expect(a.summary).toMatch(/^path:[a-f0-9]{16}$/) + expect(a.summary).toBe(b.summary) + expect(a.summary).toBe(c.summary) + }) + test("summarizes unknown inputs without storing readable payloads", () => { const summary = SessionDiagnostics.targetSummary("custom", { prompt: "sensitive internal request", token: "secret-token", - }) + }).summary expect(summary).toMatch(/^custom:input:[a-f0-9]{16}$/) expect(summary).not.toContain("sensitive") expect(summary).not.toContain("secret-token") }) + + test("blank/whitespace target fields fall back instead of poisoning a stable hash", () => { + // Empty string and whitespace-only would otherwise hash to a stable "url:..." token and + // make malformed inputs accumulate as same_target. Should be treated as "no target" and + // fall through to the generic input fallback. + const blankUrl = SessionDiagnostics.targetSummary("webfetch", { url: "" }) + const wsPath = SessionDiagnostics.targetSummary("read", { filePath: " " }) + expect(blankUrl.isFallback).toBe(true) + expect(wsPath.isFallback).toBe(true) + expect(blankUrl.summary).not.toMatch(/^url:/) + expect(wsPath.summary).not.toMatch(/^path:/) + }) +}) + +describe("SessionDiagnostics v1 schema", () => { + test("LoopAction enum is observe|block|stop only", () => { + const all: SessionDiagnostics.LoopAction[] = ["observe", "block", "stop"] + expect(all).toHaveLength(3) + }) +}) + +describe("SessionDiagnostics.truncateForRenderer", () => { + test("returns short strings unchanged", () => { + expect(SessionDiagnostics.truncateForRenderer("hello")).toBe("hello") + }) + test("serializes objects via JSON.stringify before truncation", () => { + expect(SessionDiagnostics.truncateForRenderer({ url: "https://x.com" })).toBe('{"url":"https://x.com"}') + }) + test("truncates long strings with ellipsis at codepoint boundary", () => { + const out = SessionDiagnostics.truncateForRenderer("a".repeat(2000)) + expect(out.endsWith("…")).toBe(true) + expect(Buffer.byteLength(out, "utf8")).toBeLessThanOrEqual(1024 + 4) + }) + test("does not split a percent-encoded sequence", () => { + const head = "x".repeat(1022) + const out = SessionDiagnostics.truncateForRenderer(head + "%2F" + "y".repeat(100)) + expect(out.endsWith("%")).toBe(false) + expect(/%[0-9A-Fa-f]$/.test(out.replace(/…$/, ""))).toBe(false) + }) + test("does not split a multibyte codepoint", () => { + const out = SessionDiagnostics.truncateForRenderer("a".repeat(1023) + "中文") + expect(() => Buffer.from(out, "utf8").toString("utf8")).not.toThrow() + }) + test("survives circular structures via String fallback", () => { + const o: Record = { a: 1 } + o.self = o + expect(() => SessionDiagnostics.truncateForRenderer(o)).not.toThrow() + expect(typeof SessionDiagnostics.truncateForRenderer(o)).toBe("string") + }) + test("survives BigInt via String fallback", () => { + expect(() => SessionDiagnostics.truncateForRenderer({ big: 10n })).not.toThrow() + expect(typeof SessionDiagnostics.truncateForRenderer({ big: 10n })).toBe("string") + }) +}) + +describe("SessionDiagnostics targetRepeatCount cross-tool semantics", () => { + test("cross-tool same target keeps newTarget=true (exploration, not loop)", () => { + const url = "https://example.com/a" + const first = SessionDiagnostics.observeToolCall({ + records: [], + sessionID, + parentID, + tool: "webfetch", + input: { url }, + agent: "build", + modelID, + providerID, + }) + const second = SessionDiagnostics.observeToolCall({ + records: [first.record], + sessionID, + parentID, + tool: "fetch", + input: { url }, + agent: "build", + modelID, + providerID, + }) + expect(second.record.metadata.diagnostics?.loop?.newTarget).toBe(true) + expect(second.record.metadata.diagnostics?.loop?.targetRepeatCount).toBe(1) + }) + + test("same-tool same-target accumulates targetRepeatCount", () => { + const url = "https://example.com/a" + const first = SessionDiagnostics.observeToolCall({ + records: [], + sessionID, + parentID, + tool: "webfetch", + input: { url }, + agent: "build", + modelID, + providerID, + }) + const second = SessionDiagnostics.observeToolCall({ + records: [first.record], + sessionID, + parentID, + tool: "webfetch", + input: { url }, + agent: "build", + modelID, + providerID, + }) + expect(second.record.metadata.diagnostics?.loop?.targetRepeatCount).toBe(2) + expect(second.record.metadata.diagnostics?.loop?.newTarget).toBe(false) + }) +}) + +describe("SessionDiagnostics targetHashIsFallback", () => { + test("is false on webfetch with a recognized URL", () => { + const observed = SessionDiagnostics.observeToolCall({ + records: [], + sessionID, + parentID, + tool: "webfetch", + input: { url: "https://example.com/a" }, + agent: "build", + modelID, + providerID, + }) + expect(observed.record.metadata.diagnostics?.loop?.targetHashIsFallback).toBe(false) + }) + test("is true on a tool whose input has no findTarget hit", () => { + const observed = SessionDiagnostics.observeToolCall({ + records: [], + sessionID, + parentID, + tool: "mystery", + input: { foo: "bar" }, + agent: "build", + modelID, + providerID, + }) + expect(observed.record.metadata.diagnostics?.loop?.targetHashIsFallback).toBe(true) + }) }) describe("SessionDiagnostics.consumeReminders", () => { @@ -288,4 +547,111 @@ describe("SessionDiagnostics.consumeReminders", () => { expect(again.text).toBeUndefined() expect(again.parts).toHaveLength(0) }) + + test("emits legacy copy as fallback for v0 error: prefix reminders", () => { + const part: MessageV2.ToolPart = { + id: PartID.make("prt_legacy"), + messageID: MessageID.make("msg_assistant_legacy"), + sessionID, + type: "tool", + tool: "github", + callID: "call_legacy", + state: { + status: "error", + input: { x: 1 }, + error: "504", + metadata: { + diagnostics: { + loop: { + reminders: [ + { + key: "error:msg_user:github:abc", + type: "error_repeat", + status: "pending", + count: 3, + createdAt: 1, + }, + ], + }, + }, + }, + time: { start: 1, end: 2 }, + }, + } + const messages: MessageV2.WithParts[] = [ + { + info: { + id: MessageID.make("msg_assistant_legacy"), + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID, + providerID, + parentID, + time: { created: 1 }, + }, + parts: [part], + }, + ] + const result = SessionDiagnostics.consumeReminders({ messages, parentID, now: 10 }) + expect(result.text).toContain("class of tool error") + }) + + test("target reminder text describes same-target failures, not same-error class", () => { + const part: MessageV2.ToolPart = { + id: PartID.make("prt_target"), + messageID: MessageID.make("msg_assistant_t"), + sessionID, + type: "tool", + tool: "webfetch", + callID: "call_t", + state: { + status: "error", + input: { url: "https://example.com/a" }, + error: "404", + metadata: { + diagnostics: { + loop: { + reminders: [ + { + key: "target:webfetch:abc", + type: "target_repeat", + status: "pending", + count: 3, + createdAt: 1, + }, + ], + }, + }, + }, + time: { start: 1, end: 2 }, + }, + } + const messages: MessageV2.WithParts[] = [ + { + info: { + id: MessageID.make("msg_assistant_t"), + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID, + providerID, + parentID, + time: { created: 1 }, + }, + parts: [part], + }, + ] + const result = SessionDiagnostics.consumeReminders({ messages, parentID, now: 10 }) + expect(result.text).toContain("failed against the same target") + expect(result.text).not.toContain("class of tool error") + }) }) diff --git a/packages/opencode/test/session/export.test.ts b/packages/opencode/test/session/export.test.ts index 62eb4a1dc..332284506 100644 --- a/packages/opencode/test/session/export.test.ts +++ b/packages/opencode/test/session/export.test.ts @@ -180,6 +180,228 @@ describe("Export.session", () => { }) }) +describe("Export.deriveSnapshotDiagnostics", () => { + const sessionID = SessionID.make("ses_diag") + const messageID = MessageID.make("msg_assistant") + const userID = MessageID.make("msg_user") + + function blockToolPart(): MessageV2.ToolPart { + return { + id: PartID.make("prt_loop_block"), + messageID, + sessionID, + type: "tool", + tool: "webfetch", + callID: "call_block", + state: { + status: "error", + input: { url: "https://example.com/missing.md" }, + error: "blocked by PawWork: 5 same target failures", + metadata: { + diagnostics: { + loop: { + loopAction: "block", + loopType: "target", + loopCompletedFailures: 5, + loopSigKey: "target:webfetch:abc", + }, + }, + }, + time: { start: 1, end: 2 }, + }, + } + } + + function makeTree(): Export.Tree { + return { + info: { + id: sessionID, + title: "loop test", + time: { created: 1 }, + version: "0", + } as unknown as Export.Tree["info"], + had_cloud_share: false, + diffs: [], + messages: [ + { + info: { + id: messageID, + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "test-model", + providerID: "test", + parentID: userID, + time: { created: 1 }, + } as MessageV2.Assistant, + parts: [blockToolPart()], + }, + ], + children: [], + } + } + + test("emits loop.last for the latest synthetic block tool part", () => { + const tree = makeTree() + const result = Export.deriveSnapshotDiagnostics(tree) + expect(result.loop?.last).toBeDefined() + expect(result.loop?.last?.type).toBe("same_target") + expect(result.loop?.last?.action).toBe("block") + expect(result.loop?.last?.tool).toBe("webfetch") + expect(result.loop?.last?.completedFailures).toBe(5) + expect(result.loop?.last?.parentID).toBe(userID) + }) + + test("returns empty when no block tool part exists", () => { + const tree: Export.Tree = { ...makeTree(), messages: [] } + expect(Export.deriveSnapshotDiagnostics(tree)).toEqual({}) + }) + + test("picks the block with the latest timestamp across child trees, not DFS order", () => { + const rootInfo = makeAssistantInfo() + const childInfo = makeAssistantInfo() + const olderInChild = blockToolPartAt(childInfo.id, 50, "older-child") + const newerInRoot = blockToolPartAt(rootInfo.id, 100, "newer-root") + const tree: Export.Tree = { + ...makeTree(), + messages: [ + { + info: rootInfo, + parts: [newerInRoot], + }, + ], + children: [ + { + ...makeTree(), + messages: [ + { + info: childInfo, + parts: [olderInChild], + }, + ], + }, + ], + } + const result = Export.deriveSnapshotDiagnostics(tree) + expect(result.loop?.last?.completedFailures).toBe(100) + }) + + test("picks stop over earlier block when stop is the terminal action", () => { + const info = makeAssistantInfo() + const block = blockToolPartAt(info.id, 100, "early-block") + const stop = stopToolPartAt(info.id, 200, "final-stop") + const tree: Export.Tree = { + ...makeTree(), + messages: [ + { + info, + parts: [block, stop], + }, + ], + children: [], + } + const result = Export.deriveSnapshotDiagnostics(tree) + expect(result.loop?.last?.action).toBe("stop") + expect(result.loop?.last?.completedFailures).toBe(200) + }) + + test("includes stop tool part even when no block exists in tree", () => { + const info = makeAssistantInfo() + const stop = stopToolPartAt(info.id, 50, "lone-stop") + const tree: Export.Tree = { + ...makeTree(), + messages: [ + { + info, + parts: [stop], + }, + ], + children: [], + } + const result = Export.deriveSnapshotDiagnostics(tree) + expect(result.loop?.last?.action).toBe("stop") + }) +}) + +let assistantSeq = 0 +function makeAssistantInfo(): MessageV2.Assistant { + const sessionID = SessionID.make("ses_diag") + assistantSeq += 1 + const messageID = MessageID.make(`msg_assistant_seq_${assistantSeq}`) + return { + id: messageID, + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "test-model", + providerID: "test", + parentID: MessageID.make("msg_user"), + time: { created: 1 }, + } as MessageV2.Assistant +} + +function blockToolPartAt(messageID: MessageID, end: number, tag: string): MessageV2.ToolPart { + return { + id: PartID.make("prt_block_" + tag), + messageID, + sessionID: SessionID.make("ses_diag"), + type: "tool", + tool: "webfetch", + callID: "call_" + tag, + state: { + status: "error", + input: { url: "https://x.com/" + tag }, + error: "blocked by PawWork", + metadata: { + diagnostics: { + loop: { + loopAction: "block", + loopType: "target", + loopCompletedFailures: end, + loopSigKey: "target:webfetch:" + tag, + }, + }, + }, + time: { start: 1, end }, + }, + } +} + +function stopToolPartAt(messageID: MessageID, end: number, tag: string): MessageV2.ToolPart { + return { + id: PartID.make("prt_stop_" + tag), + messageID, + sessionID: SessionID.make("ses_diag"), + type: "tool", + tool: "webfetch", + callID: "call_stop_" + tag, + state: { + status: "error", + input: { url: "https://x.com/" + tag }, + error: "halted by PawWork", + metadata: { + diagnostics: { + loop: { + loopAction: "stop", + loopType: "target", + loopCompletedFailures: end, + loopSigKey: "target:webfetch:" + tag, + }, + }, + }, + time: { start: 1, end }, + }, + } +} + describe("redactPart", () => { test("replaces data: url in a file part with empty string and adds redacted_binary metadata", () => { const ctx = { count: { omitted: 0 } } diff --git a/packages/opencode/test/session/loop-gate.test.ts b/packages/opencode/test/session/loop-gate.test.ts new file mode 100644 index 000000000..0aac3a91e --- /dev/null +++ b/packages/opencode/test/session/loop-gate.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from "bun:test" +import { MessageID, SessionID } from "../../src/session/schema" +import { SessionDiagnostics } from "../../src/session/diagnostics" + +const sessionID = SessionID.make("ses_test") +const parentID = MessageID.make("msg_user") + +const inputHashFor = (input: unknown) => SessionDiagnostics.normalizeInput(input).hash +const targetHashFor = (url: string) => SessionDiagnostics.hash("url:" + SessionDiagnostics.hash(url)) + +const failingErrorRecord = ( + url: string, + recoverFiredFor: string[] = [], +): SessionDiagnostics.ToolErrorRecord => ({ + sessionID, + parentID, + tool: "webfetch", + inputHash: inputHashFor({ url }), + targetHash: targetHashFor(url), + errorFingerprint: SessionDiagnostics.errorFingerprint(new Error("404")), + lastInput: { url }, + lastError: "404", + metadata: { + diagnostics: { + loop: { + errorFingerprint: SessionDiagnostics.errorFingerprint(new Error("404")), + loopRecoverFiredFor: recoverFiredFor.length ? recoverFiredFor : undefined, + loopLastInput: { url }, + loopLastError: "404", + }, + }, + }, +}) + +describe("SessionDiagnostics.deriveParentLoopState", () => { + test("populates SignatureState.lastInput/lastError from latest matching record", () => { + const url = "https://x.com/a" + // Two distinct records; the second one's lastError must win — proves "latest", not + // "first match in array order". + const records = [failingErrorRecord(url), { ...failingErrorRecord(url), lastError: "500" }] + const state = SessionDiagnostics.deriveParentLoopState({ + errorRecords: records, + syntheticBlockSigKeys: [], + parentID, + }) + const sigKey = `target:webfetch:${targetHashFor(url)}` + expect(state.signatures[sigKey]?.lastInput).toEqual({ url }) + expect(state.signatures[sigKey]?.lastError).toBe("500") + }) + + test("counts non-block records as completedFailures", () => { + const url = "https://x.com/a" + const records = [failingErrorRecord(url), failingErrorRecord(url)] + const state = SessionDiagnostics.deriveParentLoopState({ + errorRecords: records, + syntheticBlockSigKeys: [], + parentID, + }) + const sigKey = `input:webfetch:${inputHashFor({ url })}` + expect(state.signatures[sigKey]?.completedFailures).toBe(2) + }) + + test("autoResumeSpent flips when any synthetic block sigKey is present", () => { + const state = SessionDiagnostics.deriveParentLoopState({ + errorRecords: [], + syntheticBlockSigKeys: ["input:webfetch:abc"], + parentID, + }) + expect(state.autoResumeSpent).toBe(true) + }) + + test("blockEmitted set on the matched signature", () => { + const url = "https://x.com/a" + const sigKey = `target:webfetch:${targetHashFor(url)}` + const state = SessionDiagnostics.deriveParentLoopState({ + errorRecords: [failingErrorRecord(url)], + syntheticBlockSigKeys: [sigKey], + parentID, + }) + expect(state.signatures[sigKey]?.blockEmitted).toBe(true) + }) +}) + +describe("SessionDiagnostics.queryGateAction", () => { + test("observe when no failures", () => { + const state = SessionDiagnostics.deriveParentLoopState({ + errorRecords: [], + syntheticBlockSigKeys: [], + parentID, + }) + const decision = SessionDiagnostics.queryGateAction({ + parentLoopState: state, + tool: "webfetch", + inputHash: inputHashFor({ url: "https://x.com/a" }), + targetHash: targetHashFor("https://x.com/a"), + }) + expect(decision.action).toBe("observe") + }) + + test("observe when failures < 5", () => { + const url = "https://x.com/a" + const sigKey = `target:webfetch:${targetHashFor(url)}` + const records = [ + failingErrorRecord(url), + failingErrorRecord(url), + failingErrorRecord(url, [sigKey]), + failingErrorRecord(url), + ] + const state = SessionDiagnostics.deriveParentLoopState({ + errorRecords: records, + syntheticBlockSigKeys: [], + parentID, + }) + const decision = SessionDiagnostics.queryGateAction({ + parentLoopState: state, + tool: "webfetch", + inputHash: inputHashFor({ url }), + targetHash: targetHashFor(url), + }) + expect(decision.action).toBe("observe") + }) + + test("block at >= 5 with target recover emitted and budget unspent", () => { + const url = "https://x.com/a" + const sigKey = `target:webfetch:${targetHashFor(url)}` + const records = [ + failingErrorRecord(url), + failingErrorRecord(url), + failingErrorRecord(url, [sigKey]), + failingErrorRecord(url), + failingErrorRecord(url), + ] + const state = SessionDiagnostics.deriveParentLoopState({ + errorRecords: records, + syntheticBlockSigKeys: [], + parentID, + }) + const decision = SessionDiagnostics.queryGateAction({ + parentLoopState: state, + tool: "webfetch", + inputHash: inputHashFor({ url }), + targetHash: targetHashFor(url), + }) + expect(decision.action).toBe("block") + if (decision.action === "block") { + expect(decision.kind).toBe("target") + expect(decision.completedFailures).toBe(5) + } + }) + + test("stop when autoResumeSpent", () => { + const url = "https://x.com/a" + const sigKey = `target:webfetch:${targetHashFor(url)}` + const records = [ + failingErrorRecord(url), + failingErrorRecord(url), + failingErrorRecord(url, [sigKey]), + failingErrorRecord(url), + failingErrorRecord(url), + ] + const state = SessionDiagnostics.deriveParentLoopState({ + errorRecords: records, + syntheticBlockSigKeys: ["input:other:zzz"], + parentID, + }) + const decision = SessionDiagnostics.queryGateAction({ + parentLoopState: state, + tool: "webfetch", + inputHash: inputHashFor({ url }), + targetHash: targetHashFor(url), + }) + expect(decision.action).toBe("stop") + }) + + test("stop when blockEmitted on this same signature", () => { + const url = "https://x.com/a" + const sigKey = `target:webfetch:${targetHashFor(url)}` + const records = [ + failingErrorRecord(url), + failingErrorRecord(url), + failingErrorRecord(url, [sigKey]), + failingErrorRecord(url), + failingErrorRecord(url), + ] + const state = SessionDiagnostics.deriveParentLoopState({ + errorRecords: records, + syntheticBlockSigKeys: [sigKey], + parentID, + }) + const decision = SessionDiagnostics.queryGateAction({ + parentLoopState: state, + tool: "webfetch", + inputHash: inputHashFor({ url }), + targetHash: targetHashFor(url), + }) + expect(decision.action).toBe("stop") + }) + + test("only same_input matches when targetHash absent", () => { + const inputHash = inputHashFor({ k: "v" }) + const sigKey = `input:webfetch:${inputHash}` + const make = (recoverFiredFor: string[] = []): SessionDiagnostics.ToolErrorRecord => ({ + ...failingErrorRecord("u", recoverFiredFor), + inputHash, + targetHash: undefined, + lastInput: { k: "v" }, + }) + const records: SessionDiagnostics.ToolErrorRecord[] = [ + make(), + make(), + make([sigKey]), + make(), + make(), + ] + const state = SessionDiagnostics.deriveParentLoopState({ + errorRecords: records, + syntheticBlockSigKeys: [], + parentID, + }) + const decision = SessionDiagnostics.queryGateAction({ + parentLoopState: state, + tool: "webfetch", + inputHash, + targetHash: undefined, + }) + expect(decision.action).toBe("block") + if (decision.action === "block") expect(decision.kind).toBe("input") + }) +}) diff --git a/packages/opencode/test/session/loop-renderer.test.ts b/packages/opencode/test/session/loop-renderer.test.ts new file mode 100644 index 000000000..48a246271 --- /dev/null +++ b/packages/opencode/test/session/loop-renderer.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, test } from "bun:test" +import { LoopRenderer } from "../../src/session/loop-renderer" +import { SessionDiagnostics } from "../../src/session/diagnostics" + +const makeState = ( + overrides: Partial = {}, +): SessionDiagnostics.SignatureState => ({ + kind: "input", + completedFailures: 5, + recoverEmitted: true, + blockEmitted: true, + ...overrides, +}) + +describe("LoopRenderer.render", () => { + test("webfetch same_input shows the URL", () => { + const text = LoopRenderer.render({ + tool: "webfetch", + state: makeState({ kind: "input", lastInput: { url: "https://example.com/a" }, lastError: "boom" }), + }) + expect(text).toContain("https://example.com/a") + expect(text).toContain("5") + }) + + test("webfetch same_target shows URL and raw error", () => { + const text = LoopRenderer.render({ + tool: "webfetch", + state: makeState({ kind: "target", lastInput: { url: "https://example.com/a" }, lastError: "404 Not Found" }), + }) + expect(text).toContain("https://example.com/a") + expect(text).toContain("404 Not Found") + }) + + test("webfetch strips query string and fragment from rendered URL", () => { + const text = LoopRenderer.render({ + tool: "webfetch", + state: makeState({ + kind: "target", + lastInput: { url: "https://example.com/repo/file.md?token=secret-abc&q=visible#h1" }, + lastError: "404", + }), + }) + expect(text).toContain("https://example.com/repo/file.md") + expect(text).not.toContain("token=") + expect(text).not.toContain("secret-abc") + expect(text).not.toContain("#h1") + }) + + test("missing lastInput uses degraded template (no placeholder)", () => { + const text = LoopRenderer.render({ + tool: "webfetch", + state: makeState({ kind: "target", lastInput: undefined, lastError: "e" }), + }) + expect(text).not.toContain("") + expect(text).toContain("5") + }) + + test("missing lastError omits error line in target template (zh)", () => { + const text = LoopRenderer.render({ + tool: "webfetch", + state: makeState({ kind: "target", lastInput: { url: "https://x.com/a" }, lastError: undefined }), + locale: "zh-Hans", + }) + expect(text).toContain("https://x.com/a") + expect(text).not.toContain("错误:") + }) + + test("missing lastError omits error line in target template (en)", () => { + const text = LoopRenderer.render({ + tool: "webfetch", + state: makeState({ kind: "target", lastInput: { url: "https://x.com/a" }, lastError: undefined }), + locale: "en", + }) + expect(text).toContain("https://x.com/a") + expect(text).not.toContain("Error:") + }) + + test("non-webfetch same_target shows tool name and error but never raw input", () => { + const text = LoopRenderer.render({ + tool: "bash", + state: makeState({ + kind: "target", + lastInput: { command: "curl -H 'Authorization: Bearer secret' https://internal/x" }, + lastError: "EACCES", + }), + }) + expect(text).toContain("bash") + expect(text).toContain("EACCES") + expect(text).not.toContain("curl") + expect(text).not.toContain("Bearer") + expect(text).not.toContain("secret") + expect(text).not.toContain("internal") + }) + + test("non-webfetch same_input shows tool name and error", () => { + const text = LoopRenderer.render({ + tool: "grep", + state: makeState({ kind: "input", lastInput: { pattern: "x" }, lastError: "permission denied" }), + }) + expect(text).toContain("grep") + expect(text).toContain("permission denied") + }) + + test("accepts a bare URL string as lastInput", () => { + const text = LoopRenderer.render({ + tool: "webfetch", + state: makeState({ kind: "target", lastInput: "https://x.com/a", lastError: "404" }), + }) + expect(text).toContain("https://x.com/a") + }) + + test("scrubs Unix file paths in error text", () => { + const text = LoopRenderer.render({ + tool: "read", + state: makeState({ + kind: "input", + lastInput: { filePath: "/tmp/x" }, + lastError: "open /Users/alice/private.txt: permission denied", + }), + }) + expect(text).not.toContain("/Users/alice/private.txt") + expect(text).toContain("permission denied") + }) + + test("scrubs Windows file paths in error text", () => { + const text = LoopRenderer.render({ + tool: "read", + state: makeState({ + kind: "input", + lastInput: { filePath: "C:/x" }, + lastError: "ENOENT C:\\Users\\bob\\secrets.json", + }), + }) + expect(text).not.toContain("C:\\Users\\bob\\secrets.json") + expect(text).toContain("ENOENT") + }) + + test("scrubs relative paths (./foo, ../foo)", () => { + const dot = LoopRenderer.render({ + tool: "read", + state: makeState({ + kind: "input", + lastInput: { filePath: "./x" }, + lastError: "failed to open ./config/dev.json", + }), + }) + expect(dot).not.toContain("config/dev.json") + expect(dot).toContain("failed to open") + + const dotdot = LoopRenderer.render({ + tool: "read", + state: makeState({ + kind: "input", + lastInput: { filePath: "../x" }, + lastError: "ENOENT ../Secrets/token.txt", + }), + }) + expect(dotdot).not.toContain("Secrets") + expect(dotdot).not.toContain("token.txt") + expect(dotdot).toContain("ENOENT") + }) + + test("scrubs paths containing spaces (Unix)", () => { + const text = LoopRenderer.render({ + tool: "read", + state: makeState({ + kind: "input", + lastInput: { filePath: "/tmp/x" }, + lastError: "open /Users/alice/My Documents/secret.txt: permission denied", + }), + }) + expect(text).not.toContain("My Documents") + expect(text).not.toContain("secret.txt") + expect(text).toContain("permission denied") + }) + + test("scrubs paths containing spaces (Windows)", () => { + const text = LoopRenderer.render({ + tool: "read", + state: makeState({ + kind: "input", + lastInput: { filePath: "C:/x" }, + lastError: "ENOENT C:\\Users\\bob\\My Secrets\\token.txt", + }), + }) + expect(text).not.toContain("My Secrets") + expect(text).not.toContain("token.txt") + expect(text).toContain("ENOENT") + }) + + test("scrubs forward-slash Windows file paths (C:/...) in error text", () => { + const text = LoopRenderer.render({ + tool: "read", + state: makeState({ + kind: "input", + lastInput: { filePath: "C:/x" }, + lastError: "ENOENT C:/Users/bob/secrets.json: no such file", + }), + }) + expect(text).not.toContain("C:/Users/bob/secrets.json") + expect(text).toContain("ENOENT") + }) + + test("scrubs quoted strings in error text", () => { + const text = LoopRenderer.render({ + tool: "bash", + state: makeState({ + kind: "target", + lastInput: { command: "ls" }, + lastError: 'parsing failed: unexpected token "secret-payload" at position 12', + }), + }) + expect(text).not.toContain("secret-payload") + expect(text).toContain("parsing failed") + }) + + test("scrubs Bearer/Basic auth headers and api key fragments in error text", () => { + const text = LoopRenderer.render({ + tool: "webfetch", + state: makeState({ + kind: "target", + lastInput: { url: "https://api.example.com/x" }, + lastError: "401 Unauthorized: Authorization: Bearer abc123-def456 invalid", + }), + }) + expect(text).not.toContain("abc123-def456") + expect(text).toContain("401") + }) + + test("scrubs Unix paths even when preceded by punctuation, not just whitespace", () => { + const text = LoopRenderer.render({ + tool: "read", + state: makeState({ + kind: "input", + lastInput: { filePath: "/tmp/x" }, + lastError: "Error:at /home/alice/secret.txt expected token", + }), + }) + expect(text).not.toContain("/home/alice/secret.txt") + expect(text).toContain("Error") + }) + + test("scrubs URL even with uppercase scheme (HTTPS://)", () => { + const text = LoopRenderer.render({ + tool: "webfetch", + state: makeState({ + kind: "target", + lastInput: { url: "https://example.com/x" }, + lastError: "Got error from HTTPS://api.example.com/repo?token=secret-xyz", + }), + }) + expect(text).not.toContain("token=") + expect(text).not.toContain("secret-xyz") + }) + + test("scrubs query/fragment from URLs embedded in error text", () => { + const text = LoopRenderer.render({ + tool: "webfetch", + state: makeState({ + kind: "target", + lastInput: { url: "https://example.com/x" }, + lastError: "Request to https://api.example.com/repo?token=secret-abc&q=visible#sect failed: 401", + }), + }) + expect(text).toContain("https://api.example.com/repo") + expect(text).toContain("401") + expect(text).not.toContain("token=") + expect(text).not.toContain("secret-abc") + expect(text).not.toContain("#sect") + }) + + test("truncates very long URLs", () => { + const longURL = "https://x.com/" + "a".repeat(2000) + const text = LoopRenderer.render({ + tool: "webfetch", + state: makeState({ kind: "target", lastInput: { url: longURL }, lastError: "e" }), + }) + expect(text.length).toBeLessThan(2200) + expect(text).toContain("…") + }) +}) + +describe("LoopRenderer.render locale routing", () => { + const baseTarget = { kind: "target" as const, lastInput: { url: "https://x.com/a" }, lastError: "404" } + + test("default (no locale) renders English", () => { + const text = LoopRenderer.render({ tool: "webfetch", state: makeState(baseTarget) }) + expect(text).toContain("failed to fetch the same target") + expect(text).not.toContain("我重复抓取") + }) + + test("locale 'en' renders English", () => { + const text = LoopRenderer.render({ tool: "webfetch", state: makeState(baseTarget), locale: "en" }) + expect(text).toContain("failed to fetch the same target") + }) + + test("locale 'zh-Hans' renders Chinese", () => { + const text = LoopRenderer.render({ tool: "webfetch", state: makeState(baseTarget), locale: "zh-Hans" }) + expect(text).toContain("我重复抓取同一个目标") + expect(text).not.toContain("failed to fetch") + }) + + test("locale 'zh' (bare prefix) renders Chinese", () => { + const text = LoopRenderer.render({ tool: "webfetch", state: makeState(baseTarget), locale: "zh" }) + expect(text).toContain("我重复抓取同一个目标") + }) + + test("locale 'fr' (unsupported) falls back to English", () => { + const text = LoopRenderer.render({ tool: "webfetch", state: makeState(baseTarget), locale: "fr" }) + expect(text).toContain("failed to fetch the same target") + expect(text).not.toContain("我重复抓取") + }) + + test("non-webfetch generic-target template honors locale", () => { + const baseGeneric = makeState({ kind: "target", lastInput: { command: "ls" }, lastError: "EACCES" }) + const en = LoopRenderer.render({ tool: "bash", state: baseGeneric, locale: "en" }) + const zh = LoopRenderer.render({ tool: "bash", state: baseGeneric, locale: "zh-Hans" }) + expect(en).toContain("failed against the same target") + expect(en).toContain("EACCES") + expect(zh).toContain("我重复在同一个目标上失败了") + expect(zh).toContain("EACCES") + }) + + test("input-template (non-target) honors locale", () => { + const inputState = makeState({ kind: "input", lastInput: { pattern: "x" }, lastError: "permission denied" }) + const en = LoopRenderer.render({ tool: "grep", state: inputState, locale: "en" }) + const zh = LoopRenderer.render({ tool: "grep", state: inputState, locale: "zh-Hans" }) + expect(en).toContain("I called grep") + expect(zh).toContain("我重复调用了 grep") + }) +}) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index b52ab31cd..cf60c2142 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -497,49 +497,107 @@ it.live("loop continues when finish is tool-calls", () => ), ) -it.live("loop injects diagnostics reminder after repeated tool input", () => +it.live("loop gate blocks then stops after autoResume budget on repeated tool errors", () => provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({ - title: "Diagnostics", + title: "Loop gate", permission: [{ permission: "*", pattern: "*", action: "allow" }], }) - const file = path.join(dir, "probe.txt") - yield* Effect.promise(() => Bun.write(file, "probe")) - yield* prompt.prompt({ sessionID: session.id, agent: "build", noReply: true, - parts: [{ type: "text", text: "repeat tool" }], + parts: [{ type: "text", text: "read missing" }], }) - const input = { pattern: "**/*.txt" } - yield* llm.tool("glob", input) - yield* llm.tool("glob", input) - yield* llm.tool("glob", input) + + // Always read the same nonexistent path so input + (fallback target) hashes are stable. + const filePath = path.join(dir, "loop-gate-nonexistent.txt") + const input = { filePath } + for (let i = 0; i < 7; i++) yield* llm.tool("read", input) yield* llm.text("done") const result = yield* prompt.loop({ sessionID: session.id }) expect(result.info.role).toBe("assistant") - expect(yield* llm.calls).toBe(4) - const requests = yield* llm.inputs - expect(JSON.stringify(requests.at(-1))).toContain("Detected that you have repeated the same tool input 3 times") - - const msgs = yield* MessageV2.filterCompactedEffect(session.id) - const tools = msgs.flatMap((msg) => - msg.parts.filter((part): part is CompletedToolPart => part.type === "tool" && part.state.status === "completed"), + const allMessages = yield* MessageV2.filterCompactedEffect(session.id) + const allParts = allMessages.flatMap((m) => m.parts) + const errorParts = allParts.filter( + (part): part is ErrorToolPart => part.type === "tool" && part.state.status === "error", ) - expect(tools).toHaveLength(3) - expect(tools[2]?.state.metadata.diagnostics.loop.inputRepeatCount).toBe(3) - expect(tools[2]?.state.metadata.diagnostics.loop.reminders?.[0]).toMatchObject({ - type: "input_repeat", - status: "injected", - count: 3, + const completedParts = allParts.filter( + (part): part is CompletedToolPart => part.type === "tool" && part.state.status === "completed", + ) + expect(completedParts).toHaveLength(0) + + const blockParts = errorParts.filter((p) => p.state.metadata?.diagnostics?.loop?.loopAction === "block") + const stopParts = errorParts.filter((p) => p.state.metadata?.diagnostics?.loop?.loopAction === "stop") + expect(blockParts).toHaveLength(1) + expect(stopParts).toHaveLength(1) + + const blockMeta = blockParts[0]!.state.metadata!.diagnostics!.loop! + expect(blockMeta.loopType).toBe("target") + expect(blockMeta.loopCompletedFailures).toBe(5) + expect(blockParts[0]!.state.error).toContain("blocked by PawWork") + + const stopMeta = stopParts[0]!.state.metadata!.diagnostics!.loop! + expect(stopMeta.loopType).toBe("target") + expect(stopParts[0]!.state.error).toContain("halted by PawWork") + + const stopIdx = allParts.indexOf(stopParts[0]!) + const trailing = allParts.slice(stopIdx + 1) + // After the synthetic stop, the turn must be terminal: exactly one trailing text part + // (the rendered stop summary) and no trailing tool parts. Just looking at the FIRST + // text part would still pass if a queued model text leaked through after stop. + const trailingTexts = trailing.filter((p): p is MessageV2.TextPart => p.type === "text") + expect(trailingTexts).toHaveLength(1) + expect(trailingTexts[0]!.synthetic).toBe(true) + // Default locale (test fixture sends no user-message locale) → English template. + expect(trailingTexts[0]!.text).toContain("stopped") + expect(trailing.filter((p) => p.type === "tool")).toHaveLength(0) + + // Recovery fires per-sigKey; in this scenario read+filePath produces both `input:` and + // `target:` candidates, so loopRecoverFiredFor can carry either prefix. Accept both — + // narrowing to one was a false-failure risk without catching real regressions. + const recoverFired = errorParts.some((p) => + (p.state.metadata?.diagnostics?.loop?.loopRecoverFiredFor ?? []).some( + (k: string) => k.startsWith("input:") || k.startsWith("target:"), + ), + ) + expect(recoverFired).toBe(true) + + const requests = yield* llm.inputs + // Extract user/system message text fields rather than stringifying the whole request + // shape, so the assertion does not break when ai-sdk request schema gets unrelated + // fields (timestamps, ids, model parameters). + const flattenedText = requests.flatMap((r) => { + const msgs = (r as { messages?: unknown[] }).messages ?? [] + return msgs.flatMap((m) => { + const content = (m as { content?: unknown }).content + if (typeof content === "string") return [content] + if (Array.isArray(content)) { + return content.flatMap((c) => { + if (typeof c === "string") return [c] + if (c && typeof c === "object" && "text" in c) return [String((c as { text: unknown }).text)] + return [] + }) + } + return [] + }) }) + // Either same-input or same-target reminder is acceptable in this scenario — the + // read+filePath path produces both signatures and either flavor is a valid recovery + // signal. Narrowing to one would fail spuriously without any behavior regression. + expect( + flattenedText.some( + (t) => + t.includes("repeated the same tool input 3 times") || + t.includes("failed against the same target multiple times"), + ), + ).toBe(true) }), { git: true, config: providerCfg }, ),