@stigmer/runner 3.19.1-dev.20260920100552 → 3.21.0

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 (70) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/__test-utils__/execution-record-fixture.d.ts +7 -0
  3. package/dist/__test-utils__/execution-record-fixture.js +10 -1
  4. package/dist/__test-utils__/execution-record-fixture.js.map +1 -1
  5. package/dist/__test-utils__/hermetic-activity.d.ts +7 -4
  6. package/dist/__test-utils__/hermetic-activity.js +12 -2
  7. package/dist/__test-utils__/hermetic-activity.js.map +1 -1
  8. package/dist/__test-utils__/turn-input-fixture.js +0 -1
  9. package/dist/__test-utils__/turn-input-fixture.js.map +1 -1
  10. package/dist/activities/execute-cursor/prompt-builder.js +8 -3
  11. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  12. package/dist/activities/execute-cursor/turn-settle.js +4 -1
  13. package/dist/activities/execute-cursor/turn-settle.js.map +1 -1
  14. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +1 -3
  15. package/dist/activities/execute-deep-agent/prompt-builder.js +2 -3
  16. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  17. package/dist/activities/generate-session-subject.d.ts +2 -1
  18. package/dist/activities/generate-session-subject.js +18 -10
  19. package/dist/activities/generate-session-subject.js.map +1 -1
  20. package/dist/harness/run-turn.d.ts +3 -2
  21. package/dist/harness/run-turn.js +33 -6
  22. package/dist/harness/run-turn.js.map +1 -1
  23. package/dist/harness/transcript/builder.d.ts +26 -2
  24. package/dist/harness/transcript/builder.js +35 -2
  25. package/dist/harness/transcript/builder.js.map +1 -1
  26. package/dist/harness/turn-context.js +3 -1
  27. package/dist/harness/turn-context.js.map +1 -1
  28. package/dist/harness/turn-timeline.d.ts +132 -0
  29. package/dist/harness/turn-timeline.js +213 -0
  30. package/dist/harness/turn-timeline.js.map +1 -0
  31. package/dist/shared/blueprint-resolver.d.ts +14 -4
  32. package/dist/shared/blueprint-resolver.js +23 -10
  33. package/dist/shared/blueprint-resolver.js.map +1 -1
  34. package/dist/shared/builtin-assistant-prompt.d.ts +30 -0
  35. package/dist/shared/builtin-assistant-prompt.js +36 -0
  36. package/dist/shared/builtin-assistant-prompt.js.map +1 -0
  37. package/dist/shared/cold-start-timing.d.ts +18 -2
  38. package/dist/shared/cold-start-timing.js +30 -3
  39. package/dist/shared/cold-start-timing.js.map +1 -1
  40. package/package.json +5 -5
  41. package/src/__test-utils__/execution-record-fixture.ts +17 -1
  42. package/src/__test-utils__/hermetic-activity.ts +19 -6
  43. package/src/__test-utils__/timing-lines.ts +43 -0
  44. package/src/__test-utils__/turn-input-fixture.ts +0 -1
  45. package/src/activities/__tests__/generate-session-subject.test.ts +14 -0
  46. package/src/activities/execute-cursor/__tests__/goldens/prompt.enhanced.no-instructions.prompt.md +142 -0
  47. package/src/activities/execute-cursor/__tests__/hermetic/built-in-assistant.test.ts +127 -0
  48. package/src/activities/execute-cursor/__tests__/hermetic/turn-phases.test.ts +136 -0
  49. package/src/activities/execute-cursor/__tests__/prompt-goldens.test.ts +14 -1
  50. package/src/activities/execute-cursor/prompt-builder.ts +8 -3
  51. package/src/activities/execute-cursor/turn-settle.ts +4 -1
  52. package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.minimal.prompt.md +2 -1
  53. package/src/activities/execute-deep-agent/__tests__/hermetic/built-in-assistant.test.ts +101 -0
  54. package/src/activities/execute-deep-agent/__tests__/hermetic/turn-phases.test.ts +118 -0
  55. package/src/activities/execute-deep-agent/__tests__/prompt-goldens.test.ts +1 -1
  56. package/src/activities/execute-deep-agent/prompt-builder.ts +3 -5
  57. package/src/activities/generate-session-subject.ts +20 -13
  58. package/src/harness/README.md +4 -1
  59. package/src/harness/__tests__/run-turn.test.ts +53 -0
  60. package/src/harness/__tests__/turn-timeline.test.ts +274 -0
  61. package/src/harness/run-turn.ts +36 -6
  62. package/src/harness/transcript/__tests__/builder.test.ts +69 -1
  63. package/src/harness/transcript/builder.ts +39 -2
  64. package/src/harness/turn-context.ts +3 -1
  65. package/src/harness/turn-timeline.ts +248 -0
  66. package/src/shared/__tests__/blueprint-resolver.test.ts +78 -8
  67. package/src/shared/__tests__/cold-start-timing.test.ts +53 -5
  68. package/src/shared/blueprint-resolver.ts +25 -12
  69. package/src/shared/builtin-assistant-prompt.ts +38 -0
  70. package/src/shared/cold-start-timing.ts +29 -3
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Hermetic pin: the BUILT-IN ASSISTANT through the whole `ExecuteDeepAgent`
3
+ * activity — a session that names no agent instance, so the blueprint chain
4
+ * stops at the session (`shared/blueprint-resolver.ts`), the control plane
5
+ * is never asked for an instance or an agent, and the system prompt opens
6
+ * with the one built-in text (`shared/builtin-assistant-prompt.ts`).
7
+ *
8
+ * Invariant pinned: the agent-less record is a first-class turn, not an
9
+ * error arm — IN_PROGRESS then COMPLETED with the scripted answer, the same
10
+ * shape as `plain-turn.test.ts`; and the words the model saw are the shared
11
+ * ones, read off the scripted model's `systemPrompt` context. The status
12
+ * itself is not goldened here: nothing agent-shaped reaches the status, so
13
+ * the plain-turn golden already photographs it.
14
+ */
15
+
16
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
17
+ import { ExecutionPhase, MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
18
+
19
+ vi.mock("../../../../shared/model-client.js", async () =>
20
+ (await import("../../__test-utils__/scripted-model-module.js")).scriptedModelClientModule(),
21
+ );
22
+ vi.mock("../../../../client/stigmer-client.js", async () =>
23
+ (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
24
+ );
25
+
26
+ import {
27
+ ScriptedClock,
28
+ createHermeticEnvironment,
29
+ type HermeticEnvironment,
30
+ } from "../../../../__test-utils__/hermetic-activity.js";
31
+ import { stubRegistryFetch } from "../../../../__test-utils__/model-registry-fixture.js";
32
+ import { BUILT_IN_ASSISTANT_INSTRUCTIONS } from "../../../../shared/builtin-assistant-prompt.js";
33
+ import {
34
+ beginDeepAgentScenario,
35
+ deepAgentExecutionRecord,
36
+ runDeepAgentTurn,
37
+ } from "../../__test-utils__/hermetic-deep-agent.js";
38
+
39
+ const USER_MESSAGE = "Say hello.";
40
+ const ASSISTANT_TEXT = "Hello from the built-in assistant.";
41
+
42
+ describe("ExecuteDeepAgent hermetic — the built-in assistant", () => {
43
+ let env: HermeticEnvironment;
44
+ let registry: ReturnType<typeof stubRegistryFetch>;
45
+ const clock = new ScriptedClock();
46
+
47
+ beforeAll(() => {
48
+ env = createHermeticEnvironment();
49
+ registry = stubRegistryFetch();
50
+ clock.install();
51
+ });
52
+
53
+ afterAll(() => {
54
+ clock.uninstall();
55
+ registry.restore();
56
+ env.dispose();
57
+ });
58
+
59
+ it("runs a session with no agent to COMPLETED on the built-in prompt, never reading an instance or an agent", async () => {
60
+ clock.reset();
61
+ const record = deepAgentExecutionRecord({ message: USER_MESSAGE, builtInAssistant: true });
62
+ const systemPrompts: string[] = [];
63
+ const instanceRead = vi.fn(async () => {
64
+ throw new Error("must not be reached");
65
+ });
66
+ const agentRead = vi.fn(async () => {
67
+ throw new Error("must not be reached");
68
+ });
69
+ const scenario = beginDeepAgentScenario({
70
+ env,
71
+ clock,
72
+ record,
73
+ clientOverrides: { getAgentInstance: instanceRead, getAgent: agentRead },
74
+ script: (_tools, context) => {
75
+ systemPrompts.push(context.systemPrompt);
76
+ return { turns: [{ text: ASSISTANT_TEXT, usage: { inputTokens: 900, outputTokens: 20 } }] };
77
+ },
78
+ });
79
+
80
+ const invocation = await runDeepAgentTurn(scenario);
81
+
82
+ expect(invocation.outcome.kind).toBe("returned");
83
+ expect(record.persistedPhases).toEqual([
84
+ ExecutionPhase.EXECUTION_IN_PROGRESS,
85
+ ExecutionPhase.EXECUTION_COMPLETED,
86
+ ]);
87
+ const final = record.lastFullStatus!;
88
+ const ai = final.messages.filter((m) => m.type === MessageType.MESSAGE_AI);
89
+ expect(ai).toHaveLength(1);
90
+ expect(ai[0].content).toBe(ASSISTANT_TEXT);
91
+
92
+ // The chain stopped at the session: the refusing reads were never
93
+ // reached (they would have failed the resolve phase).
94
+ expect(instanceRead).not.toHaveBeenCalled();
95
+ expect(agentRead).not.toHaveBeenCalled();
96
+
97
+ // The model met the shared words, first.
98
+ expect(systemPrompts).toHaveLength(1);
99
+ expect(systemPrompts[0].startsWith(BUILT_IN_ASSISTANT_INSTRUCTIONS)).toBe(true);
100
+ });
101
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Hermetic proof: the native harness gets the runtime's `turn_phases` timing
3
+ * line without a line of its own.
4
+ *
5
+ * Invariant pinned: driving the REAL deepagents graph through the real native
6
+ * translator, the turn runtime writes exactly one `turn_phases` line for the
7
+ * turn, and the line reads what the translator's events said — one model
8
+ * round per LangGraph run (two here: the proposal and the closing text), one
9
+ * tool span for the `read_file` call, the three root instants present, the
10
+ * adapter's own name as `harness`. The fold and its rules are
11
+ * `harness/__tests__/turn-timeline.test.ts`'s; the runtime's act of writing
12
+ * the line is `harness/__tests__/run-turn.test.ts`'s against the fake. What
13
+ * only this file can prove is that the native translator emits the events the
14
+ * fold needs (`message_start`/`message_finish` per run, `tool_started`/
15
+ * `tool_finished` per call) in the order it needs them, on a real graph.
16
+ *
17
+ * Structural assertions only: the scripted clock makes every number
18
+ * deterministic, but pinning one would pin the clock's tick quantum, not a
19
+ * behaviour. The status goldens beside this file are untouched by design —
20
+ * the line is stdout, never the status — and their byte-identity under this
21
+ * change is proved by running them.
22
+ *
23
+ * Same scenario as `tool-call.test.ts`, so the two files read together.
24
+ */
25
+
26
+ import { mkdirSync, writeFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
29
+
30
+ vi.mock("../../../../shared/model-client.js", async () =>
31
+ (await import("../../__test-utils__/scripted-model-module.js")).scriptedModelClientModule(),
32
+ );
33
+ vi.mock("../../../../client/stigmer-client.js", async () =>
34
+ (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
35
+ );
36
+
37
+ import {
38
+ ScriptedClock,
39
+ createHermeticEnvironment,
40
+ type HermeticEnvironment,
41
+ } from "../../../../__test-utils__/hermetic-activity.js";
42
+ import { stubRegistryFetch } from "../../../../__test-utils__/model-registry-fixture.js";
43
+ import { captureTimingLines } from "../../../../__test-utils__/timing-lines.js";
44
+ import { TURN_PHASES_EVENT } from "../../../../harness/turn-timeline.js";
45
+ import {
46
+ beginDeepAgentScenario,
47
+ deepAgentExecutionRecord,
48
+ runDeepAgentTurn,
49
+ sessionWorkspaceDir,
50
+ } from "../../__test-utils__/hermetic-deep-agent.js";
51
+
52
+ const CALL_ID = "call-hermetic-read-0001";
53
+ const USER_MESSAGE = "What is in README.md?";
54
+ const README = "# Hermetic\n\nA fixture readme.\n";
55
+ const ASSISTANT_TEXT = "README.md holds a one-line fixture description.";
56
+
57
+ describe("ExecuteDeepAgent hermetic — the runtime's turn_phases line", () => {
58
+ let env: HermeticEnvironment;
59
+ let registry: ReturnType<typeof stubRegistryFetch>;
60
+ const clock = new ScriptedClock();
61
+
62
+ beforeAll(() => {
63
+ env = createHermeticEnvironment();
64
+ registry = stubRegistryFetch();
65
+ clock.install();
66
+ });
67
+
68
+ afterAll(() => {
69
+ clock.uninstall();
70
+ registry.restore();
71
+ env.dispose();
72
+ });
73
+
74
+ it("writes one line for the turn, reading the translator's rounds and tool spans", async () => {
75
+ const record = deepAgentExecutionRecord({ message: USER_MESSAGE });
76
+ const scenario = beginDeepAgentScenario({
77
+ env,
78
+ clock,
79
+ record,
80
+ script: () => ({
81
+ turns: [
82
+ {
83
+ text: "Let me read it.",
84
+ toolCalls: [{ id: CALL_ID, name: "read_file", args: { file_path: "/README.md" } }],
85
+ usage: { inputTokens: 1_500, outputTokens: 60 },
86
+ },
87
+ { text: ASSISTANT_TEXT, usage: { inputTokens: 1_700, outputTokens: 30 } },
88
+ ],
89
+ }),
90
+ });
91
+ const workspace = sessionWorkspaceDir(env);
92
+ mkdirSync(workspace, { recursive: true });
93
+ writeFileSync(join(workspace, "README.md"), README, "utf-8");
94
+
95
+ const lines = await captureTimingLines(TURN_PHASES_EVENT, async () => {
96
+ const invocation = await runDeepAgentTurn(scenario);
97
+ expect(invocation.outcome.kind).toBe("returned");
98
+ });
99
+
100
+ expect(lines, "one line per turn").toHaveLength(1);
101
+ const line = lines[0]!;
102
+ expect(line.harness, "the adapter's diagnostic name, the runtime's vocabulary").toBe("deep-agent");
103
+ expect(line.outcome).toBe("completed");
104
+ expect(line.execution_id).toBe(record.execution.metadata!.id);
105
+ expect(line.turn_seq).toBeTypeOf("number");
106
+
107
+ expect(line.rounds, "the proposing run and the closing run").toBe(2);
108
+ expect(line.tool_calls).toBe(1);
109
+ expect(line.sub_agents).toBe(0);
110
+ expect(line.segments.filter((s) => s.name === "model_round")).toHaveLength(2);
111
+ expect(line.segments.filter((s) => s.name === "tool:read_file")).toHaveLength(1);
112
+
113
+ expect(line.first_event_ms).toBeTypeOf("number");
114
+ expect(line.first_visible_token_ms).toBeTypeOf("number");
115
+ expect(line.first_text_ms).toBeTypeOf("number");
116
+ expect(line.total_ms).toBeGreaterThanOrEqual(line.first_text_ms as number);
117
+ });
118
+ });
@@ -20,7 +20,7 @@
20
20
  * the filter fires, channel templates, referenced files, three input files
21
21
  * with a rename and a download URL, the vision disclosure, all five standing
22
22
  * sections); the below-threshold skills branch; plan mode; build-from-plan;
23
- * and the minimal prompt (no instructions, so `DEFAULT_INSTRUCTIONS`). Plan
23
+ * and the minimal prompt (no instructions, so the built-in assistant's). Plan
24
24
  * mode and build-from-plan are mutually exclusive in production, so each has
25
25
  * its own golden.
26
26
  *
@@ -40,6 +40,7 @@ import {
40
40
  type StandingSectionKind,
41
41
  type VisionPromptInfo,
42
42
  } from "../../shared/prompt-sections.js";
43
+ import { effectiveInstructions } from "../../shared/builtin-assistant-prompt.js";
43
44
  import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
44
45
  import {
45
46
  buildImplementPlanDirective,
@@ -115,11 +116,8 @@ directly over delegating. Only delegate when context isolation \
115
116
  or parallelism genuinely helps the user.
116
117
  `;
117
118
 
118
- /** The agent's instructions when the blueprint carries none; the prompt is never empty (since #1096: was `setup.ts`'s alone). */
119
- export const DEFAULT_INSTRUCTIONS = "You are a helpful AI assistant.";
120
-
121
119
  export interface PromptBuilderInput {
122
- /** The blueprint's instructions, raw; empty falls back to {@link DEFAULT_INSTRUCTIONS}. */
120
+ /** The blueprint's instructions, raw; empty reads the built-in assistant's (shared/builtin-assistant-prompt.ts). */
123
121
  instructions: string;
124
122
  provisionResults: ProvisionResult[];
125
123
  containerRoot: string;
@@ -233,7 +231,7 @@ const STANDING_SECTION_HEADINGS: Record<StandingSectionKind, string> = {
233
231
  * sections. Pure function with no I/O.
234
232
  */
235
233
  export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
236
- let prompt = input.instructions || DEFAULT_INSTRUCTIONS;
234
+ let prompt = effectiveInstructions(input.instructions);
237
235
 
238
236
  const workspaceSection = buildWorkspacePromptSection(
239
237
  input.provisionResults,
@@ -137,22 +137,26 @@ export async function generateSessionSubject(
137
137
  return;
138
138
  }
139
139
 
140
+ // The agent, when the conversation has one, lends the title its purpose;
141
+ // the built-in assistant (no agent on the session) is titled from the
142
+ // message alone rather than left "Untitled".
143
+ let agentName = "";
144
+ let agentDescription = "";
140
145
  const agentId = await resolveAgentId(execution, session, client);
141
- if (agentId === "") {
142
- log(`cannot resolve agent_id for execution ${executionId}, skipping`);
143
- return;
146
+ if (agentId !== "") {
147
+ const agent = await getOrSkip(
148
+ () => client.getAgent(agentId),
149
+ `agent not found: ${agentId}`,
150
+ );
151
+ if (agent === undefined) return;
152
+ agentName = agent.metadata?.name ?? "";
153
+ agentDescription = agent.spec?.description ?? "";
144
154
  }
145
155
 
146
- const agent = await getOrSkip(
147
- () => client.getAgent(agentId),
148
- `agent not found: ${agentId}`,
149
- );
150
- if (agent === undefined) return;
151
-
152
156
  const subject = await generateTitle({
153
157
  userMessage,
154
- agentName: agent.metadata?.name ?? "",
155
- agentDescription: agent.spec?.description ?? "",
158
+ agentName,
159
+ agentDescription,
156
160
  executionId,
157
161
  options,
158
162
  });
@@ -175,7 +179,8 @@ export async function generateSessionSubject(
175
179
  /**
176
180
  * The agent behind the execution: the direct agent_id when the execution
177
181
  * carries one, else resolved through the session's agent-instance chain.
178
- * Empty string when unresolvable. Mirrors the cloud activity's resolveAgentId.
182
+ * Empty string when there is none (the built-in assistant) or when the
183
+ * instance cannot be read.
179
184
  */
180
185
  export async function resolveAgentId(
181
186
  execution: AgentExecution,
@@ -264,7 +269,9 @@ export function buildUserPrompt(
264
269
  agentDescription: string,
265
270
  ): string {
266
271
  let prompt = `User's first message:\n"${userMessage}"\n\n`;
267
- prompt += `Agent: ${agentName}\n`;
272
+ if (agentName !== "") {
273
+ prompt += `Agent: ${agentName}\n`;
274
+ }
268
275
  if (agentDescription !== "") {
269
276
  prompt += `Agent purpose: ${agentDescription}\n`;
270
277
  }
@@ -47,6 +47,10 @@ pinned somewhere. Read a row across before searching for anything.
47
47
  translator emits.
48
48
  - `harness/transcript/builder.ts` — `TranscriptBuilder`, the one builder every
49
49
  harness folds its events through.
50
+ - `harness/turn-timeline.ts` — `TurnTimeline`, the runtime's fold over that
51
+ builder's events into the `turn_phases` timing line every harness gets for
52
+ free: first visible token, model rounds, tool and sub-agent spans, the
53
+ longest silence.
50
54
  - `harness/capture.ts` — the file-review capture. The runtime owns all of it; an
51
55
  adapter supplies one fact, which CAS-owned paths its engine touched.
52
56
  - `harness/turn-context.ts` — the resolution phases that build the `TurnInput`
@@ -204,7 +208,6 @@ Every hit falls into one of three classes:
204
208
  | `sdk/react/src/workflow/inspector/forms/AgentCallForm.tsx` | Two radio buttons, placed by hand. |
205
209
  | `sdk/react/src/pricing-governance/BaselineEditor.tsx` | A select with one option per harness. |
206
210
  | `site/src/components/pages/pricing/ModelPricingTable.tsx` | Harness to display label. |
207
- | `site/src/data/harness-cost-comparison.json` | Per-harness cost rows. |
208
211
  | `docs/concepts/harnesses.mdx`, `docs/concepts/sessions.mdx`, `docs/guides/runners/cursor-harness.mdx`, `docs/guides/workflows/task-types/agent-call.mdx`, `docs/vocabulary.md` | Hand-authored pages that name the harness set; each has an entry in `docs/_inventory/classification.yaml` to keep current. |
209
212
 
210
213
  The cloud composition that serves api.stigmer.ai has sites of the same three
@@ -53,13 +53,16 @@ import {
53
53
  assertFailedSurfaceWritesItsCopy,
54
54
  assertNonExecutingDecisionSettlesSkipped,
55
55
  assertRejectedBindFails,
56
+ assertWorkspaceLockTimeoutFails,
56
57
  describeHarnessRuntimeContract,
57
58
  RuntimeExecutionDriver,
58
59
  slimOf,
59
60
  systemMessages,
60
61
  type RuntimeContractHarness,
61
62
  } from "../../__test-utils__/harness-contract/runtime-contract.js";
63
+ import { captureTimingLines } from "../../__test-utils__/timing-lines.js";
62
64
  import { TERMINAL_COPY } from "../terminal-table.js";
65
+ import { TURN_PHASES_EVENT } from "../turn-timeline.js";
63
66
  import type { HarnessAdapter } from "../types.js";
64
67
 
65
68
  const TASK_QUEUE = "runtime-test-queue";
@@ -219,6 +222,56 @@ describe("run-turn: the fake adapter through the real runtime", () => {
219
222
  });
220
223
  });
221
224
 
225
+ describe("the runtime's turn timeline", () => {
226
+ // `run-turn.ts` emits the `turn_phases` line once, after the adapter
227
+ // returns and the transcript is finalized, on every outcome; a turn that
228
+ // never reached the engine emits none. The line's CONTENT is
229
+ // `turn-timeline.test.ts`'s; what is pinned here is the runtime's act:
230
+ // that the fold is installed on the one builder the adapter is handed,
231
+ // that the line names the adapter and the activity's turn, and that it is
232
+ // written for a failed turn as for a completed one.
233
+ const turnPhasesLines = (act: () => Promise<void>) => captureTimingLines(TURN_PHASES_EVENT, act);
234
+
235
+ it("writes one turn_phases line for a completed turn, naming the adapter, the turn and what the fold saw", async () => {
236
+ clock.reset();
237
+ const driver = new RuntimeExecutionDriver(harness, "fake-timeline-completed");
238
+ const lines = await turnPhasesLines(async () => {
239
+ const invocation = await driver.turn([scenario.say("Reading it."), scenario.read("t1", "notes.md"), scenario.say("Done.")]);
240
+ expect(slimOf(subject, invocation).phase).toBe("EXECUTION_COMPLETED");
241
+ });
242
+
243
+ expect(lines).toHaveLength(1);
244
+ const [line] = lines;
245
+ expect(line!.harness).toBe(subject.adapter.name);
246
+ expect(line!.outcome).toBe("completed");
247
+ expect(line!.turn_seq).toBeTypeOf("number");
248
+ expect(line!.rounds, "two `say`s, two rounds").toBe(2);
249
+ expect(line!.tool_calls, "one `read`").toBe(1);
250
+ expect(line!.first_text_ms).toBeTypeOf("number");
251
+ });
252
+
253
+ it("writes the line for a failed turn too: every outcome is measured", async () => {
254
+ clock.reset();
255
+ const driver = new RuntimeExecutionDriver(harness, "fake-timeline-failed");
256
+ const lines = await turnPhasesLines(async () => {
257
+ const invocation = await driver.turn([scenario.say("Checking."), scenario.fail("engine gave up")]);
258
+ expect(slimOf(subject, invocation).phase).toBe("EXECUTION_FAILED");
259
+ });
260
+
261
+ expect(lines).toHaveLength(1);
262
+ expect(lines[0]!.outcome).toBe("failed");
263
+ expect(lines[0]!.rounds).toBe(1);
264
+ });
265
+
266
+ it("writes no line for a turn that settled during resolution: no engine, no phases", async () => {
267
+ clock.reset();
268
+ const lines = await turnPhasesLines(async () => {
269
+ await assertWorkspaceLockTimeoutFails(harness, "fake-timeline-no-engine");
270
+ });
271
+ expect(lines).toEqual([]);
272
+ });
273
+ });
274
+
222
275
  describe("the runtime's write-back rule", () => {
223
276
  // `run-turn.ts` calls `frame.writeback.finalize()` in exactly two places:
224
277
  // once when a completed turn reaches its epilogue, and once when a resumed