@stigmer/runner 3.14.0-dev.20260910084630 → 3.14.1
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/__test-utils__/hermetic-activity.d.ts +245 -0
- package/dist/__test-utils__/hermetic-activity.js +369 -0
- package/dist/__test-utils__/hermetic-activity.js.map +1 -0
- package/dist/__test-utils__/mock-client.d.ts +13 -0
- package/dist/__test-utils__/mock-client.js +45 -0
- package/dist/__test-utils__/mock-client.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.d.ts +172 -0
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +331 -0
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.d.ts +167 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js +239 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.d.ts +97 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js +132 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js.map +1 -0
- package/dist/harness/capabilities.d.ts +71 -0
- package/dist/harness/capabilities.js +36 -0
- package/dist/harness/capabilities.js.map +1 -0
- package/dist/harness/registry.d.ts +67 -0
- package/dist/harness/registry.js +112 -0
- package/dist/harness/registry.js.map +1 -0
- package/dist/harness/types.d.ts +268 -0
- package/dist/harness/types.js +55 -0
- package/dist/harness/types.js.map +1 -0
- package/package.json +4 -4
- package/src/__test-utils__/__tests__/harness-contract-self-check.test.ts +229 -0
- package/src/__test-utils__/config-fixture.ts +63 -0
- package/src/__test-utils__/harness-contract/contract.ts +536 -0
- package/src/__test-utils__/harness-contract/recording-sink.ts +96 -0
- package/src/__test-utils__/harness-contract/scripted-adapter.ts +289 -0
- package/src/__test-utils__/harness-contract/types.ts +100 -0
- package/src/__test-utils__/hermetic-activity.ts +477 -0
- package/src/__test-utils__/proto-helpers.ts +25 -0
- package/src/__tests__/harness-contract.test.ts +25 -0
- package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +422 -0
- package/src/activities/execute-cursor/__test-utils__/scripted-agent.ts +342 -0
- package/src/activities/execute-cursor/__test-utils__/scripted-sdk.ts +166 -0
- package/src/activities/execute-cursor/__tests__/hermetic/deny-and-retry.test.ts +228 -0
- package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +180 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/deny-and-retry.turn1.status.json +55 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/deny-and-retry.turn2.status.json +77 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/file-review-capture.status.json +126 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/pause.status.json +45 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/plain-turn.status.json +48 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/recovery-fresh-agent.status.json +53 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/tool-call.status.json +68 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/worker-shutdown.status.json +47 -0
- package/src/activities/execute-cursor/__tests__/hermetic/pause-vs-shutdown.test.ts +201 -0
- package/src/activities/execute-cursor/__tests__/hermetic/plain-turn.test.ts +171 -0
- package/src/activities/execute-cursor/__tests__/hermetic/recovery-fresh-agent.test.ts +156 -0
- package/src/activities/execute-cursor/__tests__/hermetic/tool-call.test.ts +137 -0
- package/src/harness/__tests__/registry.test.ts +167 -0
- package/src/harness/capabilities.ts +75 -0
- package/src/harness/registry.ts +123 -0
- package/src/harness/types.ts +278 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scripted harness adapter — the reference implementation of
|
|
3
|
+
* `HarnessAdapter`, driven by a `TurnScenario` per turn instead of a vendor
|
|
4
|
+
* SDK.
|
|
5
|
+
*
|
|
6
|
+
* Two jobs. It is the fake the contract kit runs against under every
|
|
7
|
+
* capability combination, so the kit is proven before any real adapter
|
|
8
|
+
* implements the contract. And it is the TEMPLATE a future harness author
|
|
9
|
+
* reads first: every rule of the contract appears here as the smallest code
|
|
10
|
+
* that honours it, with the reason beside it. Its measured line count is the
|
|
11
|
+
* program's "lines a new harness must write" data point.
|
|
12
|
+
*
|
|
13
|
+
* The insight this fake makes visible: above the contract line, the two real
|
|
14
|
+
* pause primitives are indistinguishable. Whether the engine checkpoints at
|
|
15
|
+
* the gate (`interrupt`) or a hook denies the tool and the run is cancelled
|
|
16
|
+
* (`deny-and-retry`), the turn ends `awaiting_approval` with a WAITING row on
|
|
17
|
+
* the status, and the next invocation carries the decision. How the engine
|
|
18
|
+
* RESUMES is the adapter's business, below the line, and the runtime never
|
|
19
|
+
* sees it. So this fake takes `pausePrimitive` as an option and does not
|
|
20
|
+
* branch on it — and the kit runs it under both to prove the kit does not
|
|
21
|
+
* branch on it either.
|
|
22
|
+
*
|
|
23
|
+
* What is deliberately simple here and would be real work in an adapter:
|
|
24
|
+
* "executing" a side effect is incrementing a counter; the engine state id is
|
|
25
|
+
* a counter too; the transcript rows are built with the shared proto
|
|
26
|
+
* factories. What is NOT simplified is the contract behaviour itself —
|
|
27
|
+
* settling with an outcome and never rejecting, stopping at every step
|
|
28
|
+
* boundary and inside a hang, binding before the first persist, executing an
|
|
29
|
+
* approval exactly once, refusing to resume a state it never minted — because
|
|
30
|
+
* those are what the kit measures.
|
|
31
|
+
*
|
|
32
|
+
* A `runTurn` with no scenario arranged is a test bug and throws (the same
|
|
33
|
+
* rule as the scripted `@cursor/sdk` agent's `send()` with no script left);
|
|
34
|
+
* every other exit is a `TurnOutcome`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { ApprovalAction, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
38
|
+
import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
39
|
+
|
|
40
|
+
import type { Config } from "../../config.js";
|
|
41
|
+
import type { HarnessCapabilities, PausePrimitive, StateIdSource } from "../../harness/capabilities.js";
|
|
42
|
+
import type { HarnessAdapter, TurnInput, TurnOutcome, TurnSink } from "../../harness/types.js";
|
|
43
|
+
import { DEEP_AGENT_VISION_PROFILE } from "../../shared/attachment-vision.js";
|
|
44
|
+
import type { ProposedAction } from "../approval-contract/types.js";
|
|
45
|
+
import { testConfig } from "../config-fixture.js";
|
|
46
|
+
import { aiMessage, findToolCallRow, waitingToolCall } from "../proto-helpers.js";
|
|
47
|
+
import type { HarnessContractSubject, ScenarioStep, TurnScenario } from "./types.js";
|
|
48
|
+
|
|
49
|
+
export interface ScriptedHarnessOptions {
|
|
50
|
+
/** Diagnostic name; defaults to a name that says which primitives this instance runs under. */
|
|
51
|
+
readonly name?: string;
|
|
52
|
+
readonly pausePrimitive: PausePrimitive;
|
|
53
|
+
readonly stateIdSource: StateIdSource;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolves when the signal aborts; resolves at once if it already has. Parks
|
|
58
|
+
* on the abort EVENT, never on a timer, so a hanging turn holds nothing that
|
|
59
|
+
* could keep a process alive — the property the runtime's heartbeat relies
|
|
60
|
+
* on when it declares a stalled turn dead.
|
|
61
|
+
*/
|
|
62
|
+
function whenAborted(signal: AbortSignal): Promise<void> {
|
|
63
|
+
if (signal.aborted) return Promise.resolve();
|
|
64
|
+
return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A proposal already carried to a terminal row has been settled by an earlier invocation. */
|
|
68
|
+
function isSettled(row: ToolCall | undefined): boolean {
|
|
69
|
+
return row !== undefined
|
|
70
|
+
&& (row.status === ToolCallStatus.TOOL_CALL_COMPLETED || row.status === ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class ScriptedHarnessAdapter implements HarnessAdapter {
|
|
74
|
+
readonly name: string;
|
|
75
|
+
readonly capabilities: HarnessCapabilities;
|
|
76
|
+
|
|
77
|
+
private readonly queue: TurnScenario[] = [];
|
|
78
|
+
private readonly mintedStateIds = new Set<string>();
|
|
79
|
+
private readonly executions = new Map<string, number>();
|
|
80
|
+
private mintCounter = 0;
|
|
81
|
+
|
|
82
|
+
constructor(options: ScriptedHarnessOptions) {
|
|
83
|
+
this.name = options.name ?? `scripted(${options.pausePrimitive}, ${options.stateIdSource})`;
|
|
84
|
+
this.capabilities = {
|
|
85
|
+
pausePrimitive: options.pausePrimitive,
|
|
86
|
+
stateIdSource: options.stateIdSource,
|
|
87
|
+
systemPrompt: true,
|
|
88
|
+
subAgents: false,
|
|
89
|
+
toolRestriction: true,
|
|
90
|
+
visionProfile: DEEP_AGENT_VISION_PROFILE,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Engine controls (the subject's side of the kit seam) ──────────────────
|
|
95
|
+
|
|
96
|
+
/** Queue what the next `runTurn` plays. */
|
|
97
|
+
arrange(turn: TurnScenario): void {
|
|
98
|
+
this.queue.push(turn);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
executionCount(toolCallId: string): number {
|
|
102
|
+
return this.executions.get(toolCallId) ?? 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── HarnessAdapter ────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/** Nothing to install: a real adapter imports its SDK lazily and installs its transport here. */
|
|
108
|
+
async boot(_config: Config): Promise<void> {}
|
|
109
|
+
|
|
110
|
+
/** Nothing held: a real adapter closes every engine it still has parked. */
|
|
111
|
+
async shutdown(): Promise<void> {}
|
|
112
|
+
|
|
113
|
+
/** Nothing is parked per session here; a real adapter releases the engine it parked for `sessionId`. */
|
|
114
|
+
async releaseSession(_sessionId: string): Promise<void> {}
|
|
115
|
+
|
|
116
|
+
async runTurn(input: TurnInput, sink: TurnSink): Promise<TurnOutcome> {
|
|
117
|
+
const turn = this.queue.shift();
|
|
118
|
+
if (!turn) throw new Error(`${this.name}: runTurn called with no scenario arranged (test bug)`);
|
|
119
|
+
|
|
120
|
+
// The signal may be aborted before the turn is entered; do no work then.
|
|
121
|
+
if (sink.stopSignal.aborted) return { kind: "interrupted" };
|
|
122
|
+
|
|
123
|
+
const resumed = await this.resolveEngineState(input, sink);
|
|
124
|
+
if (resumed.kind !== "ok") return resumed.outcome;
|
|
125
|
+
|
|
126
|
+
for (const step of turn) {
|
|
127
|
+
// Stop at every step boundary — the Cursor loop's per-event isCancelled().
|
|
128
|
+
if (sink.stopSignal.aborted) return { kind: "interrupted" };
|
|
129
|
+
const ended = await this.play(step, input, sink);
|
|
130
|
+
if (ended) return ended;
|
|
131
|
+
}
|
|
132
|
+
return { kind: "completed" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Create or resume the engine, per `stateIdSource`. An engine-minted id is
|
|
137
|
+
* bound BEFORE the turn proceeds so a crash mid-turn still resumes; a
|
|
138
|
+
* resume of an id this engine never minted fails the way `Agent.resume` of
|
|
139
|
+
* an unknown id fails, so a runtime that threads the wrong id is caught. A
|
|
140
|
+
* deterministic engine's id is the runtime's; nothing to bind.
|
|
141
|
+
*/
|
|
142
|
+
private async resolveEngineState(
|
|
143
|
+
input: TurnInput,
|
|
144
|
+
sink: TurnSink,
|
|
145
|
+
): Promise<{ kind: "ok" } | { kind: "ended"; outcome: TurnOutcome }> {
|
|
146
|
+
if (this.capabilities.stateIdSource === "deterministic") return { kind: "ok" };
|
|
147
|
+
|
|
148
|
+
if (input.threadId === "") {
|
|
149
|
+
const minted = `${this.name}#state-${++this.mintCounter}`;
|
|
150
|
+
this.mintedStateIds.add(minted);
|
|
151
|
+
try {
|
|
152
|
+
await sink.bindHarnessState(minted);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return {
|
|
155
|
+
kind: "ended",
|
|
156
|
+
outcome: { kind: "failed", message: `${this.name}: could not bind engine state`, cause: err },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { kind: "ok" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!this.mintedStateIds.has(input.threadId)) {
|
|
163
|
+
return {
|
|
164
|
+
kind: "ended",
|
|
165
|
+
outcome: { kind: "failed", message: `${this.name}: no engine state '${input.threadId}' to resume` },
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return { kind: "ok" };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Play one step; returns the outcome that ends the turn, or undefined to continue. */
|
|
172
|
+
private async play(step: ScenarioStep, input: TurnInput, sink: TurnSink): Promise<TurnOutcome | undefined> {
|
|
173
|
+
switch (step.kind) {
|
|
174
|
+
case "say": {
|
|
175
|
+
sink.status.messages.push(aiMessage(step.text));
|
|
176
|
+
sink.recordActivity();
|
|
177
|
+
sink.requestPersist();
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
case "propose":
|
|
181
|
+
return this.propose(step.toolCallId, step.action, input, sink);
|
|
182
|
+
case "usage": {
|
|
183
|
+
sink.reportUsage(step.delta);
|
|
184
|
+
sink.recordActivity();
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
case "hang": {
|
|
188
|
+
await whenAborted(sink.stopSignal);
|
|
189
|
+
return { kind: "interrupted" };
|
|
190
|
+
}
|
|
191
|
+
case "fail":
|
|
192
|
+
return { kind: "failed", message: step.message };
|
|
193
|
+
default: {
|
|
194
|
+
const exhaustive: never = step;
|
|
195
|
+
throw new Error(`${this.name}: unknown scenario step ${JSON.stringify(exhaustive)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The gate, above the contract line. The status is the single source of
|
|
202
|
+
* truth for whether the effect ran: a row already carried to COMPLETED or
|
|
203
|
+
* SKIPPED by an earlier invocation is settled and the step is a no-op, which
|
|
204
|
+
* is what makes "reinvoked twice with the same decision, still once" hold
|
|
205
|
+
* without a second ledger.
|
|
206
|
+
*/
|
|
207
|
+
private propose(
|
|
208
|
+
toolCallId: string,
|
|
209
|
+
action: ProposedAction,
|
|
210
|
+
input: TurnInput,
|
|
211
|
+
sink: TurnSink,
|
|
212
|
+
): TurnOutcome | undefined {
|
|
213
|
+
const existing = findToolCallRow(sink.status, toolCallId);
|
|
214
|
+
if (isSettled(existing)) return undefined;
|
|
215
|
+
|
|
216
|
+
const decision = input.approvalDecisions.get(toolCallId) ?? ApprovalAction.UNSPECIFIED;
|
|
217
|
+
switch (decision) {
|
|
218
|
+
case ApprovalAction.UNSPECIFIED: {
|
|
219
|
+
// Propose: surface the gated call as a WAITING row and stop the turn.
|
|
220
|
+
// Never write a second row for the same id on a resumed turn.
|
|
221
|
+
if (!existing) {
|
|
222
|
+
const message = aiMessage("");
|
|
223
|
+
message.toolCalls.push(waitingToolCall(toolCallId, action.kind, `Approve ${action.kind} ${action.resource}?`));
|
|
224
|
+
sink.status.messages.push(message);
|
|
225
|
+
}
|
|
226
|
+
sink.recordActivity();
|
|
227
|
+
sink.requestPersist();
|
|
228
|
+
return { kind: "awaiting_approval" };
|
|
229
|
+
}
|
|
230
|
+
case ApprovalAction.APPROVE:
|
|
231
|
+
case ApprovalAction.APPROVE_ALL: {
|
|
232
|
+
this.executions.set(toolCallId, this.executionCount(toolCallId) + 1);
|
|
233
|
+
this.settleRow(existing, toolCallId, action, ToolCallStatus.TOOL_CALL_COMPLETED, sink);
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
case ApprovalAction.SKIP:
|
|
237
|
+
case ApprovalAction.REJECT: {
|
|
238
|
+
this.settleRow(existing, toolCallId, action, ToolCallStatus.TOOL_CALL_SKIPPED, sink);
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
default: {
|
|
242
|
+
const exhaustive: never = decision;
|
|
243
|
+
throw new Error(`${this.name}: unknown approval action ${String(exhaustive)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Carry the proposal's row to its terminal status. The row normally exists
|
|
250
|
+
* (the runtime seeded the status with last turn's WAITING row); a decision
|
|
251
|
+
* with no row is a runtime that decided out of band, and the adapter still
|
|
252
|
+
* records what happened rather than losing the fact.
|
|
253
|
+
*/
|
|
254
|
+
private settleRow(
|
|
255
|
+
existing: ToolCall | undefined,
|
|
256
|
+
toolCallId: string,
|
|
257
|
+
action: ProposedAction,
|
|
258
|
+
status: ToolCallStatus,
|
|
259
|
+
sink: TurnSink,
|
|
260
|
+
): void {
|
|
261
|
+
if (existing) {
|
|
262
|
+
existing.status = status;
|
|
263
|
+
} else {
|
|
264
|
+
const message = aiMessage("");
|
|
265
|
+
const row = waitingToolCall(toolCallId, action.kind, "");
|
|
266
|
+
row.status = status;
|
|
267
|
+
message.toolCalls.push(row);
|
|
268
|
+
sink.status.messages.push(message);
|
|
269
|
+
}
|
|
270
|
+
sink.recordActivity();
|
|
271
|
+
sink.requestPersist();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The scripted adapter as a kit subject: the adapter IS its own engine, so the seam is a thin view over it. */
|
|
276
|
+
export interface ScriptedSubject extends HarnessContractSubject {
|
|
277
|
+
readonly adapter: ScriptedHarnessAdapter;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function scriptedSubject(options: ScriptedHarnessOptions): ScriptedSubject {
|
|
281
|
+
const adapter = new ScriptedHarnessAdapter(options);
|
|
282
|
+
return {
|
|
283
|
+
name: adapter.name,
|
|
284
|
+
adapter,
|
|
285
|
+
config: testConfig(),
|
|
286
|
+
arrange: (turn) => adapter.arrange(turn),
|
|
287
|
+
executionCount: (toolCallId) => adapter.executionCount(toolCallId),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test-facing seam of the harness contract kit.
|
|
3
|
+
*
|
|
4
|
+
* `HarnessAdapter.runTurn(input, sink)` gives a test no way to make the
|
|
5
|
+
* engine DO anything: propose a gated action, emit usage, hang, fail. The
|
|
6
|
+
* approval-contract kit met the same problem with `GatewaySubstrate` — a
|
|
7
|
+
* small interface each real substrate adapts so one invariant catalog can
|
|
8
|
+
* drive all of them. This is that seam for harnesses: a
|
|
9
|
+
* {@link HarnessContractSubject} owns the ENGINE (what the next turn will
|
|
10
|
+
* do), and the kit owns everything the runtime would own (the sink, the
|
|
11
|
+
* `TurnInput`, the state id threading, the reinvocation).
|
|
12
|
+
*
|
|
13
|
+
* The scenario vocabulary is deliberately small and engine-neutral: it says
|
|
14
|
+
* what the kit needs to PROVOKE, never how an engine behaves. The scripted
|
|
15
|
+
* fake consumes it directly; a real harness's subject translates it onto that
|
|
16
|
+
* harness's own double (the Cursor subject onto the scripted `@cursor/sdk`
|
|
17
|
+
* agent of `execute-cursor/__test-utils__/`). One vocabulary, not one per
|
|
18
|
+
* engine, so the kit cannot drift from what the fake can do.
|
|
19
|
+
*
|
|
20
|
+
* Gated actions reuse `ProposedAction` from `approval-contract/types.ts`, the
|
|
21
|
+
* taxonomy-free action every enforcement substrate already translates, so the
|
|
22
|
+
* two kits speak of the same logical side effect and neither restates the
|
|
23
|
+
* HITL taxonomy.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Config } from "../../config.js";
|
|
27
|
+
import type { HarnessAdapter, UsageDelta } from "../../harness/types.js";
|
|
28
|
+
import type { ProposedAction } from "../approval-contract/types.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One thing the engine does during a turn. A turn plays its steps in order
|
|
32
|
+
* and ends `completed` when none is left, unless a step ends it first.
|
|
33
|
+
*
|
|
34
|
+
* - `say`: the engine emits assistant text (a transcript row).
|
|
35
|
+
* - `propose`: the engine reaches a gated side effect identified by
|
|
36
|
+
* `toolCallId`. Undecided → the turn ends `awaiting_approval` and later
|
|
37
|
+
* steps do not run; APPROVE → the effect runs once and the turn continues;
|
|
38
|
+
* REJECT / SKIP → the effect never runs and the turn continues.
|
|
39
|
+
* - `usage`: the engine reports one turn's token counts.
|
|
40
|
+
* - `hang`: the engine makes no further progress until told to stop. The
|
|
41
|
+
* step that the stop-signal invariants and the runtime's stall watchdog
|
|
42
|
+
* are built around; it must be parked on the signal, never on a timer.
|
|
43
|
+
* - `fail`: the engine fails with a message the adapter can name; the turn
|
|
44
|
+
* ends `failed`.
|
|
45
|
+
*/
|
|
46
|
+
export type ScenarioStep =
|
|
47
|
+
| { readonly kind: "say"; readonly text: string }
|
|
48
|
+
| { readonly kind: "propose"; readonly toolCallId: string; readonly action: ProposedAction }
|
|
49
|
+
| { readonly kind: "usage"; readonly delta: UsageDelta }
|
|
50
|
+
| { readonly kind: "hang" }
|
|
51
|
+
| { readonly kind: "fail"; readonly message: string };
|
|
52
|
+
|
|
53
|
+
/** One turn's worth of engine behaviour. */
|
|
54
|
+
export type TurnScenario = readonly ScenarioStep[];
|
|
55
|
+
|
|
56
|
+
/** Step builders — the vocabulary a kit invariant reads as. */
|
|
57
|
+
export const scenario = {
|
|
58
|
+
say(text: string): ScenarioStep {
|
|
59
|
+
return { kind: "say", text };
|
|
60
|
+
},
|
|
61
|
+
propose(toolCallId: string, action: ProposedAction): ScenarioStep {
|
|
62
|
+
return { kind: "propose", toolCallId, action };
|
|
63
|
+
},
|
|
64
|
+
usage(delta: UsageDelta): ScenarioStep {
|
|
65
|
+
return { kind: "usage", delta };
|
|
66
|
+
},
|
|
67
|
+
hang(): ScenarioStep {
|
|
68
|
+
return { kind: "hang" };
|
|
69
|
+
},
|
|
70
|
+
fail(message: string): ScenarioStep {
|
|
71
|
+
return { kind: "fail", message };
|
|
72
|
+
},
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One adapter under test, with the engine controls the kit needs. Adapters
|
|
77
|
+
* translate scenarios into their own double's drives and report what the
|
|
78
|
+
* double observed; they never reimplement contract behaviour.
|
|
79
|
+
*/
|
|
80
|
+
export interface HarnessContractSubject {
|
|
81
|
+
/** Stable name, used in suite titles and every assertion message. */
|
|
82
|
+
readonly name: string;
|
|
83
|
+
/** The adapter under test — the real implementation of the contract. */
|
|
84
|
+
readonly adapter: HarnessAdapter;
|
|
85
|
+
/** What `adapter.boot` needs. The subject knows its adapter's fields; the kit does not. */
|
|
86
|
+
readonly config: Config;
|
|
87
|
+
/**
|
|
88
|
+
* Arrange the engine so that the NEXT `runTurn` on this adapter plays
|
|
89
|
+
* `turn`. Called by the kit before every `runTurn`, including a
|
|
90
|
+
* reinvocation (the engine re-reaches the same gated call on resume, so the
|
|
91
|
+
* same proposal is arranged again).
|
|
92
|
+
*/
|
|
93
|
+
arrange(turn: TurnScenario): void;
|
|
94
|
+
/**
|
|
95
|
+
* How many times the side effect behind `toolCallId` actually ran, as the
|
|
96
|
+
* subject's double observed it. The safety-critical observable; zero for an
|
|
97
|
+
* id the engine never reached.
|
|
98
|
+
*/
|
|
99
|
+
executionCount(toolCallId: string): number;
|
|
100
|
+
}
|