@stigmer/runner 3.1.2 → 3.1.3
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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
- package/dist/activities/execute-cursor/capture-flow.js +21 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +121 -139
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
- package/dist/activities/execute-cursor/turn-boundary.js +163 -0
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
- package/dist/activities/execute-deep-agent/index.js +30 -3
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
- package/dist/activities/execute-deep-agent/streaming.js +5 -1
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/shared/filereview/capture.d.ts +18 -1
- package/dist/shared/filereview/capture.js +105 -28
- package/dist/shared/filereview/capture.js.map +1 -1
- package/dist/shared/filereview/cas-substrate.d.ts +9 -0
- package/dist/shared/filereview/cas-substrate.js +9 -1
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- package/dist/shared/filereview/events.d.ts +16 -0
- package/dist/shared/filereview/events.js +37 -0
- package/dist/shared/filereview/events.js.map +1 -1
- package/dist/shared/filereview/git-substrate.d.ts +42 -0
- package/dist/shared/filereview/git-substrate.js +78 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -1
- package/dist/shared/filereview/index.d.ts +3 -1
- package/dist/shared/filereview/index.js +3 -1
- package/dist/shared/filereview/index.js.map +1 -1
- package/dist/shared/filereview/line-counts.d.ts +48 -0
- package/dist/shared/filereview/line-counts.js +72 -0
- package/dist/shared/filereview/line-counts.js.map +1 -0
- package/dist/shared/filereview/progress.d.ts +96 -0
- package/dist/shared/filereview/progress.js +134 -0
- package/dist/shared/filereview/progress.js.map +1 -0
- package/dist/shared/implement-plan-prompt.d.ts +6 -4
- package/dist/shared/implement-plan-prompt.js +38 -6
- package/dist/shared/implement-plan-prompt.js.map +1 -1
- package/dist/shared/plan-artifact.d.ts +59 -11
- package/dist/shared/plan-artifact.js +144 -16
- package/dist/shared/plan-artifact.js.map +1 -1
- package/dist/shared/plan-mode-prompt.d.ts +6 -4
- package/dist/shared/plan-mode-prompt.js +10 -5
- package/dist/shared/plan-mode-prompt.js.map +1 -1
- package/package.json +3 -2
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +7 -0
- package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
- package/src/activities/execute-cursor/capture-flow.ts +32 -0
- package/src/activities/execute-cursor/index.ts +130 -153
- package/src/activities/execute-cursor/turn-boundary.ts +274 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +11 -0
- package/src/activities/execute-deep-agent/index.ts +34 -3
- package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
- package/src/activities/execute-deep-agent/streaming.ts +12 -1
- package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
- package/src/shared/__tests__/plan-artifact.test.ts +142 -9
- package/src/shared/filereview/__tests__/capture.test.ts +182 -1
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
- package/src/shared/filereview/__tests__/events.test.ts +105 -0
- package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
- package/src/shared/filereview/__tests__/progress.test.ts +236 -0
- package/src/shared/filereview/capture.ts +134 -30
- package/src/shared/filereview/cas-substrate.ts +17 -1
- package/src/shared/filereview/events.ts +50 -0
- package/src/shared/filereview/git-substrate.ts +108 -0
- package/src/shared/filereview/index.ts +22 -1
- package/src/shared/filereview/line-counts.ts +83 -0
- package/src/shared/filereview/progress.ts +186 -0
- package/src/shared/implement-plan-prompt.ts +39 -8
- package/src/shared/plan-artifact.ts +152 -16
- package/src/shared/plan-mode-prompt.ts +10 -5
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Plan mode's output contract spans three components that must agree:
|
|
5
5
|
* - the model produces the plan as its FINAL message (this directive),
|
|
6
|
-
* - the runner publishes that final message verbatim as a
|
|
7
|
-
* (`plan-artifact.ts` —
|
|
6
|
+
* - the runner publishes that final message verbatim as a plan markdown
|
|
7
|
+
* artifact, named from the plan's title (`plan-artifact.ts` —
|
|
8
|
+
* `extractFinalPlanText`, `planArtifactName`),
|
|
8
9
|
* - the SDK promotes the same message to a first-class plan document in the
|
|
9
10
|
* thread and offers "Build from plan".
|
|
10
11
|
*
|
|
@@ -24,8 +25,9 @@
|
|
|
24
25
|
/**
|
|
25
26
|
* Directive body injected into the system prompt of every Plan-mode
|
|
26
27
|
* execution. Deliberately explicit about the deliverable's shape: the final
|
|
27
|
-
* message is published verbatim as
|
|
28
|
-
*
|
|
28
|
+
* message is published verbatim as a plan document (whose filename is derived
|
|
29
|
+
* from the leading `#` title), so a fenced or chat-suffixed plan degrades the
|
|
30
|
+
* reviewable document the user sees.
|
|
29
31
|
*/
|
|
30
32
|
export const PLAN_MODE_DIRECTIVE = [
|
|
31
33
|
"IMPORTANT: You are in Plan mode — a read-only analysis turn whose " +
|
|
@@ -37,10 +39,13 @@ export const PLAN_MODE_DIRECTIVE = [
|
|
|
37
39
|
"- Only read, search, and analyze.",
|
|
38
40
|
"",
|
|
39
41
|
"Deliverable — your FINAL message IS the plan. It is published verbatim " +
|
|
40
|
-
"as a
|
|
42
|
+
"as a plan document that the user reviews and builds from, so:",
|
|
41
43
|
"- Write it as a complete, well-structured markdown document: start with " +
|
|
42
44
|
"a single `#` title and organize the work under `##` section headings. " +
|
|
43
45
|
"Use lists and tables where they aid scanning.",
|
|
46
|
+
'- Give the `#` title a concise, descriptive name for the work itself; do ' +
|
|
47
|
+
'NOT prefix it with "Plan:" (this document is already a plan — the ' +
|
|
48
|
+
"prefix is redundant and leaks into the plan's filename).",
|
|
44
49
|
"- Reference concrete file paths and describe the specific changes " +
|
|
45
50
|
"planned for each.",
|
|
46
51
|
"- Do NOT wrap the document in a code fence.",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plan-mode-prompt.js","sourceRoot":"","sources":["../../src/shared/plan-mode-prompt.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"plan-mode-prompt.js","sourceRoot":"","sources":["../../src/shared/plan-mode-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,oEAAoE;QAClE,wCAAwC;IAC1C,EAAE;IACF,cAAc;IACd,6CAA6C;IAC7C,yEAAyE;IACzE,mCAAmC;IACnC,EAAE;IACF,yEAAyE;QACvE,+DAA+D;IACjE,0EAA0E;QACxE,wEAAwE;QACxE,+CAA+C;IACjD,2EAA2E;QACzE,oEAAoE;QACpE,0DAA0D;IAC5D,oEAAoE;QAClE,mBAAmB;IACrB,6CAA6C;IAC7C,uEAAuE;QACrE,uEAAuE;QACvE,kDAAkD;CACrD,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/runner",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.3",
|
|
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.
|
|
88
|
+
"@stigmer/protos": "3.1.3",
|
|
89
89
|
"@temporalio/activity": "^1.11.0",
|
|
90
90
|
"@temporalio/client": "^1.11.0",
|
|
91
91
|
"@temporalio/common": "^1.11.0",
|
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
"@temporalio/worker": "^1.11.0",
|
|
94
94
|
"@temporalio/workflow": "^1.16.2",
|
|
95
95
|
"deepagents": "^1.10.0",
|
|
96
|
+
"diff": "^8.0.4",
|
|
96
97
|
"jq-wasm": "^1.1.0-jq-1.8.1",
|
|
97
98
|
"js-yaml": "^4.1.1",
|
|
98
99
|
"proper-lockfile": "^4.1.2",
|
|
@@ -332,6 +332,13 @@ describe("formatImplementPlanSection", () => {
|
|
|
332
332
|
expect(formatImplementPlanSection(undefined, [PLAN_PATH])).toBeUndefined();
|
|
333
333
|
});
|
|
334
334
|
|
|
335
|
+
it("carries the plan-derived progress-tracking instruction (Tier 3)", () => {
|
|
336
|
+
const section = formatImplementPlanSection(true, [PLAN_PATH]);
|
|
337
|
+
|
|
338
|
+
expect(section).toContain("to-do list");
|
|
339
|
+
expect(section).toContain("break the plan into");
|
|
340
|
+
});
|
|
341
|
+
|
|
335
342
|
it("injects the directive into a build-from-plan first prompt", () => {
|
|
336
343
|
const prompt = buildPrompt(
|
|
337
344
|
input({
|
|
@@ -240,3 +240,71 @@ describe("DeltaEnricher finalize reconciliation", () => {
|
|
|
240
240
|
expect(messages[1].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
241
241
|
});
|
|
242
242
|
});
|
|
243
|
+
|
|
244
|
+
describe("DeltaEnricher recovery-retry stream epilogue", () => {
|
|
245
|
+
// Regression for the zombie RUNNING row of aex_01kws27q1e2esvkqjpvectttxf:
|
|
246
|
+
// the recovery retry's bare stream loop buffered the tool-call-completed
|
|
247
|
+
// delta but never flushed it, so the row lacked the completedAt evidence
|
|
248
|
+
// finalize's sweep requires and persisted as RUNNING forever. The epilogue
|
|
249
|
+
// must applyEnrichments (flush the buffered evidence) BEFORE finalize.
|
|
250
|
+
it("applyEnrichments + finalize promotes a retry's buffered-completion RUNNING row", () => {
|
|
251
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
252
|
+
|
|
253
|
+
const enricher = new DeltaEnricher();
|
|
254
|
+
const messages: AgentMessage[] = [
|
|
255
|
+
makeMessage([{ id: "tc-retry", name: "Shell", status: ToolCallStatus.TOOL_CALL_RUNNING }]),
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
// The retry's onDelta buffered the completion, but the bare retry loop
|
|
259
|
+
// never applied it to the transcript.
|
|
260
|
+
enricher.processDelta({
|
|
261
|
+
type: "tool-call-completed",
|
|
262
|
+
callId: "tc-retry",
|
|
263
|
+
toolCall: { type: "shell" },
|
|
264
|
+
} as unknown as InteractionUpdate);
|
|
265
|
+
|
|
266
|
+
// Without the flush, finalize alone cannot promote (no evidence on the row).
|
|
267
|
+
const unflushed = makeMessage([
|
|
268
|
+
{ id: "tc-retry", name: "Shell", status: ToolCallStatus.TOOL_CALL_RUNNING },
|
|
269
|
+
]);
|
|
270
|
+
new DeltaEnricher().finalize([unflushed]);
|
|
271
|
+
expect(unflushed.toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_RUNNING);
|
|
272
|
+
|
|
273
|
+
// The epilogue sequence: flush the buffered evidence, then finalize.
|
|
274
|
+
enricher.applyEnrichments(messages);
|
|
275
|
+
enricher.finalize(messages);
|
|
276
|
+
|
|
277
|
+
const tc = messages[0].toolCalls[0];
|
|
278
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
279
|
+
expect(tc.completedAt).toBeTruthy();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("re-running applyEnrichments and finalize is idempotent", () => {
|
|
283
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
284
|
+
|
|
285
|
+
const enricher = new DeltaEnricher();
|
|
286
|
+
const messages: AgentMessage[] = [
|
|
287
|
+
makeMessage([{ id: "tc-1", name: "Shell", status: ToolCallStatus.TOOL_CALL_RUNNING }]),
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
enricher.processDelta({
|
|
291
|
+
type: "tool-call-completed",
|
|
292
|
+
callId: "tc-1",
|
|
293
|
+
toolCall: { type: "shell" },
|
|
294
|
+
} as unknown as InteractionUpdate);
|
|
295
|
+
|
|
296
|
+
// Primary epilogue ...
|
|
297
|
+
enricher.applyEnrichments(messages);
|
|
298
|
+
enricher.finalize(messages);
|
|
299
|
+
const stamped = messages[0].toolCalls[0].completedAt;
|
|
300
|
+
|
|
301
|
+
// ... and the retry epilogue re-runs both. The row must keep its original
|
|
302
|
+
// completion evidence and terminal status.
|
|
303
|
+
enricher.applyEnrichments(messages);
|
|
304
|
+
enricher.finalize(messages);
|
|
305
|
+
|
|
306
|
+
const tc = messages[0].toolCalls[0];
|
|
307
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
308
|
+
expect(tc.completedAt).toBe(stamped);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the extracted turn boundary (turn-boundary.ts) — the post-run
|
|
3
|
+
* pipeline shared by the primary path and the recovery retries.
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing scenario is the recovery-retry sequence that motivated the
|
|
6
|
+
* extraction (production case aex_01kws27q1e2esvkqjpvectttxf): the primary
|
|
7
|
+
* boundary runs against an untouched tree (the resumed agent errored before
|
|
8
|
+
* doing anything), the retry agent then edits files, and the boundary is
|
|
9
|
+
* re-entered — the candidate must be authored exactly once and the turn must
|
|
10
|
+
* pause for review. Runs against a REAL temp git repo with in-memory
|
|
11
|
+
* transcript + status protos, mirroring capture-flow.test.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
20
|
+
import { create } from "@bufbuild/protobuf";
|
|
21
|
+
import {
|
|
22
|
+
AgentMessageSchema,
|
|
23
|
+
ToolCallSchema,
|
|
24
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
25
|
+
import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
26
|
+
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
27
|
+
import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
28
|
+
import {
|
|
29
|
+
FileReviewEventType,
|
|
30
|
+
MessageType,
|
|
31
|
+
ToolCallStatus,
|
|
32
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
33
|
+
import { captureBaselineToLedger } from "../capture-flow.js";
|
|
34
|
+
import { denialLedgerPath } from "../approval-state.js";
|
|
35
|
+
import { toolCallIdentityToken } from "../message-translator.js";
|
|
36
|
+
import { runTurnBoundary, type TurnBoundaryOptions } from "../turn-boundary.js";
|
|
37
|
+
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
const EXEC_ID = "exec-boundary-1";
|
|
40
|
+
const CHANGE_SET_ID = `${EXEC_ID}:0`;
|
|
41
|
+
|
|
42
|
+
let repo: string;
|
|
43
|
+
let hitlDir: string;
|
|
44
|
+
|
|
45
|
+
async function git(args: string[]): Promise<void> {
|
|
46
|
+
await execFileAsync("git", args, { cwd: repo });
|
|
47
|
+
}
|
|
48
|
+
async function write(rel: string, content: string): Promise<void> {
|
|
49
|
+
await writeFile(join(repo, rel), content, "utf-8");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function newStatus(): AgentExecutionStatus {
|
|
53
|
+
return create(AgentExecutionStatusSchema, {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A streamed (COMPLETED) file-edit tool call, as the SDK would have recorded. */
|
|
57
|
+
function streamedEdit(id: string, path: string, content: string): AgentMessage {
|
|
58
|
+
return create(AgentMessageSchema, {
|
|
59
|
+
type: MessageType.MESSAGE_AI,
|
|
60
|
+
toolCalls: [
|
|
61
|
+
create(ToolCallSchema, {
|
|
62
|
+
id,
|
|
63
|
+
name: "edit",
|
|
64
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
65
|
+
args: { path, content },
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Boundary options for this repo's turn; overrides layer the per-test shape. */
|
|
72
|
+
function boundaryOpts(
|
|
73
|
+
status: AgentExecutionStatus,
|
|
74
|
+
baselineTree: string,
|
|
75
|
+
overrides?: Partial<TurnBoundaryOptions>,
|
|
76
|
+
): TurnBoundaryOptions {
|
|
77
|
+
return {
|
|
78
|
+
status,
|
|
79
|
+
executionId: EXEC_ID,
|
|
80
|
+
changeSetId: CHANGE_SET_ID,
|
|
81
|
+
hitlDir,
|
|
82
|
+
captureMode: true,
|
|
83
|
+
baselineTree,
|
|
84
|
+
primaryWorkspaceDir: repo,
|
|
85
|
+
gitWorkspace: true,
|
|
86
|
+
turnStartMessageIndex: 0,
|
|
87
|
+
approvalGrants: undefined,
|
|
88
|
+
globalBypass: false,
|
|
89
|
+
seededSubAgents: [],
|
|
90
|
+
artifactStorage: undefined,
|
|
91
|
+
mergedPolicies: new Map(),
|
|
92
|
+
...overrides,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function candidateEvents(status: AgentExecutionStatus) {
|
|
97
|
+
return (status.fileReviewEventStream?.events ?? []).filter(
|
|
98
|
+
(e) => e.eventType === FileReviewEventType.CANDIDATE_CAPTURED,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeEach(async () => {
|
|
103
|
+
repo = await mkdtemp(join(tmpdir(), "stigmer-boundary-repo-"));
|
|
104
|
+
hitlDir = await mkdtemp(join(tmpdir(), "stigmer-boundary-hitl-"));
|
|
105
|
+
await git(["init", "-q"]);
|
|
106
|
+
await git(["config", "user.email", "t@t.local"]);
|
|
107
|
+
await git(["config", "user.name", "t"]);
|
|
108
|
+
await write("notes.md", "original notes\n");
|
|
109
|
+
await git(["add", "-A"]);
|
|
110
|
+
await git(["commit", "-q", "-m", "initial"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(async () => {
|
|
114
|
+
await rm(repo, { recursive: true, force: true });
|
|
115
|
+
await rm(hitlDir, { recursive: true, force: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("runTurnBoundary", () => {
|
|
119
|
+
it("authors a candidate, stamps the row, and pauses when the turn edited files", async () => {
|
|
120
|
+
const status = newStatus();
|
|
121
|
+
const baseline = await captureBaselineToLedger({
|
|
122
|
+
status,
|
|
123
|
+
gitRoot: repo,
|
|
124
|
+
executionId: EXEC_ID,
|
|
125
|
+
changeSetId: CHANGE_SET_ID,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await write("notes.md", "original notes\n\n## TODO\n- ship\n");
|
|
129
|
+
status.messages.push(
|
|
130
|
+
streamedEdit("tc-1", "notes.md", "original notes\n\n## TODO\n- ship\n"),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
134
|
+
|
|
135
|
+
expect(result.waiting).toBe(true);
|
|
136
|
+
expect(result.capturedChangeCount).toBe(1);
|
|
137
|
+
expect(result.deniedToolCallCount).toBe(0);
|
|
138
|
+
expect(candidateEvents(status)).toHaveLength(1);
|
|
139
|
+
// The streamed row is stamped with the change set id (observational record
|
|
140
|
+
// anchoring the decision surface).
|
|
141
|
+
expect(status.messages[0].toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reports a clean turn when nothing changed (no candidate, no pause)", async () => {
|
|
145
|
+
const status = newStatus();
|
|
146
|
+
const baseline = await captureBaselineToLedger({
|
|
147
|
+
status,
|
|
148
|
+
gitRoot: repo,
|
|
149
|
+
executionId: EXEC_ID,
|
|
150
|
+
changeSetId: CHANGE_SET_ID,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
154
|
+
|
|
155
|
+
expect(result.waiting).toBe(false);
|
|
156
|
+
expect(result.capturedChangeCount).toBe(0);
|
|
157
|
+
expect(result.deniedToolCallCount).toBe(0);
|
|
158
|
+
expect(candidateEvents(status)).toHaveLength(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("skips capture when no baseline was authored this turn", async () => {
|
|
162
|
+
const status = newStatus();
|
|
163
|
+
await write("notes.md", "edited without a baseline\n");
|
|
164
|
+
|
|
165
|
+
const result = await runTurnBoundary(
|
|
166
|
+
boundaryOpts(status, "", { baselineTree: undefined }),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(result.waiting).toBe(false);
|
|
170
|
+
expect(result.capturedChangeCount).toBe(0);
|
|
171
|
+
expect(candidateEvents(status)).toHaveLength(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("re-entered after a no-op primary call, authors the candidate exactly once (recovery-retry sequence)", async () => {
|
|
175
|
+
const status = newStatus();
|
|
176
|
+
const baseline = await captureBaselineToLedger({
|
|
177
|
+
status,
|
|
178
|
+
gitRoot: repo,
|
|
179
|
+
executionId: EXEC_ID,
|
|
180
|
+
changeSetId: CHANGE_SET_ID,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Primary boundary: the resumed agent errored before touching anything —
|
|
184
|
+
// the tree is at baseline, no candidate is authored, the turn looks clean.
|
|
185
|
+
const primary = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
186
|
+
expect(primary.waiting).toBe(false);
|
|
187
|
+
expect(candidateEvents(status)).toHaveLength(0);
|
|
188
|
+
|
|
189
|
+
// Recovery retry: a fresh agent re-ran the prompt and created the file.
|
|
190
|
+
await write("notes.md", "original notes\n\nretry made this edit\n");
|
|
191
|
+
status.messages.push(
|
|
192
|
+
streamedEdit("tc-retry", "notes.md", "original notes\n\nretry made this edit\n"),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Boundary re-entry: the retry's edit reaches the ledger and arms the gate.
|
|
196
|
+
const retry = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
197
|
+
expect(retry.waiting).toBe(true);
|
|
198
|
+
expect(retry.capturedChangeCount).toBe(1);
|
|
199
|
+
expect(candidateEvents(status)).toHaveLength(1);
|
|
200
|
+
expect(status.messages[0].toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("surfaces a hook denial as a WAITING_APPROVAL gate and pauses", async () => {
|
|
204
|
+
const status = newStatus();
|
|
205
|
+
const baseline = await captureBaselineToLedger({
|
|
206
|
+
status,
|
|
207
|
+
gitRoot: repo,
|
|
208
|
+
executionId: EXEC_ID,
|
|
209
|
+
changeSetId: CHANGE_SET_ID,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// The hook gated a shell command mid-turn; the streamed call is still
|
|
213
|
+
// RUNNING (Cursor reported the deny to the model, not a completion).
|
|
214
|
+
const shellCall = create(ToolCallSchema, {
|
|
215
|
+
id: "tc-shell",
|
|
216
|
+
name: "shell",
|
|
217
|
+
status: ToolCallStatus.TOOL_CALL_RUNNING,
|
|
218
|
+
args: { command: "rm -rf build" },
|
|
219
|
+
});
|
|
220
|
+
status.messages.push(
|
|
221
|
+
create(AgentMessageSchema, {
|
|
222
|
+
type: MessageType.MESSAGE_AI,
|
|
223
|
+
content: "cleaning the build dir",
|
|
224
|
+
toolCalls: [shellCall],
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
await writeFile(
|
|
228
|
+
denialLedgerPath(hitlDir),
|
|
229
|
+
JSON.stringify({ toolName: "shell", token: toolCallIdentityToken(shellCall) }) + "\n",
|
|
230
|
+
"utf-8",
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
234
|
+
|
|
235
|
+
expect(result.waiting).toBe(true);
|
|
236
|
+
expect(result.deniedToolCallCount).toBe(1);
|
|
237
|
+
expect(result.capturedChangeCount).toBe(0);
|
|
238
|
+
expect(shellCall.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("waits for the denial-stop cancel to settle before reading the ledger", async () => {
|
|
242
|
+
const status = newStatus();
|
|
243
|
+
const baseline = await captureBaselineToLedger({
|
|
244
|
+
status,
|
|
245
|
+
gitRoot: repo,
|
|
246
|
+
executionId: EXEC_ID,
|
|
247
|
+
changeSetId: CHANGE_SET_ID,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const shellCall = create(ToolCallSchema, {
|
|
251
|
+
id: "tc-late",
|
|
252
|
+
name: "shell",
|
|
253
|
+
status: ToolCallStatus.TOOL_CALL_RUNNING,
|
|
254
|
+
args: { command: "curl example.com" },
|
|
255
|
+
});
|
|
256
|
+
status.messages.push(
|
|
257
|
+
create(AgentMessageSchema, {
|
|
258
|
+
type: MessageType.MESSAGE_AI,
|
|
259
|
+
toolCalls: [shellCall],
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// The denial lands only while the cancel is settling — a premature ledger
|
|
264
|
+
// read would miss it and leave the row RUNNING forever.
|
|
265
|
+
const denialCancelSettled = (async () => {
|
|
266
|
+
await writeFile(
|
|
267
|
+
denialLedgerPath(hitlDir),
|
|
268
|
+
JSON.stringify({ toolName: "shell", token: toolCallIdentityToken(shellCall) }) + "\n",
|
|
269
|
+
"utf-8",
|
|
270
|
+
);
|
|
271
|
+
})();
|
|
272
|
+
|
|
273
|
+
const result = await runTurnBoundary(
|
|
274
|
+
boundaryOpts(status, baseline, { denialCancelSettled }),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(result.deniedToolCallCount).toBe(1);
|
|
278
|
+
expect(shellCall.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Placement is always `inputs/{filename}` — the platform namespace this
|
|
18
18
|
* harness can surface in the workspace. An attachment's `mountPath` is
|
|
19
19
|
* honored by convention, not mechanism: the standard mounts (e.g. the
|
|
20
|
-
* approved plan at `.stigmer/inputs
|
|
20
|
+
* approved plan at `.stigmer/inputs/<slug>_<id>.plan.md`) resolve to exactly this
|
|
21
21
|
* placement, and the prompt's `<input_files>` section plus any path-derived
|
|
22
22
|
* directives are built from the RESOLVED paths, so prompt and filesystem can
|
|
23
23
|
* never disagree.
|
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
captureCandidateToLedger as sharedCaptureCandidateToLedger,
|
|
36
36
|
type CaptureResumeResult,
|
|
37
37
|
} from "../../shared/filereview/capture.js";
|
|
38
|
+
import {
|
|
39
|
+
captureFileChangeProgress,
|
|
40
|
+
type ProgressCaptureState,
|
|
41
|
+
} from "../../shared/filereview/progress.js";
|
|
38
42
|
import { hasCandidateCaptured } from "../../shared/filereview/events.js";
|
|
39
43
|
import { partitionIgnoredPathsBySecret } from "../../shared/filereview/secret-paths.js";
|
|
40
44
|
import { casBlobReader, type CasPathCapture } from "../../shared/filereview/cas-substrate.js";
|
|
@@ -197,6 +201,34 @@ export async function captureTurnToLedger(opts: {
|
|
|
197
201
|
return changes;
|
|
198
202
|
}
|
|
199
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Mid-run: attach the live "N files changed so far" snapshot onto
|
|
206
|
+
* `status.file_change_progress` (DD-32), throttled by the floor + tree-sha
|
|
207
|
+
* short-circuit inside {@link captureFileChangeProgress}. The Cursor adapter binds
|
|
208
|
+
* `CURSOR_RUNNER_OWNED_PATHS` so the progress diff excludes the gate's own files —
|
|
209
|
+
* the SAME exclusion {@link captureTurnToLedger} uses for the turn-boundary
|
|
210
|
+
* candidate, so the live count and the reviewed set agree. Content-free and
|
|
211
|
+
* secret-safe; a no-op when nothing changed since the last capture.
|
|
212
|
+
*/
|
|
213
|
+
export async function captureProgressToStatus(opts: {
|
|
214
|
+
readonly status: AgentExecutionStatus;
|
|
215
|
+
readonly gitRoot: string;
|
|
216
|
+
readonly executionId: string;
|
|
217
|
+
readonly changeSetId: string;
|
|
218
|
+
readonly baselineTree: string;
|
|
219
|
+
readonly state: ProgressCaptureState;
|
|
220
|
+
}): Promise<void> {
|
|
221
|
+
await captureFileChangeProgress({
|
|
222
|
+
status: opts.status,
|
|
223
|
+
gitRoot: opts.gitRoot,
|
|
224
|
+
executionId: opts.executionId,
|
|
225
|
+
changeSetId: opts.changeSetId,
|
|
226
|
+
baselineTree: opts.baselineTree,
|
|
227
|
+
excludePaths: CURSOR_RUNNER_OWNED_PATHS,
|
|
228
|
+
state: opts.state,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
200
232
|
/**
|
|
201
233
|
* Compose this turn's CAS captures from the sidecar the hook staged, mirroring
|
|
202
234
|
* deep-agent's `buildCasTurnCaptures`: for each non-secret observed path, the
|