{"version":3,"file":"todo-loop-false-positive.test.d.ts","sourceRoot":"","sources":["../../src/core/todo-loop-false-positive.test.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * Production regression: the observed FALSE_POSITIVE_TODO_LOOP_TERMINATION\n * incident.\n *\n * Observed sequence (before this release):\n *   revision 7 -> stale todo_update(7) -> TODO_READ_REQUIRED\n *   -> productive work -> stale todo_update(8) -> REPEATED_TODO_UPDATE_LOOP\n *   -> active run TERMINATED\n *\n * After this release the two rejected updates are NOT a consecutive no-progress\n * loop: authoritative progress occurred between them, so they are two separate\n * recoverable incidents. Each stale update is recovered internally (read +\n * rebase) with no model-required todo_read and no run termination.\n */\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 type { TodoItem } from \"./tools/todo-write.js\";\n\nfunction setup() {\n\tlet items: TodoItem[] = [\n\t\t{ id: \"a\", content: \"Task A\", activeForm: \"Working on A\", status: \"pending\" },\n\t\t{ id: \"b\", content: \"Task B\", activeForm: \"Working on B\", status: \"pending\" },\n\t\t{ id: \"c\", content: \"Task C\", activeForm: \"Working on C\", status: \"pending\" },\n\t];\n\tlet revision = 7;\n\tconst engine = new TodoEngine(\"run-1\");\n\tconst getSessionTodos = () => items;\n\tconst getRevision = () => revision;\n\tconst setSessionTodos = (t: TodoItem[]) => {\n\t\titems = t;\n\t\trevision++;\n\t};\n\tconst readTool = createTodoReadTool(getSessionTodos, getRevision, undefined, engine);\n\tconst updateTool = createTodoUpdateTool(\n\t\tgetSessionTodos,\n\t\tsetSessionTodos,\n\t\tgetRevision,\n\t\tnew TodoLoopGuard(),\n\t\tundefined,\n\t\tundefined,\n\t\tengine,\n\t);\n\t/** Simulate an independent concurrent writer advancing the store. */\n\tconst concurrent = (mutate?: (t: TodoItem[]) => void) => {\n\t\tconst copy = items.map((t) => ({ ...t }));\n\t\tmutate?.(copy);\n\t\titems = copy;\n\t\trevision++;\n\t};\n\treturn {\n\t\treadTool,\n\t\tupdateTool,\n\t\tengine,\n\t\tconcurrent,\n\t\tgetItems: () => items,\n\t\tgetRevision: () => revision,\n\t};\n}\n\ndescribe(\"observed false-positive loop no longer terminates the run\", () => {\n\tit(\"two stale updates separated by productive progress are both recovered; run completes\", async () => {\n\t\tconst { readTool, updateTool, concurrent, engine, getRevision, getItems } = setup();\n\n\t\t// Step 1: model reads revision 7 (snapshot base for rebase).\n\t\tawait readTool.execute(\"r1\", {});\n\t\texpect(getRevision()).toBe(7);\n\n\t\t// Step 2: TODO store concurrently advances to revision 8 (unrelated item).\n\t\tconcurrent();\n\n\t\t// Step 3: model submits an update based on revision 7 (definitely NOT a loop).\n\t\tconst first = await updateTool.execute(\"tc-1\", {\n\t\t\tupdates: [{ id: \"a\", status: \"completed\" }],\n\t\t\texpectedRevision: 7,\n\t\t});\n\t\tconst firstText = first.content[0];\n\t\tif (firstText.type !== \"text\") throw new Error(\"text\");\n\t\t// Automatic internal recovery: no TODO_READ_REQUIRED forced on the model.\n\t\texpect(firstText.text).not.toContain(\"TODO_READ_REQUIRED\");\n\t\tconst firstDetails = first.details as Record<string, unknown>;\n\t\texpect(firstDetails.rebased ?? firstDetails.changed).toBe(true);\n\t\texpect(getRevision()).toBe(9); // 7 -> 8 (concurrent) -> 9 (this update)\n\n\t\t// Step 4: productive non-TODO progress occurs (docs read, file write, test).\n\t\tengine.recordProgress();\n\n\t\t// Step 5: store advances concurrently to revision 10.\n\t\tconcurrent();\n\n\t\t// Step 6: model submits another update based on revision 9.\n\t\tconst second = await updateTool.execute(\"tc-2\", {\n\t\t\tupdates: [{ id: \"b\", status: \"completed\" }],\n\t\t\texpectedRevision: 9,\n\t\t});\n\t\tconst secondText = second.content[0];\n\t\tif (secondText.type !== \"text\") throw new Error(\"text\");\n\t\texpect(secondText.text).not.toContain(\"REPEATED_TODO_UPDATE_LOOP\");\n\t\texpect(secondText.text).not.toContain(\"TODO_READ_REQUIRED\");\n\t\tconst secondDetails = second.details as Record<string, unknown>;\n\t\texpect(secondDetails.rebased ?? secondDetails.changed).toBe(true);\n\t\texpect(getRevision()).toBe(11); // 9 -> 10 -> 11\n\n\t\t// Both intended changes applied; unrelated work preserved.\n\t\tconst itemsNow = getItems();\n\t\texpect(itemsNow.find((t) => t.id === \"a\")?.status).toBe(\"completed\");\n\t\texpect(itemsNow.find((t) => t.id === \"b\")?.status).toBe(\"completed\");\n\t\texpect(itemsNow.find((t) => t.id === \"c\")?.status).toBe(\"pending\");\n\n\t\t// Internal recovery was exercised (not a model-visible error, no run terminal).\n\t\tconst events = engine.getEvents(50);\n\t\texpect(events.some((e) => e.type === \"TODO_REVISION_STALE_DETECTED\")).toBe(true);\n\t\texpect(events.some((e) => e.type === \"TODO_REBASE_SUCCEEDED\")).toBe(true);\n\t\texpect(events.some((e) => e.type === \"TODO_MUTATION_REJECTED\")).toBe(false);\n\t});\n\n\tit(\"an ambiguous (non-recoverable) first conflict is typed and nonfatal; a later independent update succeeds without inherited loop counter\", async () => {\n\t\tconst { readTool, updateTool, concurrent, engine, getRevision, getItems } = setup();\n\n\t\tawait readTool.execute(\"r1\", {});\n\t\texpect(getRevision()).toBe(7);\n\n\t\t// Concurrently change the SAME content the model will target (creates a conflict).\n\t\tconcurrent((t) => {\n\t\t\tconst a = t.find((x) => x.id === \"a\");\n\t\t\tif (a) a.content = \"concurrent-edit\";\n\t\t});\n\t\t// revision now 8\n\n\t\t// Model attempts to edit the same content based on stale revision 7 -> conflict.\n\t\tconst conflicted = await updateTool.execute(\"tc-conflict\", {\n\t\t\tupdates: [{ id: \"a\", content: \"model-edit\" }],\n\t\t\texpectedRevision: 7,\n\t\t});\n\t\tconst cText = conflicted.content[0];\n\t\tif (cText.type !== \"text\") throw new Error(\"text\");\n\t\texpect(cText.text).toContain(\"TODO_REBASE_CONFLICT\");\n\t\t// Nonfatal: run continues; store not corrupted.\n\t\texpect(getRevision()).toBe(8);\n\t\texpect(getItems().find((t) => t.id === \"a\")?.content).toBe(\"concurrent-edit\");\n\n\t\t// Productive work between incidents resets any chain.\n\t\tengine.recordProgress();\n\n\t\t// A later INDEPENDENT (different-item) update is allowed with no inherited counter.\n\t\tconst ok = await updateTool.execute(\"tc-independent\", {\n\t\t\tupdates: [{ id: \"c\", status: \"in_progress\" }],\n\t\t\texpectedRevision: 8,\n\t\t});\n\t\tconst okText = ok.content[0];\n\t\tif (okText.type !== \"text\") throw new Error(\"text\");\n\t\texpect(okText.text).not.toContain(\"REPEATED_TODO_UPDATE_LOOP\");\n\t\tconst okDetails = ok.details as Record<string, unknown>;\n\t\texpect(okDetails.changed).toBe(true);\n\t\texpect(getItems().find((t) => t.id === \"c\")?.status).toBe(\"in_progress\");\n\t});\n});\n"]}