{"version":3,"file":"todo-stale-error-classification.test.d.ts","sourceRoot":"","sources":["../../src/core/todo-stale-error-classification.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG","sourcesContent":["/**\n * Tests: stale todo_update revision handling after durable recovery.\n *\n * A stale todo_update revision is now recovered INTERNALLY and\n * DETERMINISTICALLY: the engine reads the current state, rebases the\n * non-conflicting intent exactly once, and applies it. A stale revision is\n * NEVER a run-terminal condition and NEVER requires the model to manually\n * issue todo_read merely to satisfy an internal concurrency protocol.\n *\n * The prior false-positive behavior (REQUIRE_READ + REPEATED_TODO_UPDATE_LOOP\n * run termination) is gone; this file documents the corrected behavior.\n */\n\nimport type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from \"@apholdings/jensen-agent-core\";\nimport { agentLoop } from \"@apholdings/jensen-agent-core\";\nimport {\n\ttype AssistantMessage,\n\ttype AssistantMessageEvent,\n\tEventStream,\n\ttype Model,\n\ttype UserMessage,\n} from \"@apholdings/jensen-ai\";\nimport { describe, expect, it } from \"vitest\";\nimport { TodoEngine } from \"./todo/index.js\";\nimport { TodoLoopGuard } from \"./tools/todo-loop-guard.js\";\nimport { createTodoReadTool } from \"./tools/todo-read.js\";\nimport { createTodoUpdateTool } from \"./tools/todo-update.js\";\nimport { createTodoWriteTool, type TodoItem } from \"./tools/todo-write.js\";\n\n// ---------------------------------------------------------------------------\n// Agent-loop helpers (same pattern as packages/agent/test/agent-loop.test.ts)\n// ---------------------------------------------------------------------------\n\nclass MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {\n\tconstructor() {\n\t\tsuper(\n\t\t\t(event) => event.type === \"done\" || event.type === \"error\",\n\t\t\t(event) => {\n\t\t\t\tif (event.type === \"done\") return event.message;\n\t\t\t\tif (event.type === \"error\") return event.error;\n\t\t\t\tthrow new Error(\"Unexpected event type\");\n\t\t\t},\n\t\t);\n\t}\n}\n\nfunction createUsage() {\n\treturn {\n\t\tinput: 0,\n\t\toutput: 0,\n\t\tcacheRead: 0,\n\t\tcacheWrite: 0,\n\t\ttotalTokens: 0,\n\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t};\n}\n\nfunction createModel(): Model<\"openai-responses\"> {\n\treturn {\n\t\tid: \"mock\",\n\t\tname: \"mock\",\n\t\tapi: \"openai-responses\",\n\t\tprovider: \"openai\",\n\t\tbaseUrl: \"https://example.invalid\",\n\t\treasoning: false,\n\t\tinput: [\"text\"],\n\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcontextWindow: 8192,\n\t\tmaxTokens: 2048,\n\t};\n}\n\nfunction createAssistantMessage(\n\tcontent: AssistantMessage[\"content\"],\n\tstopReason: AssistantMessage[\"stopReason\"] = \"stop\",\n): AssistantMessage {\n\treturn {\n\t\trole: \"assistant\",\n\t\tcontent,\n\t\tapi: \"openai-responses\",\n\t\tprovider: \"openai\",\n\t\tmodel: \"mock\",\n\t\tusage: createUsage(),\n\t\tstopReason,\n\t\ttimestamp: Date.now(),\n\t};\n}\n\nfunction createUserMessage(text: string): UserMessage {\n\treturn { role: \"user\", content: text, timestamp: Date.now() };\n}\n\nfunction identityConverter(messages: AgentMessage[]): import(\"@apholdings/jensen-ai\").Message[] {\n\treturn messages.filter(\n\t\t(m) => m.role === \"user\" || m.role === \"assistant\" || m.role === \"toolResult\",\n\t) as import(\"@apholdings/jensen-ai\").Message[];\n}\n\n// ---------------------------------------------------------------------------\n// Shared test setup — one engine shared across read/update tools\n// ---------------------------------------------------------------------------\n\nfunction createTodoTools() {\n\tlet persisted: TodoItem[] = [];\n\tlet revision = 0;\n\tconst guard = new TodoLoopGuard();\n\tconst engine = new TodoEngine(\"test-session\");\n\n\tconst writeTool = createTodoWriteTool(\n\t\t() => persisted,\n\t\t(next) => {\n\t\t\tpersisted = next;\n\t\t\trevision++;\n\t\t},\n\t\tguard,\n\t\t() => revision,\n\t);\n\n\tconst readTool = createTodoReadTool(\n\t\t() => persisted,\n\t\t() => revision,\n\t\tundefined,\n\t\tengine,\n\t);\n\n\tconst updateTool = createTodoUpdateTool(\n\t\t() => persisted,\n\t\t(next) => {\n\t\t\tpersisted = next;\n\t\t\trevision++;\n\t\t},\n\t\t() => revision,\n\t\tguard,\n\t\tundefined,\n\t\tundefined,\n\t\tengine,\n\t);\n\n\treturn {\n\t\twriteTool,\n\t\treadTool,\n\t\tupdateTool,\n\t\tengine,\n\t\tgetPersisted: () => persisted,\n\t\tgetRevision: () => revision,\n\t};\n}\n\n// ---------------------------------------------------------------------------\n// C01–C08: corrected stale-recovery behavior through the real agent loop\n// ---------------------------------------------------------------------------\n\ndescribe(\"stale todo_update auto-recovery (real agent loop)\", () => {\n\tit(\"C01: stale non-conflicting revision auto-rebases and updates with isError=false\", async () => {\n\t\tconst { writeTool, readTool, updateTool, getPersisted } = createTodoTools();\n\t\tawait makeTodos(writeTool, \"Task A\");\n\t\tconst firstTodoId = getPersisted()[0].id!;\n\t\tawait readTool.execute(\"r1\", {}); // snapshot revision 2\n\n\t\tconst context: AgentContext = {\n\t\t\tsystemPrompt: \"You are helpful.\",\n\t\t\tmessages: [],\n\t\t\ttools: [updateTool as unknown as AgentTool],\n\t\t};\n\t\tconst config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter };\n\n\t\tlet callIndex = 0;\n\t\tconst streamFn = () => {\n\t\t\tconst s = new MockAssistantStream();\n\t\t\tqueueMicrotask(() => {\n\t\t\t\tif (callIndex === 0) {\n\t\t\t\t\ts.push({\n\t\t\t\t\t\ttype: \"done\",\n\t\t\t\t\t\treason: \"toolUse\",\n\t\t\t\t\t\tmessage: createAssistantMessage(\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttype: \"toolCall\",\n\t\t\t\t\t\t\t\t\tid: \"stale-1\",\n\t\t\t\t\t\t\t\t\tname: \"todo_update\",\n\t\t\t\t\t\t\t\t\targuments: {\n\t\t\t\t\t\t\t\t\t\tupdates: [{ id: firstTodoId, status: \"completed\" }],\n\t\t\t\t\t\t\t\t\t\texpectedRevision: 0, // far-stale\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"toolUse\",\n\t\t\t\t\t\t),\n\t\t\t\t\t});\n\t\t\t\t} else {\n\t\t\t\t\ts.push({\n\t\t\t\t\t\ttype: \"done\",\n\t\t\t\t\t\treason: \"stop\",\n\t\t\t\t\t\tmessage: createAssistantMessage([{ type: \"text\", text: \"Done\" }]),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tcallIndex++;\n\t\t\t});\n\t\t\treturn s;\n\t\t};\n\n\t\tconst events: AgentEvent[] = [];\n\t\tconst stream = agentLoop([createUserMessage(\"go\")], context, config, undefined, streamFn);\n\t\tfor await (const event of stream) {\n\t\t\tevents.push(event);\n\t\t}\n\n\t\tconst toolEnd = events.find(\n\t\t\t(e): e is Extract<AgentEvent, { type: \"tool_execution_end\" }> => e.type === \"tool_execution_end\",\n\t\t);\n\t\texpect(toolEnd).toBeDefined();\n\t\texpect(toolEnd!.isError).toBe(false);\n\n\t\tconst text = (toolEnd!.result.content as Array<{ type: string; text?: string }>)\n\t\t\t.filter((c) => c.type === \"text\")\n\t\t\t.map((c) => c.text ?? \"\")\n\t\t\t.join(\"\\n\");\n\t\texpect(text).not.toContain(\"TODO_READ_REQUIRED\");\n\t\texpect(text).toContain(\"rebase\");\n\n\t\t// C01: item was actually completed\n\t\texpect(getPersisted()[0].status).toBe(\"completed\");\n\t});\n\n\tit(\"C02: a conflicting stale update is typed and nonfatal; run continues\", async () => {\n\t\tconst { writeTool, readTool, updateTool, getPersisted } = createTodoTools();\n\t\tawait makeTodos(writeTool, \"Task B\");\n\t\tconst firstTodoId = getPersisted()[0].id!;\n\t\tawait readTool.execute(\"r1\", {}); // base snapshot at revision 2 (content \"Task B\")\n\n\t\t// The model legitimately changes content, advancing the store to revision 3.\n\t\tconst first = await updateTool.execute(\"v2\", {\n\t\t\tupdates: [{ id: firstTodoId, content: \"Version 2\" }],\n\t\t\texpectedRevision: 2,\n\t\t});\n\t\tif (first.content[0].type !== \"text\") throw new Error(\"text expected\");\n\n\t\t// A later stale update based on the OLD snapshot (rev 2) targets content that\n\t\t// was concurrently changed (now \"Version 2\"). This must be a typed, nonfatal\n\t\t// conflict — never a throw, never run termination.\n\t\tconst result = await updateTool.execute(\"conflict-1\", {\n\t\t\tupdates: [{ id: firstTodoId, content: \"Model X\" }],\n\t\t\texpectedRevision: 2,\n\t\t});\n\t\tconst text = result.content[0];\n\t\tif (text.type !== \"text\") throw new Error(\"expected text\");\n\t\tconst details = result.details as Record<string, unknown> | undefined;\n\t\texpect(text.text.includes(\"TODO_REBASE_CONFLICT\") || details?.errorCode === \"TODO_REBASE_CONFLICT\").toBe(true);\n\t});\n\n\tit(\"C03: valid current-revision update succeeds with isError=false\", async () => {\n\t\tconst { writeTool, readTool, updateTool, getPersisted, getRevision } = createTodoTools();\n\t\tawait makeTodos(writeTool, \"Task C\");\n\t\tconst firstTodoId = getPersisted()[0].id!;\n\t\tconst currentRev = getRevision();\n\t\tawait readTool.execute(\"r1\", {});\n\n\t\tconst context: AgentContext = {\n\t\t\tsystemPrompt: \"You are helpful.\",\n\t\t\tmessages: [],\n\t\t\ttools: [updateTool as unknown as AgentTool],\n\t\t};\n\t\tconst config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter };\n\n\t\tlet callIndex = 0;\n\t\tconst streamFn = () => {\n\t\t\tconst s = new MockAssistantStream();\n\t\t\tqueueMicrotask(() => {\n\t\t\t\tif (callIndex === 0) {\n\t\t\t\t\ts.push({\n\t\t\t\t\t\ttype: \"done\",\n\t\t\t\t\t\treason: \"toolUse\",\n\t\t\t\t\t\tmessage: createAssistantMessage(\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttype: \"toolCall\",\n\t\t\t\t\t\t\t\t\tid: \"valid-1\",\n\t\t\t\t\t\t\t\t\tname: \"todo_update\",\n\t\t\t\t\t\t\t\t\targuments: {\n\t\t\t\t\t\t\t\t\t\tupdates: [{ id: firstTodoId, status: \"completed\" }],\n\t\t\t\t\t\t\t\t\t\texpectedRevision: currentRev,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"toolUse\",\n\t\t\t\t\t\t),\n\t\t\t\t\t});\n\t\t\t\t} else {\n\t\t\t\t\ts.push({\n\t\t\t\t\t\ttype: \"done\",\n\t\t\t\t\t\treason: \"stop\",\n\t\t\t\t\t\tmessage: createAssistantMessage([{ type: \"text\", text: \"Done\" }]),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tcallIndex++;\n\t\t\t});\n\t\t\treturn s;\n\t\t};\n\n\t\tconst events: AgentEvent[] = [];\n\t\tconst stream = agentLoop([createUserMessage(\"go\")], context, config, undefined, streamFn);\n\t\tfor await (const event of stream) {\n\t\t\tevents.push(event);\n\t\t}\n\n\t\tconst toolEnd = events.find(\n\t\t\t(e): e is Extract<AgentEvent, { type: \"tool_execution_end\" }> => e.type === \"tool_execution_end\",\n\t\t);\n\t\texpect(toolEnd).toBeDefined();\n\t\texpect(toolEnd!.isError).toBe(false);\n\t\texpect(getPersisted()[0].status).toBe(\"completed\");\n\t});\n\n\tit(\"C04: stale recovery produces a non-error model-facing toolResult\", async () => {\n\t\tconst { writeTool, readTool, updateTool, getPersisted } = createTodoTools();\n\t\tawait makeTodos(writeTool, \"Task D\");\n\t\tconst firstTodoId = getPersisted()[0].id!;\n\t\tawait readTool.execute(\"r1\", {});\n\n\t\tconst context: AgentContext = {\n\t\t\tsystemPrompt: \"You are helpful.\",\n\t\t\tmessages: [],\n\t\t\ttools: [updateTool as unknown as AgentTool],\n\t\t};\n\t\tconst config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter };\n\n\t\tlet callIndex = 0;\n\t\tconst streamFn = () => {\n\t\t\tconst s = new MockAssistantStream();\n\t\t\tqueueMicrotask(() => {\n\t\t\t\tif (callIndex === 0) {\n\t\t\t\t\ts.push({\n\t\t\t\t\t\ttype: \"done\",\n\t\t\t\t\t\treason: \"toolUse\",\n\t\t\t\t\t\tmessage: createAssistantMessage(\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttype: \"toolCall\",\n\t\t\t\t\t\t\t\t\tid: \"stale-2\",\n\t\t\t\t\t\t\t\t\tname: \"todo_update\",\n\t\t\t\t\t\t\t\t\targuments: {\n\t\t\t\t\t\t\t\t\t\tupdates: [{ id: firstTodoId, status: \"completed\" }],\n\t\t\t\t\t\t\t\t\t\texpectedRevision: 0,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"toolUse\",\n\t\t\t\t\t\t),\n\t\t\t\t\t});\n\t\t\t\t} else {\n\t\t\t\t\ts.push({\n\t\t\t\t\t\ttype: \"done\",\n\t\t\t\t\t\treason: \"stop\",\n\t\t\t\t\t\tmessage: createAssistantMessage([{ type: \"text\", text: \"Done\" }]),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tcallIndex++;\n\t\t\t});\n\t\t\treturn s;\n\t\t};\n\n\t\tconst stream = agentLoop([createUserMessage(\"go\")], context, config, undefined, streamFn);\n\t\tconst messages = await stream.result();\n\t\tconst toolResults = messages.filter((m) => m.role === \"toolResult\");\n\t\texpect(toolResults.length).toBe(1);\n\t\tconst texts = toolResults[0].content.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\");\n\t\texpect(texts[0].text.length).toBeGreaterThan(20);\n\t\texpect(texts[0].text).not.toContain(\"TODO_READ_REQUIRED\");\n\t\texpect(texts[0].text).not.toContain(\"REPEATED_TODO_UPDATE_LOOP\");\n\t});\n\n\tit(\"C05: agent-loop completes normally across stale recovery\", async () => {\n\t\tconst { writeTool, readTool, updateTool, getPersisted } = createTodoTools();\n\t\tawait makeTodos(writeTool, \"Task E\");\n\t\tconst firstTodoId = getPersisted()[0].id!;\n\t\tawait readTool.execute(\"r1\", {});\n\n\t\tconst context: AgentContext = {\n\t\t\tsystemPrompt: \"You are helpful.\",\n\t\t\tmessages: [],\n\t\t\ttools: [updateTool as unknown as AgentTool],\n\t\t};\n\t\tconst config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter };\n\n\t\tlet callIndex = 0;\n\t\tconst streamFn = () => {\n\t\t\tconst s = new MockAssistantStream();\n\t\t\tqueueMicrotask(() => {\n\t\t\t\tif (callIndex === 0) {\n\t\t\t\t\ts.push({\n\t\t\t\t\t\ttype: \"done\",\n\t\t\t\t\t\treason: \"toolUse\",\n\t\t\t\t\t\tmessage: createAssistantMessage(\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttype: \"toolCall\",\n\t\t\t\t\t\t\t\t\tid: \"stale-3\",\n\t\t\t\t\t\t\t\t\tname: \"todo_update\",\n\t\t\t\t\t\t\t\t\targuments: {\n\t\t\t\t\t\t\t\t\t\tupdates: [{ id: firstTodoId, status: \"completed\" }],\n\t\t\t\t\t\t\t\t\t\texpectedRevision: 0,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"toolUse\",\n\t\t\t\t\t\t),\n\t\t\t\t\t});\n\t\t\t\t} else {\n\t\t\t\t\ts.push({\n\t\t\t\t\t\ttype: \"done\",\n\t\t\t\t\t\treason: \"stop\",\n\t\t\t\t\t\tmessage: createAssistantMessage([{ type: \"text\", text: \"Done\" }]),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tcallIndex++;\n\t\t\t});\n\t\t\treturn s;\n\t\t};\n\n\t\tconst events: AgentEvent[] = [];\n\t\tconst stream = agentLoop([createUserMessage(\"go\")], context, config, undefined, streamFn);\n\t\tfor await (const event of stream) {\n\t\t\tevents.push(event);\n\t\t}\n\t\tconst eventTypes = events.map((e) => e.type);\n\t\texpect(eventTypes).toContain(\"agent_start\");\n\t\texpect(eventTypes).toContain(\"tool_execution_end\");\n\t\texpect(eventTypes).toContain(\"agent_end\");\n\t});\n});\n\n// C10: direct execute contract — stale revision auto-recovers, no TODO_READ_REQUIRED\ndescribe(\"direct todo_update.execute contract\", () => {\n\tit(\"C10: stale revision returns automatic-rebase success details (not TODO_READ_REQUIRED)\", async () => {\n\t\tconst { writeTool, readTool, updateTool, getPersisted, getRevision } = createTodoTools();\n\t\tawait makeTodos(writeTool, \"Task F\");\n\t\tconst firstTodoId = getPersisted()[0].id!;\n\t\tconst revBefore = getRevision();\n\t\tawait readTool.execute(\"r1\", {});\n\n\t\tconst result = await updateTool.execute(\"stale-dir\", {\n\t\t\tupdates: [{ id: firstTodoId, status: \"completed\" }],\n\t\t\texpectedRevision: revBefore - 1,\n\t\t});\n\t\tconst text = result.content[0];\n\t\tif (text.type !== \"text\") throw new Error(\"expected text\");\n\t\texpect(text.text).not.toContain(\"TODO_READ_REQUIRED\");\n\t\texpect(text.text).toContain(\"rebase\");\n\t\t// mutated once via automatic rebase\n\t\texpect(getPersisted()[0].status).toBe(\"completed\");\n\t});\n});\n\nfunction makeTodos(writeTool: ReturnType<typeof createTodoTools>[\"writeTool\"], content: string): Promise<string> {\n\treturn writeTool\n\t\t.execute(\"w-seed\", {\n\t\t\ttodos: [{ content, activeForm: `Working on ${content}`, status: \"pending\" }],\n\t\t})\n\t\t.then(() => \"\"); // id captured after write via getPersisted\n}\n"]}