@stigmer/runner 3.1.3 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/capture-flow.d.ts +26 -15
  3. package/dist/activities/execute-cursor/capture-flow.js +56 -18
  4. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  5. package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
  6. package/dist/activities/execute-cursor/command-provenance.js +25 -115
  7. package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
  8. package/dist/activities/execute-cursor/index.js +296 -477
  9. package/dist/activities/execute-cursor/index.js.map +1 -1
  10. package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
  11. package/dist/activities/execute-cursor/todo-tracker.js +15 -43
  12. package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
  13. package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
  14. package/dist/activities/execute-cursor/turn-stream.js +249 -0
  15. package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
  16. package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
  17. package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
  18. package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
  19. package/dist/activities/execute-deep-agent/index.js +60 -18
  20. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/status-builder.js +8 -1
  22. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
  24. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  25. package/dist/otel.js +10 -0
  26. package/dist/otel.js.map +1 -1
  27. package/dist/shared/filereview/cas-progress.d.ts +63 -0
  28. package/dist/shared/filereview/cas-progress.js +128 -0
  29. package/dist/shared/filereview/cas-progress.js.map +1 -0
  30. package/dist/shared/filereview/cas-substrate.d.ts +29 -0
  31. package/dist/shared/filereview/cas-substrate.js +46 -21
  32. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  33. package/dist/shared/filereview/command-provenance.d.ts +93 -0
  34. package/dist/shared/filereview/command-provenance.js +132 -0
  35. package/dist/shared/filereview/command-provenance.js.map +1 -0
  36. package/dist/shared/filereview/git-substrate.d.ts +9 -3
  37. package/dist/shared/filereview/git-substrate.js.map +1 -1
  38. package/dist/shared/filereview/index.d.ts +4 -3
  39. package/dist/shared/filereview/index.js +3 -2
  40. package/dist/shared/filereview/index.js.map +1 -1
  41. package/dist/shared/filereview/progress.d.ts +105 -34
  42. package/dist/shared/filereview/progress.js +96 -34
  43. package/dist/shared/filereview/progress.js.map +1 -1
  44. package/dist/shared/plan-mode-prompt.d.ts +9 -0
  45. package/dist/shared/plan-mode-prompt.js +18 -0
  46. package/dist/shared/plan-mode-prompt.js.map +1 -1
  47. package/dist/shared/todos.d.ts +56 -0
  48. package/dist/shared/todos.js +98 -0
  49. package/dist/shared/todos.js.map +1 -0
  50. package/dist/shared/tool-row.d.ts +16 -0
  51. package/dist/shared/tool-row.js +31 -0
  52. package/dist/shared/tool-row.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/__tests__/otel-turn-span.test.ts +61 -0
  55. package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
  56. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
  57. package/src/activities/execute-cursor/capture-flow.ts +71 -25
  58. package/src/activities/execute-cursor/command-provenance.ts +25 -120
  59. package/src/activities/execute-cursor/index.ts +344 -504
  60. package/src/activities/execute-cursor/todo-tracker.ts +17 -59
  61. package/src/activities/execute-cursor/turn-stream.ts +418 -0
  62. package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
  63. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
  64. package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
  65. package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
  66. package/src/activities/execute-deep-agent/index.ts +74 -18
  67. package/src/activities/execute-deep-agent/status-builder.ts +9 -0
  68. package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
  69. package/src/otel.ts +8 -0
  70. package/src/shared/__tests__/todos.test.ts +216 -0
  71. package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
  72. package/src/shared/filereview/__tests__/cas-substrate.test.ts +66 -0
  73. package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
  74. package/src/shared/filereview/__tests__/progress.test.ts +112 -10
  75. package/src/shared/filereview/cas-progress.ts +170 -0
  76. package/src/shared/filereview/cas-substrate.ts +69 -24
  77. package/src/shared/filereview/command-provenance.ts +180 -0
  78. package/src/shared/filereview/git-substrate.ts +9 -3
  79. package/src/shared/filereview/index.ts +15 -1
  80. package/src/shared/filereview/progress.ts +171 -47
  81. package/src/shared/plan-mode-prompt.ts +18 -0
  82. package/src/shared/todos.ts +126 -0
  83. package/src/shared/tool-row.ts +34 -0
