diff --git a/packages/opencode/src/session/diagnostics.ts b/packages/opencode/src/session/diagnostics.ts new file mode 100644 index 000000000..f3ffb02e9 --- /dev/null +++ b/packages/opencode/src/session/diagnostics.ts @@ -0,0 +1,317 @@ +import { createHash } from "node:crypto" +import type { MessageV2 } from "./message-v2" +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 ReminderStatus = "pending" | "injected" + + export type Reminder = { + key: string + type: ReminderType + status: ReminderStatus + count: number + createdAt: number + injectedAt?: number + } + + export type LoopMetadata = { + inputHash?: string + inputRepeatCount?: number + targetSummary?: string + targetHash?: string + targetRepeatCount?: number + newTarget?: boolean + errorFingerprint?: string + errorRepeatCount?: number + reminders?: Reminder[] + modelID?: string + providerID?: string + agent?: string + sessionID?: SessionID + parentSessionID?: SessionID + isSubagent?: boolean + parentID?: MessageID + toolFamily?: string + truncated?: boolean + } + + export type Metadata = { + diagnostics?: { + loop?: LoopMetadata + } + } + + export type ToolCallRecord = { + sessionID: SessionID + parentID: MessageID + tool: string + inputHash: string + targetHash: string + metadata: Metadata + } + + export type ToolErrorRecord = { + sessionID: SessionID + parentID: MessageID + tool: string + errorFingerprint: string + metadata: Metadata + } + + export function hash(value: string) { + return createHash("sha256").update(value).digest("hex").slice(0, 16) + } + + 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) { + const target = findTarget(input) + if (!target) return `${tool}:input:${normalizeInput(input).hash}` + return `${target.kind}:${hash(target.value.trim())}` + } + + 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 ?? "") + .toLowerCase() + .replace(/https?:\/\/\S+/g, "") + .replace(/['"`][^'"`]*['"`]/g, "") + .replace(/[A-Za-z]:\\[^\s]+/g, "") + .replace(/\/[^\s,;)]+/g, "") + .replace(/\b[0-9a-f]{7,}\b/g, "") + .replace(/\b\d+\b/g, "") + .replace(/\s+/g, " ") + .trim() + return hash(normalized) + } + + export function observeToolCall(input: { + records: ToolCallRecord[] + sessionID: SessionID + parentID: MessageID + parentSessionID?: SessionID + tool: string + input: unknown + agent: string + modelID: string + providerID: string + }) { + const normalized = normalizeInput(input.input) + const summary = targetSummary(input.tool, input.input) + 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(), + }, + ] + : [] + + const record: ToolCallRecord = { + sessionID: input.sessionID, + parentID: input.parentID, + tool: input.tool, + inputHash: normalized.hash, + targetHash, + metadata: { + diagnostics: { + loop: { + inputHash: normalized.hash, + inputRepeatCount, + targetSummary: summary, + targetHash, + targetRepeatCount, + newTarget: targetRepeatCount === 1, + reminders, + modelID: input.modelID, + providerID: input.providerID, + agent: input.agent, + sessionID: input.sessionID, + parentSessionID: input.parentSessionID, + isSubagent: input.parentSessionID !== undefined, + parentID: input.parentID, + toolFamily: toolFamily(input.tool), + }, + }, + }, + } + return { record } + } + + export function observeToolError(input: { + records: ToolErrorRecord[] + sessionID: SessionID + parentID: MessageID + tool: string + 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(), + }, + ] + : [] + const record: ToolErrorRecord = { + sessionID: input.sessionID, + parentID: input.parentID, + tool: input.tool, + errorFingerprint: fingerprint, + metadata: { + diagnostics: { + loop: { + errorFingerprint: fingerprint, + errorRepeatCount, + reminders, + }, + }, + }, + } + return { record } + } + + export function mergeMetadata | undefined>(current: T, update: Metadata): NonNullable & Metadata { + if (!current?.diagnostics && !update.diagnostics) { + return { ...(current ?? {}), ...update } as NonNullable & Metadata + } + + return { + ...(current ?? {}), + ...update, + diagnostics: { + ...(current?.diagnostics ?? {}), + ...(update.diagnostics ?? {}), + loop: { + ...(current?.diagnostics?.loop ?? {}), + ...(update.diagnostics?.loop ?? {}), + }, + }, + } as NonNullable & Metadata + } + + export function consumeReminders(input: { + messages: MessageV2.WithParts[] + parentID: MessageID + now?: number + }): { text?: string; parts: MessageV2.ToolPart[] } { + const now = input.now ?? Date.now() + const pending: Reminder[] = [] + const parts: MessageV2.ToolPart[] = [] + + for (const message of input.messages) { + if (message.info.role !== "assistant" || message.info.parentID !== input.parentID) continue + for (const part of message.parts) { + if (part.type !== "tool") continue + const metadata = "metadata" in part.state ? part.state.metadata : undefined + const reminders = metadata?.diagnostics?.loop?.reminders + if (!Array.isArray(reminders)) continue + let changed = false + const nextReminders = reminders.map((reminder: Reminder) => { + if (reminder.status !== "pending") return reminder + changed = true + pending.push(reminder) + return { ...reminder, status: "injected" as const, injectedAt: now } + }) + if (!changed) continue + parts.push({ + ...part, + state: { + ...part.state, + metadata: mergeMetadata(metadata, { + diagnostics: { + loop: { + reminders: nextReminders, + }, + }, + }), + } as MessageV2.ToolPart["state"], + }) + } + } + + 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) { + 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) { + 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.", + ) + } + lines.push("") + return { text: lines.join("\n"), parts } + } + + function normalizeValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalizeValue) + if (!value || typeof value !== "object") return typeof value === "string" ? value.trim() : value + + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => !NON_SEMANTIC_KEYS.has(key.toLowerCase())) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, normalizeValue(item)]), + ) + } + + function findTarget(input: unknown): { kind: string; value: string } | undefined { + if (!input || typeof input !== "object") return undefined + const record = input as Record + for (const key of ["url", "href"]) { + if (typeof record[key] === "string") return { kind: "url", value: record[key] } + } + for (const key of ["query", "search", "pattern", "path", "command", "cmd"]) { + if (typeof record[key] === "string") return { kind: key, value: record[key] } + } + return undefined + } + + function toolFamily(tool: string) { + const [family] = tool.split(/[.:_/]/) + return family || tool + } +} diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b206c6c41..7ebb8c72d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,6 +1,5 @@ import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" -import { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { Config } from "@/config" import { Permission } from "@/permission" @@ -15,13 +14,13 @@ import type { SessionID } from "./schema" import { SessionRetry } from "./retry" import { SessionStatus } from "./status" import { SessionSummary } from "./summary" +import { SessionDiagnostics } from "./diagnostics" import type { Provider } from "@/provider" import { Question } from "@/question" import { errorMessage } from "@/util/error" import { Log } from "@/util/log" import { isRecord } from "@/util/record" -const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) export type Result = "compact" | "stop" | "continue" @@ -84,7 +83,6 @@ export const layer: Layer.Layer< | Config.Service | Bus.Service | Snapshot.Service - | Agent.Service | LLM.Service | Permission.Service | Plugin.Service @@ -97,9 +95,7 @@ export const layer: Layer.Layer< const config = yield* Config.Service const bus = yield* Bus.Service const snapshot = yield* Snapshot.Service - const agents = yield* Agent.Service const llm = yield* LLM.Service - const permission = yield* Permission.Service const plugin = yield* Plugin.Service const summary = yield* SessionSummary.Service const scope = yield* Scope.Scope @@ -168,6 +164,58 @@ export const layer: Layer.Layer< return part }) + const toolStateMetadata = (part: MessageV2.ToolPart) => + "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : undefined + + const toolDiagnostics = (part: MessageV2.ToolPart): SessionDiagnostics.Metadata["diagnostics"] | undefined => { + const diagnostics = toolStateMetadata(part)?.diagnostics + if (!isRecord(diagnostics)) return undefined + return diagnostics as SessionDiagnostics.Metadata["diagnostics"] + } + + const loopRecords = (parentID: MessageV2.Assistant["parentID"]) => { + if (!parentID) return [] + return Array.from(MessageV2.stream(ctx.sessionID)).flatMap((message) => { + if (message.info.role !== "assistant" || message.info.parentID !== parentID) return [] + return message.parts.flatMap((part) => { + if (part.type !== "tool") return [] + const loop = toolDiagnostics(part)?.loop + if (!loop?.inputHash || !loop.targetHash) return [] + return [ + { + sessionID: ctx.sessionID, + parentID, + tool: part.tool, + inputHash: loop.inputHash, + targetHash: loop.targetHash, + metadata: { diagnostics: { loop } }, + } satisfies SessionDiagnostics.ToolCallRecord, + ] + }) + }) + } + + const errorRecords = (parentID: MessageV2.Assistant["parentID"]) => { + if (!parentID) return [] + return Array.from(MessageV2.stream(ctx.sessionID)).flatMap((message) => { + if (message.info.role !== "assistant" || message.info.parentID !== parentID) return [] + return message.parts.flatMap((part) => { + if (part.type !== "tool") return [] + const loop = toolDiagnostics(part)?.loop + if (!loop?.errorFingerprint) return [] + return [ + { + sessionID: ctx.sessionID, + parentID, + tool: part.tool, + errorFingerprint: loop.errorFingerprint, + metadata: { diagnostics: { loop } }, + } satisfies SessionDiagnostics.ToolErrorRecord, + ] + }) + }) + } + const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* ( toolCallID: string, output: { @@ -179,13 +227,14 @@ export const layer: Layer.Layer< ) { const match = yield* readToolCall(toolCallID) if (!match || match.part.state.status !== "running") return + const diagnostics = toolDiagnostics(match.part) yield* session.updatePart({ ...match.part, state: { status: "completed", input: match.part.state.input, output: output.output, - metadata: output.metadata, + metadata: diagnostics ? SessionDiagnostics.mergeMetadata(output.metadata, { diagnostics }) : output.metadata, title: output.title, time: { start: match.part.state.time.start, end: Date.now() }, attachments: output.attachments, @@ -197,12 +246,22 @@ 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 + 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, + error, + }).record.metadata.diagnostics + : toolDiagnostics(match.part) yield* session.updatePart({ ...match.part, state: { status: "error", input: match.part.state.input, error: errorMessage(error), + metadata: SessionDiagnostics.mergeMetadata(toolStateMetadata(match.part), { diagnostics }), time: { start: match.part.state.time.start, end: Date.now() }, }, }) @@ -288,7 +347,7 @@ export const layer: Layer.Layer< if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } - yield* updateToolCall(value.toolCallId, (match) => ({ + const running = yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, state: { @@ -301,31 +360,25 @@ export const layer: Layer.Layer< ? { ...value.providerMetadata, providerExecuted: true } : value.providerMetadata, })) - - const parts = MessageV2.parts(ctx.assistantMessage.id) - const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) - - if ( - recentParts.length !== DOOM_LOOP_THRESHOLD || - !recentParts.every( - (part) => - part.type === "tool" && - part.tool === value.toolName && - part.state.status !== "pending" && - JSON.stringify(part.state.input) === JSON.stringify(value.input), - ) - ) { - return - } - - const agent = yield* agents.get(ctx.assistantMessage.agent) - yield* permission.ask({ - permission: "doom_loop", - patterns: [value.toolName], - sessionID: ctx.assistantMessage.sessionID, - metadata: { tool: value.toolName, input: value.input }, - always: [value.toolName], - ruleset: agent.permission, + if (!running || !ctx.assistantMessage.parentID) return + const info = yield* session.get(ctx.sessionID) + const observed = SessionDiagnostics.observeToolCall({ + records: loopRecords(ctx.assistantMessage.parentID), + sessionID: ctx.sessionID, + parentSessionID: info.parentID, + parentID: ctx.assistantMessage.parentID, + tool: value.toolName, + input: value.input, + agent: ctx.assistantMessage.agent, + modelID: ctx.model.id, + providerID: ctx.model.providerID, + }) + yield* session.updatePart({ + ...running, + state: { + ...running.state, + metadata: SessionDiagnostics.mergeMetadata(toolStateMetadata(running), observed.record.metadata), + }, }) return } @@ -605,7 +658,6 @@ export const defaultLayer = Layer.suspend(() => layer.pipe( Layer.provide(Session.defaultLayer), Layer.provide(Snapshot.defaultLayer), - Layer.provide(Agent.defaultLayer), Layer.provide(LLM.defaultLayer), Layer.provide(Permission.defaultLayer), Layer.provide(Plugin.defaultLayer), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0758ecac8..1a3944229 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -35,6 +35,7 @@ import { OFFICE_EXTS, pathBasename, pathSuffix } from "@opencode-ai/util/file-ex import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" import { SessionProcessor } from "./processor" +import { SessionDiagnostics } from "./diagnostics" import { Tool } from "@/tool/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" @@ -883,7 +884,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the time: { ...part.state.time, end: Date.now() }, input: part.state.input, title: "", - metadata: { output, description: "" }, + metadata: { ...part.state.metadata, output, description: "" }, output, } yield* sessions.updatePart(part) @@ -901,7 +902,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the Effect.sync(() => { output += chunk if (part.state.status === "running") { - part.state.metadata = { output, description: "" } + part.state.metadata = { ...part.state.metadata, output, description: "" } void run.fork(sessions.updatePart(part)) } }), @@ -1395,7 +1396,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the time: { ...runningTool.state.time, end: Date.now() }, input: runningTool.state.input, title: "", - metadata: { output, description: "" }, + metadata: { ...runningTool.state.metadata, output, description: "" }, output, }, } @@ -1510,6 +1511,22 @@ NOTE: At any point in time through this workflow you should feel free to ask the const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps msgs = yield* insertReminders({ messages: msgs, agent, session }) + const diagnostics = SessionDiagnostics.consumeReminders({ messages: msgs, parentID: lastUser.id }) + if (diagnostics.text) { + const userMessage = msgs.findLast((msg) => msg.info.role === "user" && msg.info.id === lastUser.id) + userMessage?.parts.push({ + id: PartID.ascending(), + messageID: lastUser.id, + sessionID, + type: "text", + text: diagnostics.text, + synthetic: true, + }) + } + yield* Effect.forEach(diagnostics.parts, (part) => sessions.updatePart(part), { + concurrency: "unbounded", + discard: true, + }) const msg: MessageV2.Assistant = { id: MessageID.ascending(), diff --git a/packages/opencode/test/session/diagnostics.test.ts b/packages/opencode/test/session/diagnostics.test.ts new file mode 100644 index 000000000..24b1de3e8 --- /dev/null +++ b/packages/opencode/test/session/diagnostics.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, test } from "bun:test" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import type { MessageV2 } from "../../src/session/message-v2" +import { SessionDiagnostics } from "../../src/session/diagnostics" +import { ModelID, ProviderID } from "../../src/provider/schema" + +const sessionID = SessionID.make("ses_diagnostics") +const parentID = MessageID.make("msg_user") +const modelID = ModelID.make("test-model") +const providerID = ProviderID.make("test") + +function loop(metadata: SessionDiagnostics.Metadata) { + const value = metadata.diagnostics?.loop + if (!value) throw new Error("expected loop diagnostics") + return value +} + +describe("SessionDiagnostics.normalizeInput", () => { + test("keeps stable hashes for reordered keys and non-semantic request ids", () => { + const a = SessionDiagnostics.normalizeInput({ + url: "https://example.com/article", + requestId: "one", + nested: { b: 2, a: 1 }, + }) + const b = SessionDiagnostics.normalizeInput({ + nested: { a: 1, b: 2 }, + requestId: "two", + url: "https://example.com/article", + }) + + expect(a.hash).toBe(b.hash) + }) + + test("keeps cursor as semantic input", () => { + const first = SessionDiagnostics.normalizeInput({ query: "Kimi K2.6", cursor: "page-1" }) + const second = SessionDiagnostics.normalizeInput({ query: "Kimi K2.6", cursor: "page-2" }) + + expect(first.hash).not.toBe(second.hash) + }) +}) + +describe("SessionDiagnostics.observeToolCall", () => { + test("creates one pending reminder on the third repeated input in one user block", () => { + let records: SessionDiagnostics.ToolCallRecord[] = [] + const input = { url: "https://example.com/article" } + + for (let i = 0; i < 3; i++) { + const observed = SessionDiagnostics.observeToolCall({ + records, + sessionID, + parentID, + tool: "webfetch", + input, + agent: "build", + modelID, + providerID, + }) + 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) + }) + + test("does not count the same input across different user blocks", () => { + const input = { url: "https://example.com/article" } + const records: SessionDiagnostics.ToolCallRecord[] = [ + SessionDiagnostics.observeToolCall({ + records: [], + sessionID, + parentID, + tool: "webfetch", + input, + agent: "build", + modelID, + providerID, + }).record, + SessionDiagnostics.observeToolCall({ + records: [], + sessionID, + parentID: MessageID.make("msg_other_user"), + tool: "webfetch", + input, + agent: "build", + modelID, + providerID, + }).record, + ] + + const observed = SessionDiagnostics.observeToolCall({ + records, + sessionID, + parentID, + tool: "webfetch", + input, + agent: "build", + modelID, + providerID, + }) + + expect(loop(observed.record.metadata).inputRepeatCount).toBe(2) + expect(loop(observed.record.metadata).reminders ?? []).toHaveLength(0) + }) + + test("does not create input reminders for different URLs in an exploratory block", () => { + let records: SessionDiagnostics.ToolCallRecord[] = [] + + for (let i = 0; i < 30; i++) { + const observed = SessionDiagnostics.observeToolCall({ + records, + sessionID, + parentID, + tool: "webfetch", + input: { url: `https://example.com/article-${i}` }, + agent: "build", + modelID, + providerID, + }) + records = [...records, observed.record] + } + + expect(records.flatMap((record) => loop(record.metadata).reminders ?? [])).toHaveLength(0) + }) +}) + +describe("SessionDiagnostics.observeToolError", () => { + test("normalizes equivalent error messages into one error reminder", () => { + let 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", + ]) { + const observed = SessionDiagnostics.observeToolError({ + records, + sessionID, + parentID, + tool: "github", + error, + }) + records = [...records, observed.record] + } + + 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 metadata helpers", () => { + test("merges diagnostics without losing existing tool metadata", () => { + const merged = SessionDiagnostics.mergeMetadata( + { truncated: false, outputPath: "/tmp/out" }, + { diagnostics: { loop: { inputHash: "abc", inputRepeatCount: 1 } } }, + ) + + expect(merged).toEqual({ + truncated: false, + outputPath: "/tmp/out", + diagnostics: { loop: { inputHash: "abc", inputRepeatCount: 1 } }, + }) + }) + + test("summarizes known targets without storing readable values", () => { + const summary = SessionDiagnostics.targetSummary("webfetch", { + url: "https://example.com/private?token=secret-token&query=visible", + }) + const command = SessionDiagnostics.targetSummary("bash", { + command: "curl -H 'Authorization: Bearer short-token' https://internal.example", + }) + + expect(summary).toMatch(/^url:[a-f0-9]{16}$/) + expect(summary).not.toContain("example.com") + expect(summary).not.toContain("private") + expect(summary).not.toContain("secret-token") + expect(summary).not.toContain("visible") + expect(command).toMatch(/^command:[a-f0-9]{16}$/) + expect(command).not.toContain("Bearer") + expect(command).not.toContain("internal") + }) + + test("summarizes unknown inputs without storing readable payloads", () => { + const summary = SessionDiagnostics.targetSummary("custom", { + prompt: "sensitive internal request", + token: "secret-token", + }) + + expect(summary).toMatch(/^custom:input:[a-f0-9]{16}$/) + expect(summary).not.toContain("sensitive") + expect(summary).not.toContain("secret-token") + }) +}) + +describe("SessionDiagnostics.consumeReminders", () => { + test("returns one model reminder and marks pending records injected", () => { + const part: MessageV2.ToolPart = { + id: PartID.make("prt_diag"), + messageID: MessageID.make("msg_assistant"), + sessionID, + type: "tool", + tool: "webfetch", + callID: "call_diag", + state: { + status: "completed", + input: { url: "https://example.com/article" }, + output: "ok", + title: "ok", + metadata: { + diagnostics: { + loop: { + reminders: [ + { + key: "input:msg_user:webfetch:abc", + type: "input_repeat", + status: "pending", + count: 3, + createdAt: 1, + }, + ], + }, + }, + }, + time: { start: 1, end: 2 }, + }, + } + const messages: MessageV2.WithParts[] = [ + { + info: { + id: MessageID.make("msg_assistant"), + 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("repeated the same tool input 3 times") + expect(result.parts).toHaveLength(1) + const updated = result.parts[0]?.state + expect(updated?.status).toBe("completed") + if (updated?.status !== "completed") throw new Error("expected completed state") + expect(updated.metadata.diagnostics.loop.reminders[0]).toMatchObject({ + status: "injected", + injectedAt: 10, + }) + + const again = SessionDiagnostics.consumeReminders({ + messages: [{ ...messages[0]!, parts: result.parts }], + parentID, + now: 11, + }) + expect(again.text).toBeUndefined() + expect(again.parts).toHaveLength(0) + }) +}) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 5cffbee4c..b3ae7205e 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -491,6 +491,54 @@ it.live("loop continues when finish is tool-calls", () => ), ) +it.live("loop injects diagnostics reminder after repeated tool input", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Diagnostics", + 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" }], + }) + const input = { pattern: "**/*.txt" } + yield* llm.tool("glob", input) + yield* llm.tool("glob", input) + yield* llm.tool("glob", 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"), + ) + 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, + }) + }), + { git: true, config: providerCfg }, + ), +) + it.live("glob tool keeps instance context during prompt runs", () => provideTmpdirServer( ({ dir, llm }) =>