{"version":3,"file":"todo-loop-guard.test.d.ts","sourceRoot":"","sources":["../../src/core/todo-loop-guard.test.ts"],"names":[],"mappings":"","sourcesContent":["import { describe, expect, it } from \"vitest\";\nimport { convertToLlm } from \"./messages.js\";\nimport { TodoLoopGuard } from \"./tools/todo-loop-guard.js\";\nimport { createTodoReadTool } from \"./tools/todo-read.js\";\nimport { createTodoUpdateTool } from \"./tools/todo-update.js\";\nimport { createPerTurnLock, createTodoWriteTool, redactSecrets, type TodoItem } from \"./tools/todo-write.js\";\n\ndescribe(\"Todo write per-user-turn duplicate contract (R01-R14)\", () => {\n\tit(\"R01 writes mutate exactly once; duplicate is rejected; repeat degrades\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tconst guard = new TodoLoopGuard();\n\t\tconst lock = createPerTurnLock();\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t},\n\t\t\tguard,\n\t\t\tundefined,\n\t\t\tlock,\n\t\t);\n\n\t\tconst items1: TodoItem[] = [{ content: \"Phase 1\", activeForm: \"Doing 1\", status: \"in_progress\" }];\n\t\tconst res1 = await tool.execute(\"w01\", { todos: items1 });\n\t\texpect((res1.details as { changed?: boolean }).changed).toBe(true);\n\t\texpect(lock.isActive()).toBe(true);\n\n\t\t// First duplicate: rejection result, no mutation\n\t\tconst res2 = await tool.execute(\"w02\", { todos: items1 });\n\t\texpect((res2.details as { changed?: boolean }).changed).toBe(false);\n\t\texpect((res2.details as { todoWriteAlreadyApplied?: boolean }).todoWriteAlreadyApplied).toBe(true);\n\t\texpect(persisted).toMatchObject(items1);\n\t\texpect(persisted).toHaveLength(1);\n\n\t\t// Second equivalent duplicate: bounded nonfatal degradation\n\t\tconst res3 = await tool.execute(\"w03\", { todos: items1 });\n\t\texpect((res3.details as { errorCode?: string }).errorCode).toBe(\"TODO_TOOL_TEMPORARILY_DEGRADED\");\n\t\texpect((res3.details as { runMustContinue?: boolean }).runMustContinue).toBe(true);\n\t\t// State unchanged\n\t\texpect(persisted).toMatchObject(items1);\n\t\texpect(persisted).toHaveLength(1);\n\t});\n\n\tit(\"R02 recursive response text absent\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t},\n\t\t);\n\n\t\tconst res = await tool.execute(\"call1\", {\n\t\t\ttodos: [{ content: \"Task 1\", activeForm: \"Doing 1\", status: \"pending\" }],\n\t\t});\n\t\tconst text = (res.content[0] as { type: \"text\"; text: string }).text;\n\n\t\texpect(text.includes(\"Call todo_write\")).toBe(false);\n\t\texpect(text.includes(\"retrieve current state using todo_write\")).toBe(false);\n\t\texpect(text.includes(\"rewrite the list\")).toBe(false);\n\t\texpect(text.includes(\"snapshot is hidden from you\")).toBe(false);\n\t\texpect(text.includes(\"set the full list again\")).toBe(false);\n\t\texpect(text).toContain(\"Todo list updated\");\n\t});\n\n\tit(\"R03 no-op duplicate write before lock returns changed=false without store mutation\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet writeCount = 0;\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\twriteCount++;\n\t\t\t},\n\t\t);\n\n\t\tconst list: TodoItem[] = [{ content: \"Task 1\", activeForm: \"Doing 1\", status: \"pending\" }];\n\t\tawait tool.execute(\"w1\", { todos: list });\n\t\texpect(writeCount).toBe(1);\n\n\t\tconst res2 = await tool.execute(\"w2\", { todos: list });\n\t\texpect(writeCount).toBe(1);\n\t\texpect((res2.details as { changed?: boolean }).changed).toBe(false);\n\t\texpect((res2.content[0] as { text: string }).text).toContain(\"Todo list unchanged\");\n\t});\n\n\tit(\"R04 rewritten consecutive plans without lock remain allowed\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t},\n\t\t);\n\n\t\tawait tool.execute(\"w1\", { todos: [{ content: \"Plan A\", activeForm: \"A\", status: \"pending\" }] });\n\t\tawait tool.execute(\"w2\", { todos: [{ content: \"Plan B\", activeForm: \"B\", status: \"pending\" }] });\n\t\tconst res3 = await tool.execute(\"w3\", { todos: [{ content: \"Plan C\", activeForm: \"C\", status: \"pending\" }] });\n\t\texpect((res3.details as { changed?: boolean }).changed).toBe(true);\n\t});\n\n\tit(\"R05 no store mutation after duplicate rejection\", async () => {\n\t\tconst lock = createPerTurnLock();\n\t\tlet persisted: TodoItem[] = [];\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t},\n\t\t\tnew TodoLoopGuard(),\n\t\t\tundefined,\n\t\t\tlock,\n\t\t);\n\n\t\tawait tool.execute(\"w1\", { todos: [{ content: \"Plan 1\", activeForm: \"Doing 1\", status: \"pending\" }] });\n\t\tconst lastValid = [...persisted];\n\n\t\tconst dup = await tool.execute(\"w2\", {\n\t\t\ttodos: [{ content: \"Plan 2\", activeForm: \"Doing 2\", status: \"pending\" }],\n\t\t});\n\t\texpect((dup.details as { todoWriteAlreadyApplied?: boolean }).todoWriteAlreadyApplied).toBe(true);\n\t\texpect(persisted).toEqual(lastValid);\n\t});\n\n\tit(\"R07 duplicate rejection count resets on non-todo tool success\", async () => {\n\t\tconst guard = new TodoLoopGuard();\n\t\tconst lock = createPerTurnLock();\n\t\tlet persisted: TodoItem[] = [];\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t},\n\t\t\tguard,\n\t\t\tundefined,\n\t\t\tlock,\n\t\t);\n\n\t\tawait tool.execute(\"w1\", { todos: [{ content: \"Plan 1\", activeForm: \"1\", status: \"pending\" }] });\n\t\tconst dup = await tool.execute(\"w2\", { todos: [{ content: \"Plan 1\", activeForm: \"1\", status: \"pending\" }] });\n\t\texpect((dup.details as { todoWriteAlreadyApplied?: boolean }).todoWriteAlreadyApplied).toBe(true);\n\n\t\tguard.resetOnNonTodoToolSuccess(\"bash\");\n\t\tlock.currentTurn++;\n\t\tlock.lockedUntil = -1;\n\n\t\tconst res4 = await tool.execute(\"w4\", { todos: [{ content: \"Plan 4\", activeForm: \"4\", status: \"pending\" }] });\n\t\texpect((res4.details as { changed?: boolean }).changed).toBe(true);\n\t});\n\n\tit(\"R08 completed span preserved by convertToLlm\", () => {\n\t\tconst assistant = {\n\t\t\trole: \"assistant\" as const,\n\t\t\tcontent: [\n\t\t\t\t{\n\t\t\t\t\ttype: \"toolCall\" as const,\n\t\t\t\t\tid: \"call-1\",\n\t\t\t\t\tname: \"todo_write\",\n\t\t\t\t\targuments: {\n\t\t\t\t\t\ttodos: Array.from({ length: 500 }, (_, i) => ({\n\t\t\t\t\t\t\tcontent: `T${i}`,\n\t\t\t\t\t\t\tactiveForm: `A${i}`,\n\t\t\t\t\t\t\tstatus: \"pending\",\n\t\t\t\t\t\t})),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\tapi: \"openai-responses\" as const,\n\t\t\tprovider: \"openai\" as const,\n\t\t\tmodel: \"gpt-4\",\n\t\t\tusage: {\n\t\t\t\tinput: 0,\n\t\t\t\toutput: 0,\n\t\t\t\tcacheRead: 0,\n\t\t\t\tcacheWrite: 0,\n\t\t\t\ttotalTokens: 0,\n\t\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t\t},\n\t\t\tstopReason: \"toolUse\" as const,\n\t\t\ttimestamp: 1,\n\t\t};\n\t\tconst toolResult = {\n\t\t\trole: \"toolResult\" as const,\n\t\t\ttoolCallId: \"call-1\",\n\t\t\ttoolName: \"todo_write\",\n\t\t\tcontent: [{ type: \"text\" as const, text: \"Todo list updated\" }],\n\t\t\tisError: false,\n\t\t\ttimestamp: 2,\n\t\t};\n\n\t\tconst converted = convertToLlm([assistant, toolResult]);\n\t\tconst assistantMsg = converted[0];\n\t\texpect(assistantMsg).toMatchObject({ role: \"assistant\" });\n\t\tif (assistantMsg.role === \"assistant\") {\n\t\t\tconst hasToolCall = assistantMsg.content.some((b) => b.type === \"toolCall\");\n\t\t\texpect(hasToolCall).toBe(true);\n\t\t}\n\t\texpect(converted.filter((m) => m.role === \"toolResult\")).toHaveLength(1);\n\t});\n\n\tit(\"R09 explicit read retrieves state without mutation\", async () => {\n\t\tconst persisted: TodoItem[] = [{ content: \"Item A\", activeForm: \"Doing A\", status: \"pending\" }];\n\t\tconst readTool = createTodoReadTool(() => persisted);\n\t\tconst readRes = await readTool.execute(\"r1\", {});\n\t\texpect(readRes.details).toMatchObject({ todos: persisted });\n\t});\n\n\tit(\"R10 clear requires explicit confirmation\", async () => {\n\t\tlet persisted: TodoItem[] = [{ content: \"Task 1\", activeForm: \"Doing 1\", status: \"pending\" }];\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t},\n\t\t);\n\n\t\tconst errRes = await tool.execute(\"c1\", { todos: [] });\n\t\texpect((errRes.content[0] as { text: string }).text).toContain(\n\t\t\t\"Clearing all todos requires explicit confirmation\",\n\t\t);\n\t\texpect(persisted.length).toBe(1);\n\n\t\tconst okRes = await tool.execute(\"c2\", { todos: [], confirmClear: true });\n\t\texpect((okRes.content[0] as { text: string }).text).toContain(\"Todo list updated\");\n\t\texpect(persisted.length).toBe(0);\n\t});\n\n\tit(\"R11 compaction preservation keeps persisted state intact\", async () => {\n\t\tconst persisted: TodoItem[] = [{ content: \"Persistent Task\", activeForm: \"Doing task\", status: \"in_progress\" }];\n\t\tconst readTool = createTodoReadTool(() => persisted);\n\n\t\tconst res = await readTool.execute(\"r1\", {});\n\t\texpect(res.details).toMatchObject({\n\t\t\ttodos: [{ content: \"Persistent Task\", activeForm: \"Doing task\", status: \"in_progress\" }],\n\t\t});\n\t});\n\n\tit(\"R12 secret redaction removes sensitive credentials from todos\", () => {\n\t\tconst raw = \"Connecting with password=admin123 and Bearer secrettoken123 and EXAMPLE_SECRET_DO_NOT_LOG\";\n\t\tconst redacted = redactSecrets(raw);\n\n\t\texpect(redacted.includes(\"admin123\")).toBe(false);\n\t\texpect(redacted.includes(\"secrettoken123\")).toBe(false);\n\t\texpect(redacted.includes(\"EXAMPLE_SECRET_DO_NOT_LOG\")).toBe(false);\n\t\texpect(redacted).toContain(\"[REDACTED_SECRET]\");\n\t});\n\n\tit(\"R13 first duplicate rejection directs model to execute task\", async () => {\n\t\tconst lock = createPerTurnLock();\n\t\tlet persisted: TodoItem[] = [];\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t},\n\t\t\tnew TodoLoopGuard(),\n\t\t\tundefined,\n\t\t\tlock,\n\t\t);\n\n\t\tawait tool.execute(\"w1\", { todos: [{ content: \"A\", activeForm: \"A\", status: \"pending\" }] });\n\t\tconst dup = await tool.execute(\"w2\", { todos: [{ content: \"A\", activeForm: \"A\", status: \"pending\" }] });\n\t\tconst text = (dup.content[0] as { type: \"text\"; text: string }).text;\n\t\texpect(text).toContain(\"The plan already exists. Do not call todo_write again during this user turn.\");\n\t\texpect(text).toContain(\"Continue using another available tool or execute the active task.\");\n\t\texpect(text.includes(\"call it again\")).toBe(false);\n\t});\n\n\tit(\"R14 second equivalent duplicate degrades without terminating\", async () => {\n\t\tconst lock = createPerTurnLock();\n\t\tlet persisted: TodoItem[] = [];\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t},\n\t\t\tnew TodoLoopGuard(),\n\t\t\tundefined,\n\t\t\tlock,\n\t\t);\n\n\t\tawait tool.execute(\"w1\", { todos: [{ content: \"A\", activeForm: \"A\", status: \"pending\" }] });\n\t\tawait tool.execute(\"w2\", { todos: [{ content: \"A\", activeForm: \"A\", status: \"pending\" }] });\n\n\t\tconst result = await tool.execute(\"w3\", { todos: [{ content: \"A\", activeForm: \"A\", status: \"pending\" }] });\n\t\texpect((result.details as { errorCode?: string }).errorCode).toBe(\"TODO_TOOL_TEMPORARILY_DEGRADED\");\n\t\texpect((result.details as { runMustContinue?: boolean }).runMustContinue).toBe(true);\n\t\texpect(persisted).toHaveLength(1);\n\t});\n\n\t// =========================================================================\n\t// R15-R30: todo_update and related contracts\n\t// =========================================================================\n\n\tit(\"R15 compacted snapshot cannot be replayed as todo_write\", () => {\n\t\tconst assistant = {\n\t\t\trole: \"assistant\" as const,\n\t\t\tcontent: [\n\t\t\t\t{\n\t\t\t\t\ttype: \"toolCall\" as const,\n\t\t\t\t\tid: \"call-1\",\n\t\t\t\t\tname: \"todo_write\",\n\t\t\t\t\targuments: { todos: [{ content: \"T\", activeForm: \"A\", status: \"pending\" }] },\n\t\t\t\t},\n\t\t\t],\n\t\t\tapi: \"openai-responses\" as const,\n\t\t\tprovider: \"openai\" as const,\n\t\t\tmodel: \"gpt-4\",\n\t\t\tusage: {\n\t\t\t\tinput: 0,\n\t\t\t\toutput: 0,\n\t\t\t\tcacheRead: 0,\n\t\t\t\tcacheWrite: 0,\n\t\t\t\ttotalTokens: 0,\n\t\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t\t},\n\t\t\tstopReason: \"toolUse\" as const,\n\t\t\ttimestamp: 1,\n\t\t};\n\t\tconst toolResult = {\n\t\t\trole: \"toolResult\" as const,\n\t\t\ttoolCallId: \"call-1\",\n\t\t\ttoolName: \"todo_write\",\n\t\t\tcontent: [{ type: \"text\" as const, text: \"done\" }],\n\t\t\tisError: false,\n\t\t\ttimestamp: 2,\n\t\t};\n\t\tconst converted = convertToLlm([assistant, toolResult]);\n\t\t// Latest completed span is preserved — toolCall stays visible so model knows write succeeded.\n\t\t// Per-user-turn lock on tool level prevents actual re-execution.\n\t\tconst assistantMsg = converted[0];\n\t\tif (assistantMsg.role !== \"assistant\") throw new Error(\"Expected assistant\");\n\t\texpect(assistantMsg.content.some((b: { type: string }) => b.type === \"toolCall\")).toBe(true);\n\t});\n\n\tit(\"R16 snapshotOmitted absent from public todo_write schema\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t},\n\t\t);\n\t\t// Passing snapshotOmitted alongside empty todos without confirmClear must fail\n\t\tconst res = await tool.execute(\"r16\", { todos: [] } as unknown as { todos: TodoItem[]; confirmClear?: boolean });\n\t\texpect((res.content[0] as { text: string }).text).toContain(\"Clearing all todos requires explicit confirmation\");\n\t});\n\n\tit(\"R17 todo_read returns stable IDs and revision\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet revision = 0;\n\t\tconst writeTool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\tnew TodoLoopGuard(),\n\t\t\t() => revision,\n\t\t);\n\t\tconst readTool = createTodoReadTool(\n\t\t\t() => persisted,\n\t\t\t() => revision,\n\t\t);\n\n\t\tawait writeTool.execute(\"w1\", {\n\t\t\ttodos: [{ content: \"Task 1\", activeForm: \"Doing 1\", status: \"pending\" }],\n\t\t});\n\t\texpect(revision).toBe(1);\n\n\t\tconst res = await readTool.execute(\"r1\", {});\n\t\tconst details = res.details as { todos: TodoItem[]; revision: number };\n\t\texpect(details.revision).toBe(1);\n\t\texpect(details.todos.length).toBe(1);\n\t\texpect(details.todos[0].id).toBeDefined();\n\t\texpect(typeof details.todos[0].id).toBe(\"string\");\n\t\texpect(details.todos[0].content).toBe(\"Task 1\");\n\t});\n\n\tit(\"R18 todo_update changes one status without full replacement\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet revision = 0;\n\t\tconst guard = new TodoLoopGuard();\n\t\tconst writeTool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\tguard,\n\t\t\t() => revision,\n\t\t);\n\t\tconst updateTool = createTodoUpdateTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\t() => revision,\n\t\t\tguard,\n\t\t\t{\n\t\t\t\tgetSnapshot: () => ({ revision: initialRevision, timestamp: Date.now() }),\n\t\t\t\tinvalidateSnapshot: () => {},\n\t\t\t},\n\t\t);\n\n\t\t// Create initial list\n\t\tawait writeTool.execute(\"w1\", {\n\t\t\ttodos: [\n\t\t\t\t{ content: \"P1\", activeForm: \"Doing P1\", status: \"pending\" },\n\t\t\t\t{ content: \"P2\", activeForm: \"Doing P2\", status: \"pending\" },\n\t\t\t],\n\t\t});\n\t\tconst initialRevision = revision;\n\t\texpect(persisted.length).toBe(2);\n\t\tconst p1Id = persisted[0].id!;\n\n\t\t// Reset guard (simulate non-todo tool)\n\t\tguard.resetOnNonTodoToolSuccess(\"bash\");\n\n\t\t// Update P1 to in_progress\n\t\tconst res = await updateTool.execute(\"u1\", {\n\t\t\tupdates: [{ id: p1Id, status: \"in_progress\" }],\n\t\t\texpectedRevision: initialRevision,\n\t\t});\n\t\texpect((res.details as { changed?: boolean }).changed).toBe(true);\n\t\texpect(persisted[0].status).toBe(\"in_progress\");\n\t\texpect(persisted[1].status).toBe(\"pending\");\n\t\t// ID should not change\n\t\texpect(persisted[0].id).toBe(p1Id);\n\t\texpect(revision).toBe(initialRevision + 1);\n\t});\n\n\tit(\"R19 multi-item transition is atomic\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet revision = 0;\n\t\tconst guard = new TodoLoopGuard();\n\t\tconst updateTool = createTodoUpdateTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\t() => revision,\n\t\t\tguard,\n\t\t\t{\n\t\t\t\tgetSnapshot: () => ({ revision: initialRev, timestamp: Date.now() }),\n\t\t\t\tinvalidateSnapshot: () => {},\n\t\t\t},\n\t\t);\n\n\t\t// Set up persisted state with known IDs\n\t\tpersisted = [\n\t\t\t{ id: \"id-a\", content: \"A\", activeForm: \"A\", status: \"pending\" },\n\t\t\t{ id: \"id-b\", content: \"B\", activeForm: \"B\", status: \"pending\" },\n\t\t\t{ id: \"id-c\", content: \"C\", activeForm: \"C\", status: \"pending\" },\n\t\t];\n\t\tconst initialRev = revision;\n\n\t\tconst res = await updateTool.execute(\"u1\", {\n\t\t\tupdates: [\n\t\t\t\t{ id: \"id-a\", status: \"in_progress\" },\n\t\t\t\t{ id: \"id-b\", status: \"completed\" },\n\t\t\t],\n\t\t\texpectedRevision: initialRev,\n\t\t});\n\t\texpect((res.details as { changed?: boolean }).changed).toBe(true);\n\t\texpect(persisted[0].status).toBe(\"in_progress\");\n\t\texpect(persisted[1].status).toBe(\"completed\");\n\t\texpect(persisted[2].status).toBe(\"pending\");\n\t});\n\n\tit(\"R20 unknown ID causes zero mutations\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet revision = 0;\n\t\tconst guard = new TodoLoopGuard();\n\t\tconst updateTool = createTodoUpdateTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\t() => revision,\n\t\t\tguard,\n\t\t\t{\n\t\t\t\tgetSnapshot: () => ({ revision: initialRev, timestamp: Date.now() }),\n\t\t\t\tinvalidateSnapshot: () => {},\n\t\t\t},\n\t\t);\n\n\t\tpersisted = [{ id: \"id-a\", content: \"A\", activeForm: \"A\", status: \"pending\" }];\n\t\tconst before = [...persisted];\n\t\tconst initialRev = revision;\n\n\t\tconst res = await updateTool.execute(\"u1\", {\n\t\t\tupdates: [{ id: \"nonexistent\", status: \"completed\" }],\n\t\t\texpectedRevision: initialRev,\n\t\t});\n\t\texpect((res.content[0] as { text: string }).text).toContain(\"unknown todo id\");\n\t\texpect((res.details as { unknownId?: string }).unknownId).toBe(\"nonexistent\");\n\t\texpect(persisted).toEqual(before);\n\t\texpect(revision).toBe(initialRev);\n\t});\n\n\tit(\"R21 stale revision causes zero mutations\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet revision = 5;\n\t\tconst guard = new TodoLoopGuard();\n\t\tconst updateTool = createTodoUpdateTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\t() => revision,\n\t\t\tguard,\n\t\t\t{\n\t\t\t\tgetSnapshot: () => ({ revision: 5, timestamp: Date.now() }),\n\t\t\t\tinvalidateSnapshot: () => {},\n\t\t\t},\n\t\t);\n\n\t\tpersisted = [{ id: \"id-a\", content: \"A\", activeForm: \"A\", status: \"pending\" }];\n\t\tconst beforeRev = revision;\n\n\t\tconst result = await updateTool.execute(\"u1\", {\n\t\t\tupdates: [{ id: \"id-a\", status: \"completed\" }],\n\t\t\texpectedRevision: 3, // stale\n\t\t});\n\t\t// Stale revisions are recovered internally: applied automatically with no\n\t\t// TODO_READ_REQUIRED and no run termination.\n\t\tconst text = result.content[0];\n\t\tif (text.type !== \"text\") throw new Error(\"expected text content\");\n\t\texpect(text.text).not.toContain(\"TODO_READ_REQUIRED\");\n\t\texpect(text.text).toContain(\"rebase\");\n\t\texpect(persisted[0].status).toBe(\"completed\");\n\t\texpect(revision).toBe(beforeRev + 1);\n\t});\n\n\tit(\"R22 no-op update returns changed=false\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet revision = 0;\n\t\tconst guard = new TodoLoopGuard();\n\t\tconst updateTool = createTodoUpdateTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\t() => revision,\n\t\t\tguard,\n\t\t\t{\n\t\t\t\tgetSnapshot: () => ({ revision: beforeRev, timestamp: Date.now() }),\n\t\t\t\tinvalidateSnapshot: () => {},\n\t\t\t},\n\t\t);\n\n\t\tpersisted = [{ id: \"id-a\", content: \"A\", activeForm: \"A\", status: \"in_progress\" }];\n\t\tconst beforeRev = revision;\n\n\t\tconst res = await updateTool.execute(\"u1\", {\n\t\t\tupdates: [{ id: \"id-a\", status: \"in_progress\" }], // same status\n\t\t\texpectedRevision: beforeRev,\n\t\t});\n\t\texpect((res.details as { changed?: boolean }).changed).toBe(false);\n\t\texpect((res.content[0] as { text: string }).text).toContain(\"unchanged\");\n\t\texpect(revision).toBe(beforeRev);\n\t});\n\n\tit(\"R23 todo_update after a fresh read mutates successfully\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet revision = 0;\n\t\tconst guard = new TodoLoopGuard();\n\t\tlet snapshot: { revision: number; timestamp: number } | null = null;\n\t\tconst writeTool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t\tsnapshot = null;\n\t\t\t},\n\t\t\tguard,\n\t\t\t() => revision,\n\t\t);\n\t\tconst readTool = createTodoReadTool(\n\t\t\t() => persisted,\n\t\t\t() => revision,\n\t\t\t{\n\t\t\t\tonRead: () => {\n\t\t\t\t\tsnapshot = { revision, timestamp: Date.now() };\n\t\t\t\t},\n\t\t\t},\n\t\t);\n\t\tconst updateTool = createTodoUpdateTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\t() => revision,\n\t\t\tguard,\n\t\t\t{\n\t\t\t\tgetSnapshot: () => snapshot,\n\t\t\t\tinvalidateSnapshot: () => {\n\t\t\t\t\tsnapshot = null;\n\t\t\t\t},\n\t\t\t},\n\t\t);\n\n\t\tawait writeTool.execute(\"w1\", {\n\t\t\ttodos: [{ content: \"A\", activeForm: \"A\", status: \"pending\" }],\n\t\t});\n\t\tconst itemId = persisted[0].id!;\n\t\tawait readTool.execute(\"r1\", {});\n\n\t\tawait updateTool.execute(\"u1\", {\n\t\t\tupdates: [{ id: itemId, status: \"in_progress\" }],\n\t\t\texpectedRevision: revision,\n\t\t});\n\t\texpect(persisted[0].status).toBe(\"in_progress\");\n\t});\n\n\tit(\"R24 second update without a fresh read is rejected\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet revision = 0;\n\t\tlet snapshot: { revision: number; timestamp: number } | null = null;\n\t\tconst guard = new TodoLoopGuard();\n\t\tconst updateTool = createTodoUpdateTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\t() => revision,\n\t\t\tguard,\n\t\t\t{\n\t\t\t\tgetSnapshot: () => snapshot,\n\t\t\t\tinvalidateSnapshot: () => {\n\t\t\t\t\tsnapshot = null;\n\t\t\t\t},\n\t\t\t},\n\t\t);\n\n\t\tpersisted = [{ id: \"id-a\", content: \"A\", activeForm: \"A\", status: \"pending\" }];\n\t\tsnapshot = { revision: 0, timestamp: Date.now() };\n\n\t\t// First update succeeds and invalidates the snapshot\n\t\tconst res1 = await updateTool.execute(\"u1\", {\n\t\t\tupdates: [{ id: \"id-a\", status: \"in_progress\" }],\n\t\t\texpectedRevision: 0,\n\t\t});\n\t\texpect((res1.details as { changed?: boolean }).changed).toBe(true);\n\n\t\t// Second update based on the current revision is accepted. A fresh read is\n\t\t// no longer a hard prerequisite because stale revisions auto-recover.\n\t\tconst res2 = await updateTool.execute(\"u2\", {\n\t\t\tupdates: [{ id: \"id-a\", status: \"completed\" }],\n\t\t\texpectedRevision: 1,\n\t\t});\n\t\tconst text2 = res2.content[0];\n\t\tif (text2.type !== \"text\") throw new Error(\"expected text content\");\n\t\texpect(text2.text).not.toContain(\"TODO_READ_REQUIRED\");\n\t\texpect(text2.text).toContain(\"updated\");\n\t\texpect(persisted[0].status).toBe(\"completed\");\n\t});\n\n\tit(\"R25 exact black-box transcript regression\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet revision = 0;\n\t\tconst guard = new TodoLoopGuard();\n\t\tconst writeTool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\tguard,\n\t\t\t() => revision,\n\t\t);\n\t\tconst updateTool = createTodoUpdateTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\t() => revision,\n\t\t\tguard,\n\t\t\t{\n\t\t\t\tgetSnapshot: () => ({ revision: initialRev, timestamp: Date.now() }),\n\t\t\t\tinvalidateSnapshot: () => {},\n\t\t\t},\n\t\t);\n\n\t\t// Initial seven-item todo_write (simulating the black-box scenario)\n\t\tconst items: TodoItem[] = [\n\t\t\t{ content: \"Phase 1\", activeForm: \"Doing P1\", status: \"pending\" },\n\t\t\t{ content: \"Phase 2\", activeForm: \"Doing P2\", status: \"pending\" },\n\t\t\t{ content: \"Phase 3\", activeForm: \"Doing P3\", status: \"pending\" },\n\t\t\t{ content: \"Phase 4\", activeForm: \"Doing P4\", status: \"pending\" },\n\t\t\t{ content: \"Phase 5\", activeForm: \"Doing P5\", status: \"pending\" },\n\t\t\t{ content: \"Phase 6\", activeForm: \"Doing P6\", status: \"pending\" },\n\t\t\t{ content: \"Phase 7\", activeForm: \"Doing P7\", status: \"pending\" },\n\t\t];\n\t\tawait writeTool.execute(\"w-init\", { todos: items });\n\t\tconst initialRev = revision;\n\n\t\t// Simulate non-todo tools (pwd, git branch, git status)\n\t\tguard.resetOnNonTodoToolSuccess(\"bash\");\n\t\tguard.resetOnNonTodoToolSuccess(\"bash\");\n\t\tguard.resetOnNonTodoToolSuccess(\"bash\");\n\n\t\t// Attempt progress transition via todo_update\n\t\tconst p1Id = persisted[0].id!;\n\t\tconst res = await updateTool.execute(\"u-progress\", {\n\t\t\tupdates: [{ id: p1Id, status: \"in_progress\" }],\n\t\t\texpectedRevision: initialRev,\n\t\t});\n\n\t\texpect((res.details as { changed?: boolean }).changed).toBe(true);\n\t\texpect(persisted[0].status).toBe(\"in_progress\");\n\t\t// Must NOT use full-list replacement\n\t\texpect(persisted.length).toBe(7);\n\t\t// No tool error\n\t\texpect(res.content[0]).toMatchObject({ type: \"text\" });\n\t});\n\n\tit(\"R26 failed todo_write cannot be reported as success\", async () => {\n\t\tlet persisted: TodoItem[] = [{ content: \"Existing\", activeForm: \"Existing\", status: \"pending\" }];\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t},\n\t\t);\n\n\t\tconst res = await tool.execute(\"c1\", { todos: [] }); // no confirmClear\n\t\texpect((res.content[0] as { text: string }).text).toContain(\"Clearing all todos requires explicit confirmation\");\n\t\t// State must be unchanged\n\t\texpect(persisted.length).toBe(1);\n\t\texpect(persisted[0].content).toBe(\"Existing\");\n\t});\n\n\tit(\"R27 recovery path read → update succeeds\", async () => {\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet revision = 0;\n\t\tconst guard = new TodoLoopGuard();\n\t\tlet snapshot: { revision: number; timestamp: number } | null = null;\n\t\tconst writeTool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t\tsnapshot = null;\n\t\t\t},\n\t\t\tguard,\n\t\t\t() => revision,\n\t\t);\n\t\tconst readTool = createTodoReadTool(\n\t\t\t() => persisted,\n\t\t\t() => revision,\n\t\t\t{\n\t\t\t\tonRead: () => {\n\t\t\t\t\tsnapshot = { revision, timestamp: Date.now() };\n\t\t\t\t},\n\t\t\t},\n\t\t);\n\t\tconst updateTool = createTodoUpdateTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t\tsnapshot = null;\n\t\t\t},\n\t\t\t() => revision,\n\t\t\tguard,\n\t\t\t{\n\t\t\t\tgetSnapshot: () => snapshot,\n\t\t\t\tinvalidateSnapshot: () => {\n\t\t\t\t\tsnapshot = null;\n\t\t\t\t},\n\t\t\t},\n\t\t);\n\n\t\t// Initial write\n\t\tawait writeTool.execute(\"w1\", { todos: [{ content: \"Task\", activeForm: \"Doing\", status: \"pending\" }] });\n\n\t\t// Simulate a failed todo_write (e.g., trying to clear without confirmClear)\n\t\tawait writeTool.execute(\"w-fail\", { todos: [] });\n\t\texpect(persisted.length).toBe(1); // unchanged\n\n\t\t// Recovery: read state\n\t\tguard.resetOnNonTodoToolSuccess(\"bash\");\n\t\tconst readRes = await readTool.execute(\"r1\", {});\n\t\tconst details = readRes.details as { todos: TodoItem[]; revision: number };\n\t\texpect(details.todos.length).toBe(1);\n\t\tconst itemId = details.todos[0].id!;\n\n\t\t// Update via todo_update\n\t\tconst updateRes = await updateTool.execute(\"u1\", {\n\t\t\tupdates: [{ id: itemId, status: \"completed\" }],\n\t\t\texpectedRevision: details.revision,\n\t\t});\n\t\texpect((updateRes.details as { changed?: boolean }).changed).toBe(true);\n\t\texpect(persisted[0].status).toBe(\"completed\");\n\t});\n\n\tit(\"R28 clear semantics remain explicit\", async () => {\n\t\tlet persisted: TodoItem[] = [{ content: \"Task\", activeForm: \"Doing\", status: \"pending\" }];\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t},\n\t\t);\n\n\t\t// Clear without confirmClear → rejected\n\t\tconst rejected = await tool.execute(\"c1\", { todos: [] });\n\t\texpect((rejected.content[0] as { text: string }).text).toContain(\"requires explicit confirmation\");\n\n\t\t// Clear with confirmClear → succeeds\n\t\tconst accepted = await tool.execute(\"c2\", { todos: [], confirmClear: true });\n\t\texpect((accepted.content[0] as { text: string }).text).toContain(\"Todo list\");\n\t\texpect(persisted).toEqual([]);\n\t});\n\n\tit(\"R29 IDs survive compaction and persistence\", () => {\n\t\t// Simulate a write where todos get IDs assigned by normalizeTodoItem\n\t\tconst normalized: TodoItem[] = [\n\t\t\t{ id: \"id-1\", content: \"Task 1\", activeForm: \"Doing 1\", status: \"pending\" },\n\t\t\t{ id: \"id-2\", content: \"Task 2\", activeForm: \"Doing 2\", status: \"in_progress\" },\n\t\t];\n\n\t\t// IDs must be stable strings\n\t\tfor (const item of normalized) {\n\t\t\texpect(typeof item.id).toBe(\"string\");\n\t\t\texpect(item.id!.length).toBeGreaterThan(0);\n\t\t}\n\t\t// IDs should be different\n\t\texpect(normalized[0].id).not.toBe(normalized[1].id);\n\t});\n\n\tit(\"R30 old snapshots without IDs remain compatible\", async () => {\n\t\t// Simulate old persisted state without IDs\n\t\tlet revision = 0;\n\t\tlet persisted: TodoItem[] = [{ content: \"Old Task\", activeForm: \"Old Activity\", status: \"pending\" }];\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t},\n\t\t\tnew TodoLoopGuard(),\n\t\t\t() => revision,\n\t\t);\n\n\t\t// Write new items alongside old items without IDs\n\t\tawait tool.execute(\"w1\", {\n\t\t\ttodos: [\n\t\t\t\t{ content: \"Old Task\", activeForm: \"Old Activity\", status: \"pending\" }, // no id\n\t\t\t\t{ content: \"New Task\", activeForm: \"New Activity\", status: \"pending\" }, // no id\n\t\t\t],\n\t\t});\n\n\t\t// Both items should now have IDs assigned\n\t\texpect(persisted.length).toBe(2);\n\t\texpect(persisted[0].id).toBeDefined();\n\t\texpect(typeof persisted[0].id).toBe(\"string\");\n\t\texpect(persisted[1].id).toBeDefined();\n\t\texpect(typeof persisted[1].id).toBe(\"string\");\n\t\t// IDs should differ\n\t\texpect(persisted[0].id).not.toBe(persisted[1].id);\n\t});\n\n\tit(\"repeated stale todo_update auto-recovers without termination\", async () => {\n\t\tlet persisted: TodoItem[] = [{ id: \"todo-1\", content: \"Task\", activeForm: \"Doing\", status: \"pending\" }];\n\t\tlet revision = 1;\n\t\tlet snapshot: { revision: number; timestamp: number } | null = { revision: 0, timestamp: Date.now() };\n\t\tconst rejectionState = { count: 0 };\n\t\tlet mutations = 0;\n\t\tconst recordRead = () => {\n\t\t\tsnapshot = { revision, timestamp: Date.now() };\n\t\t};\n\t\tconst invalidateSnapshot = () => {\n\t\t\tsnapshot = null;\n\t\t};\n\t\tconst readTool = createTodoReadTool(\n\t\t\t() => persisted,\n\t\t\t() => revision,\n\t\t\t{ onRead: recordRead },\n\t\t);\n\t\tconst updateTool = createTodoUpdateTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\trevision++;\n\t\t\t\tmutations++;\n\t\t\t\tsnapshot = null;\n\t\t\t},\n\t\t\t() => revision,\n\t\t\tnew TodoLoopGuard(),\n\t\t\t{\n\t\t\t\tgetSnapshot: () => snapshot,\n\t\t\t\tinvalidateSnapshot,\n\t\t\t},\n\t\t\trejectionState,\n\t\t);\n\n\t\t// Stale revision is recovered internally: no TODO_READ_REQUIRED, no throw,\n\t\t// no REPEATED_TODO_UPDATE_LOOP run termination.\n\t\tconst first = await updateTool.execute(\"u1\", {\n\t\t\tupdates: [{ id: \"todo-1\", status: \"completed\" }],\n\t\t\texpectedRevision: 0,\n\t\t});\n\t\texpect((first.details as { errorCode?: string }).errorCode).not.toBe(\"TODO_READ_REQUIRED\");\n\t\texpect(mutations).toBe(1);\n\n\t\t// A repeated stale update targeting new work is treated as a separate\n\t\t// recoverable incident, not a no-progress loop that terminates the run.\n\t\tconst second = await updateTool.execute(\"u2\", {\n\t\t\tupdates: [{ id: \"todo-1\", activeForm: \"Doing revised\" }],\n\t\t\texpectedRevision: 0,\n\t\t});\n\t\texpect(second).toBeDefined();\n\t\texpect(mutations).toBe(2);\n\t\texpect((second.details as Record<string, unknown>).errorCode).not.toBeDefined();\n\n\t\t// Recovery remains functional after a fresh read (idempotent repeat -> changed=false).\n\t\tconst read = await readTool.execute(\"r1\", {});\n\t\tconst readDetails = read.details as { revision: number; todos: TodoItem[] };\n\t\tconst recovered = await updateTool.execute(\"u3\", {\n\t\t\tupdates: [{ id: \"todo-1\", status: \"completed\" }],\n\t\t\texpectedRevision: readDetails.revision,\n\t\t});\n\t\texpect((recovered.details as { changed?: boolean }).changed).toBe(false);\n\t});\n\n\tit(\"reserves todo_write atomically for parallel calls\", async () => {\n\t\tconst lock = createPerTurnLock();\n\t\tlet persisted: TodoItem[] = [];\n\t\tlet mutations = 0;\n\t\tconst tool = createTodoWriteTool(\n\t\t\t() => persisted,\n\t\t\t(next) => {\n\t\t\t\tpersisted = next;\n\t\t\t\tmutations++;\n\t\t\t},\n\t\t\tnew TodoLoopGuard(),\n\t\t\t() => mutations,\n\t\t\tlock,\n\t\t);\n\n\t\tconst results = await Promise.all([\n\t\t\ttool.execute(\"w1\", { todos: [{ id: \"a\", content: \"A\", activeForm: \"Doing A\", status: \"pending\" }] }),\n\t\t\ttool.execute(\"w2\", { todos: [{ id: \"b\", content: \"B\", activeForm: \"Doing B\", status: \"pending\" }] }),\n\t\t]);\n\n\t\texpect(mutations).toBe(1);\n\t\texpect(lock.lockedUntil).toBe(lock.currentTurn);\n\t\texpect(results.filter((result) => (result.details as { changed?: boolean }).changed).length).toBe(1);\n\t});\n});\n"]}