@@ -115,3 +115,19 @@ export declare function isToolCallRowHidden(tc: ToolCall): boolean;
115
115
  * none; Cursor clones them in) — the snapshot reflects whatever is present.
116
116
  */
117
117
  export declare function collectSubAgentToolCallIds(subAgents: readonly SubAgentExecution[]): Set<string>;
118
+ /**
119
+ * Collect the ids of tool-call rows that have already SETTLED (reached a terminal
120
+ * state: completed, failed, or skipped) in a transcript.
121
+ *
122
+ * The deep-agent turn-boundary provenance derivation (DD-28) snapshots this
123
+ * BEFORE a turn's stream to scope "this turn's tool calls" by identity: a call
124
+ * whose id is absent from the snapshot is this-turn's — either freshly streamed,
125
+ * or a prior gate that was WAITING_APPROVAL before the stream and executes now on
126
+ * resume (StatusBuilder updates the seeded row in place, so it keeps its id).
127
+ * Unlike the Cursor harness, the deep-agent cannot scope positionally — an
128
+ * approved command executes at its SEEDED position — so identity is the only
129
+ * correct scope, mirroring {@link collectSubAgentToolCallIds}. Terminal (not just
130
+ * completed) so a PRIOR turn's failed/skipped non-shell call is excluded and
131
+ * cannot spuriously disqualify this turn.
132
+ */
133
+ export declare function collectSettledToolCallIds(messages: readonly AgentMessage[]): Set<string>;
@@ -173,4 +173,35 @@ export function collectSubAgentToolCallIds(subAgents) {
173
173
  }
174
174
  return ids;
175
175
  }
176
+ /** Terminal tool-call statuses — a row that has finished (however it finished). */
177
+ const TERMINAL_TOOL_CALL_STATUSES = new Set([
178
+ ToolCallStatus.TOOL_CALL_COMPLETED,
179
+ ToolCallStatus.TOOL_CALL_FAILED,
180
+ ToolCallStatus.TOOL_CALL_SKIPPED,
181
+ ]);
182
+ /**
183
+ * Collect the ids of tool-call rows that have already SETTLED (reached a terminal
184
+ * state: completed, failed, or skipped) in a transcript.
185
+ *
186
+ * The deep-agent turn-boundary provenance derivation (DD-28) snapshots this
187
+ * BEFORE a turn's stream to scope "this turn's tool calls" by identity: a call
188
+ * whose id is absent from the snapshot is this-turn's — either freshly streamed,
189
+ * or a prior gate that was WAITING_APPROVAL before the stream and executes now on
190
+ * resume (StatusBuilder updates the seeded row in place, so it keeps its id).
191
+ * Unlike the Cursor harness, the deep-agent cannot scope positionally — an
192
+ * approved command executes at its SEEDED position — so identity is the only
193
+ * correct scope, mirroring {@link collectSubAgentToolCallIds}. Terminal (not just
194
+ * completed) so a PRIOR turn's failed/skipped non-shell call is excluded and
195
+ * cannot spuriously disqualify this turn.
196
+ */
197
+ export function collectSettledToolCallIds(messages) {
198
+ const ids = new Set();
199
+ for (const msg of messages) {
200
+ for (const tc of msg.toolCalls) {
201
+ if (TERMINAL_TOOL_CALL_STATUSES.has(tc.status))
202
+ ids.add(tc.id);
203
+ }
204
+ }
205
+ return ids;
206
+ }
176
207
  //# sourceMappingURL=tool-row.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"tool-row.js","sourceRoot":"","sources":["../../src/shared/tool-row.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tool-row.js","sourceRoot":"","sources":["../../src/shared/tool-row.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigmer/runner",
3
- "version": "3.1.3",
3
+ "version": "3.1.4",
4
4
  "description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -85,7 +85,7 @@
85
85
  "@opentelemetry/sdk-metrics": "^2.0.0",
86
86
  "@opentelemetry/sdk-trace-base": "^2.0.0",
87
87
  "@opentelemetry/sdk-trace-node": "^2.0.0",
88
- "@stigmer/protos": "3.1.3",
88
+ "@stigmer/protos": "3.1.4",
89
89
  "@temporalio/activity": "^1.11.0",
90
90
  "@temporalio/client": "^1.11.0",
91
91
  "@temporalio/common": "^1.11.0",
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Spy span/tracer, hoisted so the vi.mock factory (which runs before imports)
4
+ // can close over them without a temporal-dead-zone error.
5
+ const { spanEnd, spanSetAttribute, startSpan } = vi.hoisted(() => {
6
+ const spanEnd = vi.fn();
7
+ const spanSetAttribute = vi.fn();
8
+ const startSpan = vi.fn(() => ({ end: spanEnd, setAttribute: spanSetAttribute }));
9
+ return { spanEnd, spanSetAttribute, startSpan };
10
+ });
11
+
12
+ vi.mock("@opentelemetry/api", () => ({
13
+ trace: { getTracer: () => ({ startSpan }) },
14
+ }));
15
+
16
+ import {
17
+ startCursorTurnSpan,
18
+ ATTR_LLM_INPUT_TOKENS,
19
+ ATTR_LLM_OUTPUT_TOKENS,
20
+ } from "../otel.js";
21
+
22
+ const spanAttrs = { model: "gpt-x", mode: "local", sessionId: "sess-1" };
23
+
24
+ describe("startCursorTurnSpan", () => {
25
+ beforeEach(() => {
26
+ spanEnd.mockClear();
27
+ spanSetAttribute.mockClear();
28
+ startSpan.mockClear();
29
+ });
30
+
31
+ it("records the input and output token attributes", async () => {
32
+ const span = await startCursorTurnSpan(spanAttrs);
33
+ span.setTokens(1200, 340);
34
+ expect(spanSetAttribute).toHaveBeenCalledWith(ATTR_LLM_INPUT_TOKENS, 1200);
35
+ expect(spanSetAttribute).toHaveBeenCalledWith(ATTR_LLM_OUTPUT_TOKENS, 340);
36
+ });
37
+
38
+ it("omits zero-valued token attributes (no count = no attribute)", async () => {
39
+ const span = await startCursorTurnSpan(spanAttrs);
40
+ span.setTokens(0, 0);
41
+ expect(spanSetAttribute).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it("ends the span exactly once even when end() is called repeatedly", async () => {
45
+ // finishTurnTelemetry is invoked from the activity's finally on every exit
46
+ // path; a second end() (e.g. a defensive double-invoke) must be a no-op so
47
+ // OTel never warns about ending an already-ended span.
48
+ const span = await startCursorTurnSpan(spanAttrs);
49
+ span.end();
50
+ span.end();
51
+ span.end();
52
+ expect(spanEnd).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it("ignores setTokens after the span has ended (no late mutation)", async () => {
56
+ const span = await startCursorTurnSpan(spanAttrs);
57
+ span.end();
58
+ span.setTokens(1200, 340);
59
+ expect(spanSetAttribute).not.toHaveBeenCalled();
60
+ });
61
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Unit tests for the Cursor mid-run progress substrate selection (DD-33) —
3
+ * `buildCursorProgressSubstrate`. This is the Cursor adapter's responsibility:
4
+ * pick git / non-git CAS / hybrid for the turn's workspace shape (the CAS + git
5
+ * capture behaviors themselves are covered in shared/filereview/__tests__).
6
+ *
7
+ * The chosen substrate is driven against a real temp workspace to prove WHICH
8
+ * kind was built: a git-only substrate surfaces the tracked change; a hybrid adds
9
+ * the (empty here) CAS slice; a non-git substrate reads the (empty) hook sidecar
10
+ * without touching git.
11
+ */
12
+
13
+ import { execFile } from "node:child_process";
14
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { promisify } from "node:util";
18
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
19
+ import { buildCursorProgressSubstrate } from "../capture-flow.js";
20
+ import { snapshotBaseline, dropCaptureRefs } from "../../../shared/filereview/git-substrate.js";
21
+ import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
22
+
23
+ const execFileAsync = promisify(execFile);
24
+ const EXEC_ID = "exec-cursor-progress";
25
+
26
+ let repo: string;
27
+ let hitlDir: string;
28
+
29
+ async function git(args: string[]): Promise<void> {
30
+ await execFileAsync("git", args, { cwd: repo });
31
+ }
32
+
33
+ async function write(rel: string, content: string): Promise<void> {
34
+ await mkdir(join(repo, rel, ".."), { recursive: true });
35
+ await writeFile(join(repo, rel), content, "utf-8");
36
+ }
37
+
38
+ beforeEach(async () => {
39
+ repo = await mkdtemp(join(tmpdir(), "cursor-progress-"));
40
+ hitlDir = await mkdtemp(join(tmpdir(), "cursor-progress-hitl-"));
41
+ await git(["init", "-q"]);
42
+ await git(["config", "user.email", "t@t.local"]);
43
+ await git(["config", "user.name", "t"]);
44
+ await write("src/main.ts", "export const x = 1;\n");
45
+ await git(["add", "-A"]);
46
+ await git(["commit", "-q", "-m", "initial"]);
47
+ });
48
+
49
+ afterEach(async () => {
50
+ await dropCaptureRefs(repo, EXEC_ID).catch(() => {});
51
+ await rm(repo, { recursive: true, force: true });
52
+ await rm(hitlDir, { recursive: true, force: true });
53
+ });
54
+
55
+ describe("buildCursorProgressSubstrate — selection", () => {
56
+ it("returns undefined outside capture mode", () => {
57
+ const sub = buildCursorProgressSubstrate({
58
+ captureMode: false,
59
+ gitWorkspace: true,
60
+ workspaceRoot: repo,
61
+ baselineTree: "sha",
62
+ executionId: EXEC_ID,
63
+ hitlDir,
64
+ storage: undefined,
65
+ });
66
+ expect(sub).toBeUndefined();
67
+ });
68
+
69
+ it("returns undefined with no workspace root", () => {
70
+ const sub = buildCursorProgressSubstrate({
71
+ captureMode: true,
72
+ gitWorkspace: true,
73
+ workspaceRoot: undefined,
74
+ baselineTree: "sha",
75
+ executionId: EXEC_ID,
76
+ hitlDir,
77
+ storage: undefined,
78
+ });
79
+ expect(sub).toBeUndefined();
80
+ });
81
+
82
+ it("returns undefined for a git tree with no baseline tree yet", () => {
83
+ const sub = buildCursorProgressSubstrate({
84
+ captureMode: true,
85
+ gitWorkspace: true,
86
+ workspaceRoot: repo,
87
+ baselineTree: undefined,
88
+ executionId: EXEC_ID,
89
+ hitlDir,
90
+ storage: undefined,
91
+ });
92
+ expect(sub).toBeUndefined();
93
+ });
94
+
95
+ it("returns undefined for a non-git workspace with no storage/sidecar", () => {
96
+ const sub = buildCursorProgressSubstrate({
97
+ captureMode: true,
98
+ gitWorkspace: false,
99
+ workspaceRoot: repo,
100
+ baselineTree: undefined,
101
+ executionId: EXEC_ID,
102
+ hitlDir: undefined,
103
+ storage: undefined,
104
+ });
105
+ expect(sub).toBeUndefined();
106
+ });
107
+
108
+ it("builds a git-only substrate (no storage) that surfaces the tracked change", async () => {
109
+ const baselineTree = await snapshotBaseline(repo, EXEC_ID);
110
+ const sub = buildCursorProgressSubstrate({
111
+ captureMode: true,
112
+ gitWorkspace: true,
113
+ workspaceRoot: repo,
114
+ baselineTree,
115
+ executionId: EXEC_ID,
116
+ hitlDir,
117
+ storage: undefined,
118
+ });
119
+ expect(sub).toBeDefined();
120
+
121
+ await write("src/main.ts", "export const x = 2;\nexport const y = 3;\n");
122
+ const { delta, changed } = await sub!.capture();
123
+ expect(changed).toBe(true);
124
+ expect(delta.entries.some((e) => e.pathAfter === "src/main.ts")).toBe(true);
125
+ });
126
+
127
+ it("builds a hybrid substrate (git + storage) surfacing the git slice + empty CAS", async () => {
128
+ const baselineTree = await snapshotBaseline(repo, EXEC_ID);
129
+ const { storage } = makeInMemoryArtifactStorage();
130
+ const sub = buildCursorProgressSubstrate({
131
+ captureMode: true,
132
+ gitWorkspace: true,
133
+ workspaceRoot: repo,
134
+ baselineTree,
135
+ executionId: EXEC_ID,
136
+ hitlDir,
137
+ storage,
138
+ });
139
+ expect(sub).toBeDefined();
140
+
141
+ await write("src/main.ts", "export const x = 9;\n");
142
+ const { delta } = await sub!.capture();
143
+ // The git slice contributes the tracked change; the CAS sidecar is empty, so
144
+ // it adds nothing — total equals the git entries.
145
+ expect(delta.entries.some((e) => e.pathAfter === "src/main.ts")).toBe(true);
146
+ expect(delta.totalFilesChanged).toBe(delta.entries.length);
147
+ });
148
+
149
+ it("builds a non-git CAS substrate that reads the (empty) sidecar without git", async () => {
150
+ const { storage } = makeInMemoryArtifactStorage();
151
+ const sub = buildCursorProgressSubstrate({
152
+ captureMode: true,
153
+ gitWorkspace: false,
154
+ workspaceRoot: repo,
155
+ baselineTree: undefined,
156
+ executionId: EXEC_ID,
157
+ hitlDir,
158
+ storage,
159
+ });
160
+ expect(sub).toBeDefined();
161
+
162
+ // Even with a git change on disk, the non-git substrate ignores git entirely;
163
+ // the sidecar is empty, so it reports zero changed files.
164
+ await write("src/main.ts", "export const x = 42;\n");
165
+ const { delta } = await sub!.capture();
166
+ expect(delta.totalFilesChanged).toBe(0);
167
+ expect(delta.entries).toHaveLength(0);
168
+ });
169
+ });
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Unit tests for the Cursor harness's shared stream seam (turn-stream.ts).
3
+ *
4
+ * These lock the behaviors the old bare retry loops used to DROP — live persist,
5
+ * DD-32/DD-33 mid-run progress, sub-agent tracking, the first-denial early stop,
6
+ * and correct pause/platform-stop mapping — so that unifying the primary turn and
7
+ * both recovery retries onto this one loop cannot silently regress any of them.
8
+ *
9
+ * The loop is driven with a structural `mockRun` (an async-iterable `.stream()`)
10
+ * and injected `heartbeat`/`isCancelled`, so it runs without the live Cursor SDK
11
+ * or Temporal — mirroring the deep-agent `streaming.test.ts` pattern.
12
+ */
13
+
14
+ import { describe, it, expect, vi, afterEach } from "vitest";
15
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { create } from "@bufbuild/protobuf";
19
+ import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
20
+ import { ExecutionControlSignal } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
21
+ import { CancelledFailure } from "@temporalio/activity";
22
+ import type { SDKMessage } from "@cursor/sdk";
23
+ import {
24
+ consumeCursorTurnStream,
25
+ makeCursorTurnOnDelta,
26
+ newTurnStreamState,
27
+ type CursorTurnStreamDeps,
28
+ type StreamableRun,
29
+ type TurnStreamState,
30
+ } from "../turn-stream.js";
31
+ import type { ProgressSubstrate } from "../../../shared/filereview/progress.js";
32
+
33
+ // A stream event is only inspected by the loop for `type`/`name`/`status`/
34
+ // `message`; the accumulator (stubbed) owns the rest, so a minimal cast is safe.
35
+ function ev(obj: Record<string, unknown>): SDKMessage {
36
+ return obj as unknown as SDKMessage;
37
+ }
38
+
39
+ async function* asyncIter(events: SDKMessage[]): AsyncIterable<SDKMessage> {
40
+ for (const e of events) yield e;
41
+ }
42
+
43
+ interface MockRun extends StreamableRun {
44
+ cancel: ReturnType<typeof vi.fn>;
45
+ }
46
+
47
+ function mockRun(events: SDKMessage[]): MockRun {
48
+ return {
49
+ stream: () => asyncIter(events),
50
+ supports: () => true,
51
+ cancel: vi.fn(async () => {}),
52
+ };
53
+ }
54
+
55
+ function stubAccumulator(overrides: Record<string, unknown> = {}) {
56
+ return {
57
+ processEvent: vi.fn(),
58
+ trackSubAgentExecution: vi.fn(),
59
+ finalize: vi.fn(),
60
+ markPersisted: vi.fn(),
61
+ cancelInProgressSubAgents: vi.fn(),
62
+ isDirty: true,
63
+ subAgentExecutions: [],
64
+ ...overrides,
65
+ };
66
+ }
67
+
68
+ function stubEnricher() {
69
+ return {
70
+ processDelta: vi.fn(),
71
+ applyEnrichments: vi.fn(),
72
+ finalize: vi.fn(),
73
+ markPersisted: vi.fn(),
74
+ isDirty: false,
75
+ };
76
+ }
77
+
78
+ function stubUsageAccumulator() {
79
+ return {
80
+ addTurn: vi.fn(),
81
+ // hasTurns:false keeps the loop from building a StreamingUsageSummary (which
82
+ // would need a real snapshot); usage plumbing is covered by usage-accumulator's
83
+ // own tests.
84
+ hasTurns: false,
85
+ snapshot: vi.fn(() => ({ inputTokens: 0n, outputTokens: 0n })),
86
+ };
87
+ }
88
+
89
+ interface BuiltDeps {
90
+ deps: CursorTurnStreamDeps;
91
+ state: TurnStreamState;
92
+ persist: ReturnType<typeof vi.fn>;
93
+ accumulator: ReturnType<typeof stubAccumulator>;
94
+ status: ReturnType<typeof create<typeof AgentExecutionStatusSchema>>;
95
+ }
96
+
97
+ function buildDeps(overrides: Partial<CursorTurnStreamDeps> = {}): BuiltDeps {
98
+ const state = overrides.state ?? newTurnStreamState();
99
+ const status = overrides.status ?? create(AgentExecutionStatusSchema, {});
100
+ const accumulator = (overrides.accumulator as unknown as ReturnType<typeof stubAccumulator>) ??
101
+ stubAccumulator();
102
+ const persist =
103
+ (overrides.persist as ReturnType<typeof vi.fn>) ??
104
+ vi.fn(async () => ExecutionControlSignal.UNSPECIFIED);
105
+
106
+ const deps = {
107
+ // TurnOnDeltaDeps
108
+ usageAccumulator: stubUsageAccumulator(),
109
+ deltaEnricher: stubEnricher(),
110
+ heartbeat: vi.fn(),
111
+ promptEstimatedTokens: 100,
112
+ executionId: "exec-test",
113
+ state,
114
+ // CursorTurnStreamDeps
115
+ status,
116
+ accumulator,
117
+ todoTracker: { processEvent: vi.fn(), markPersisted: vi.fn(), isDirty: false },
118
+ eventRecorder: undefined,
119
+ // contentDirty (accumulator.isDirty) forces the persist, so shouldSendUpdate
120
+ // is never consulted; markUpdateSent must still exist.
121
+ scheduler: { shouldSendUpdate: () => false, markUpdateSent: vi.fn() },
122
+ progressSubstrate: undefined,
123
+ progressState: { lastAtMs: 0 },
124
+ changeSetId: "exec-test:0",
125
+ hitlDir: undefined,
126
+ stallTimeoutMs: 120_000,
127
+ persist,
128
+ isCancelled: () => false,
129
+ ...overrides,
130
+ } as unknown as CursorTurnStreamDeps;
131
+
132
+ return { deps, state, persist, accumulator, status };
133
+ }
134
+
135
+ describe("consumeCursorTurnStream", () => {
136
+ afterEach(() => {
137
+ vi.useRealTimers();
138
+ });
139
+
140
+ it("processes every event, persists live, and returns 'completed' on a natural end", async () => {
141
+ const { deps, state, persist, accumulator } = buildDeps();
142
+
143
+ const reason = await consumeCursorTurnStream(
144
+ mockRun([ev({ type: "assistant" }), ev({ type: "assistant" })]),
145
+ deps,
146
+ );
147
+
148
+ expect(reason).toBe("completed");
149
+ expect(accumulator.processEvent).toHaveBeenCalledTimes(2);
150
+ // The #1 retry gap: the bare loop never persisted mid-stream. This proves the
151
+ // shared loop persists live.
152
+ expect(persist).toHaveBeenCalled();
153
+ expect(state.eventCount).toBe(2);
154
+ });
155
+
156
+ it("tracks a sub-agent delegation (the 'task' tool call) — dropped by the old bare retry loop", async () => {
157
+ const { deps, accumulator } = buildDeps();
158
+
159
+ await consumeCursorTurnStream(
160
+ mockRun([ev({ type: "tool_call", name: "task" })]),
161
+ deps,
162
+ );
163
+
164
+ expect(accumulator.trackSubAgentExecution).toHaveBeenCalledTimes(1);
165
+ });
166
+
167
+ it("attaches DD-32/DD-33 mid-run progress when a substrate is present", async () => {
168
+ const capture = vi.fn(async () => ({ delta: { entries: [] }, changed: true }));
169
+ const progressSubstrate = { capture } as unknown as ProgressSubstrate;
170
+ const { deps, status } = buildDeps({ progressSubstrate });
171
+
172
+ await consumeCursorTurnStream(mockRun([ev({ type: "assistant" })]), deps);
173
+
174
+ expect(capture).toHaveBeenCalled();
175
+ // changed:true → the transient snapshot is attached to status.
176
+ expect(status.fileChangeProgress).toBeDefined();
177
+ });
178
+
179
+ it("does not touch progress when no substrate is configured", async () => {
180
+ const { deps, status } = buildDeps({ progressSubstrate: undefined });
181
+
182
+ await consumeCursorTurnStream(mockRun([ev({ type: "assistant" })]), deps);
183
+
184
+ expect(status.fileChangeProgress).toBeUndefined();
185
+ });
186
+
187
+ it("returns 'paused' and sets pauseDetected when the activity is cancelled", async () => {
188
+ const { deps, state, accumulator } = buildDeps({ isCancelled: () => true });
189
+
190
+ const reason = await consumeCursorTurnStream(mockRun([ev({ type: "assistant" })]), deps);
191
+
192
+ expect(reason).toBe("paused");
193
+ expect(state.pauseDetected).toBe(true);
194
+ // Cancellation is checked before the event is processed.
195
+ expect(accumulator.processEvent).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it("returns 'platform-stop' when a persist reports the STOP control signal", async () => {
199
+ const persist = vi.fn(async () => ExecutionControlSignal.STOP);
200
+ const { deps, state } = buildDeps({ persist });
201
+
202
+ const reason = await consumeCursorTurnStream(
203
+ mockRun([ev({ type: "assistant" }), ev({ type: "assistant" })]),
204
+ deps,
205
+ );
206
+
207
+ expect(reason).toBe("platform-stop");
208
+ expect(state.platformStopSignaled).toBe(true);
209
+ // Broke after the first persist — the second event never persisted.
210
+ expect(persist).toHaveBeenCalledTimes(1);
211
+ });
212
+
213
+ it("captures a stream ERROR status message onto state.streamErrorMessage", async () => {
214
+ const { deps, state } = buildDeps();
215
+
216
+ await consumeCursorTurnStream(
217
+ mockRun([ev({ type: "status", status: "ERROR", message: "boom" })]),
218
+ deps,
219
+ );
220
+
221
+ expect(state.streamErrorMessage).toBe("boom");
222
+ });
223
+
224
+ describe("first-denial early stop", () => {
225
+ let hitlDir: string;
226
+
227
+ afterEach(async () => {
228
+ if (hitlDir) await rm(hitlDir, { recursive: true, force: true });
229
+ });
230
+
231
+ it("stops the turn, cancels the run, and arms denialCancelSettled on a ledger entry", async () => {
232
+ hitlDir = await mkdtemp(join(tmpdir(), "turn-stream-denial-"));
233
+ await writeFile(
234
+ join(hitlDir, "denials.jsonl"),
235
+ JSON.stringify({ toolName: "shell", token: "tok-1" }) + "\n",
236
+ "utf-8",
237
+ );
238
+ const { deps, state } = buildDeps({ hitlDir });
239
+ const run = mockRun([ev({ type: "tool_call", name: "shell" })]);
240
+
241
+ const reason = await consumeCursorTurnStream(run, deps);
242
+
243
+ expect(reason).toBe("first-denial");
244
+ expect(state.firstDenialDetected).toBe(true);
245
+ expect(run.cancel).toHaveBeenCalled();
246
+ expect(state.denialCancelSettled).toBeDefined();
247
+ });
248
+
249
+ it("does not stop when the ledger is empty", async () => {
250
+ hitlDir = await mkdtemp(join(tmpdir(), "turn-stream-nodenial-"));
251
+ await writeFile(join(hitlDir, "denials.jsonl"), "", "utf-8");
252
+ const { deps, state } = buildDeps({ hitlDir });
253
+
254
+ const reason = await consumeCursorTurnStream(
255
+ mockRun([ev({ type: "tool_call", name: "shell" })]),
256
+ deps,
257
+ );
258
+
259
+ expect(reason).toBe("completed");
260
+ expect(state.firstDenialDetected).toBe(false);
261
+ });
262
+ });
263
+
264
+ it("returns 'stalled' and cancels the run when the stall watchdog fires", async () => {
265
+ vi.useFakeTimers();
266
+ // The generator yields one event, then awaits a promise resolved only by
267
+ // run.cancel() — so the loop is suspended when the watchdog fires.
268
+ let unblock: (() => void) | undefined;
269
+ async function* gen(): AsyncIterable<SDKMessage> {
270
+ yield ev({ type: "assistant" });
271
+ await new Promise<void>((resolve) => {
272
+ unblock = resolve;
273
+ });
274
+ }
275
+ const run: MockRun = {
276
+ stream: () => gen(),
277
+ supports: () => true,
278
+ cancel: vi.fn(async () => {
279
+ unblock?.();
280
+ }),
281
+ };
282
+ const { deps, state } = buildDeps({ stallTimeoutMs: 40 });
283
+
284
+ const pending = consumeCursorTurnStream(run, deps);
285
+ // Advance past the stall window (tick = stallMs/4 = 10ms), flushing the
286
+ // microtasks between ticks so the loop reaches its await first.
287
+ await vi.advanceTimersByTimeAsync(80);
288
+ const reason = await pending;
289
+
290
+ expect(reason).toBe("stalled");
291
+ expect(state.stallDetected).toBe(true);
292
+ expect(run.cancel).toHaveBeenCalled();
293
+ });
294
+ });
295
+
296
+ describe("makeCursorTurnOnDelta", () => {
297
+ it("flags a pause (not a throw) when the heartbeat reports CancelledFailure", () => {
298
+ const state = newTurnStreamState();
299
+ const onDelta = makeCursorTurnOnDelta({
300
+ usageAccumulator: stubUsageAccumulator() as never,
301
+ deltaEnricher: stubEnricher() as never,
302
+ heartbeat: () => {
303
+ throw new CancelledFailure("paused");
304
+ },
305
+ promptEstimatedTokens: 10,
306
+ executionId: "e",
307
+ state,
308
+ });
309
+
310
+ expect(() => onDelta({ update: { type: "text" } as never })).not.toThrow();
311
+ expect(state.pauseDetected).toBe(true);
312
+ });
313
+
314
+ it("rethrows a non-cancellation heartbeat error", () => {
315
+ const state = newTurnStreamState();
316
+ const onDelta = makeCursorTurnOnDelta({
317
+ usageAccumulator: stubUsageAccumulator() as never,
318
+ deltaEnricher: stubEnricher() as never,
319
+ heartbeat: () => {
320
+ throw new Error("boom");
321
+ },
322
+ promptEstimatedTokens: 10,
323
+ executionId: "e",
324
+ state,
325
+ });
326
+
327
+ expect(() => onDelta({ update: { type: "text" } as never })).toThrow("boom");
328
+ expect(state.pauseDetected).toBe(false);
329
+ });
330
+
331
+ it("accumulates turn usage and logs first-turn attribution exactly once", () => {
332
+ const state = newTurnStreamState();
333
+ const usageAccumulator = stubUsageAccumulator();
334
+ const onDelta = makeCursorTurnOnDelta({
335
+ usageAccumulator: usageAccumulator as never,
336
+ deltaEnricher: stubEnricher() as never,
337
+ heartbeat: vi.fn(),
338
+ promptEstimatedTokens: 10,
339
+ executionId: "e",
340
+ state,
341
+ });
342
+
343
+ onDelta({ update: { type: "turn-ended", usage: { inputTokens: 100 } } as never });
344
+ onDelta({ update: { type: "turn-ended", usage: { inputTokens: 50 } } as never });
345
+
346
+ expect(usageAccumulator.addTurn).toHaveBeenCalledTimes(2);
347
+ expect(state.firstTurnAttributionLogged).toBe(true);
348
+ });
349
+ });