@poncho-ai/harness 0.40.1 → 0.42.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.
@@ -0,0 +1,420 @@
1
+ // ---------------------------------------------------------------------------
2
+ // runConversationTurn — load-bearing helper that runs a single primary chat
3
+ // turn end-to-end against a ConversationStore: loads the conversation,
4
+ // persists the user message before the run, drives the model + tool loop
5
+ // via executeConversationTurn, periodically persists the in-flight assistant
6
+ // draft, handles approval checkpoints + continuations + cancellation, and
7
+ // finalises the conversation row on completion.
8
+ //
9
+ // This was extracted from packages/cli/src/index.ts (POST
10
+ // /api/conversations/:id/messages handler) so consumers other than the CLI
11
+ // (PonchOS, custom servers) can ship the *same* conversation lifecycle
12
+ // without copy-pasting hundreds of lines.
13
+ //
14
+ // Caller responsibilities (NOT done here):
15
+ // - auth / ownership checks
16
+ // - active-run dedup (one run at a time per conversation)
17
+ // - streaming events to a real client (use opts.onEvent)
18
+ // - triggering continuation runs after this returns continuation: true
19
+ // - conversation title inference (helper preserves existing title)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ import { randomUUID } from "node:crypto";
23
+ import type { AgentEvent, FileInput, Message } from "@poncho-ai/sdk";
24
+ import { createLogger } from "@poncho-ai/sdk";
25
+ import type { AgentHarness } from "../harness.js";
26
+ import type { ConversationStore } from "../state.js";
27
+ import { deriveUploadKey } from "../upload-store.js";
28
+ import { withToolResultArchiveParam } from "./continuation.js";
29
+ import { resolveRunRequest } from "./history.js";
30
+ import {
31
+ applyTurnMetadata,
32
+ buildApprovalCheckpoints,
33
+ buildAssistantMetadata,
34
+ cloneSections,
35
+ createTurnDraftState,
36
+ executeConversationTurn,
37
+ flushTurnDraft,
38
+ } from "./turn.js";
39
+
40
+ const log = createLogger("orchestrator");
41
+
42
+ export interface RunConversationTurnOpts {
43
+ /** Initialised harness instance. */
44
+ harness: AgentHarness;
45
+ /** Conversation store backing the turn (typically `engine.conversations` from a StorageEngine). */
46
+ conversationStore: ConversationStore;
47
+ conversationId: string;
48
+ /** The user's new message text. Required (use `""` if you only want to attach files). */
49
+ task: string;
50
+ /**
51
+ * Optional file attachments (FileInput.data is base64 / data URI / https URL).
52
+ * Files are uploaded via `harness.uploadStore` first so the persisted user
53
+ * message references stable URLs instead of fat base64 blobs.
54
+ */
55
+ files?: FileInput[];
56
+ /**
57
+ * Extra parameters merged into runInput.parameters. Use this for recall
58
+ * corpus, archive lookup keys, messaging metadata, etc. Do NOT include
59
+ * `__activeConversationId`, `__ownerId`, or the tool-result-archive — the
60
+ * helper sets those itself.
61
+ */
62
+ parameters?: Record<string, unknown>;
63
+ abortSignal?: AbortSignal;
64
+ tenantId?: string | null;
65
+ /** Per-event hook — called for every AgentEvent yielded by the run, in order. */
66
+ onEvent?: (event: AgentEvent) => void | Promise<void>;
67
+ }
68
+
69
+ export interface RunConversationTurnResult {
70
+ /** runId of the most recent run started during this turn. */
71
+ latestRunId: string;
72
+ /** True if the run was cancelled (via abortSignal or run:cancelled event). */
73
+ cancelled: boolean;
74
+ /** True if the run errored. The error has been emitted via onEvent as run:error. */
75
+ errored: boolean;
76
+ /** True if the run requested a continuation. Caller is responsible for triggering the continuation. */
77
+ continuation: boolean;
78
+ /** True if the run paused at a tool-approval checkpoint. */
79
+ checkpointed: boolean;
80
+ contextTokens: number;
81
+ contextWindow: number;
82
+ }
83
+
84
+ export const runConversationTurn = async (
85
+ opts: RunConversationTurnOpts,
86
+ ): Promise<RunConversationTurnResult> => {
87
+ const conversation = await opts.conversationStore.getWithArchive(opts.conversationId);
88
+ if (!conversation) {
89
+ throw new Error(`Conversation not found: ${opts.conversationId}`);
90
+ }
91
+
92
+ const canonicalHistory = resolveRunRequest(conversation, {
93
+ conversationId: opts.conversationId,
94
+ messages: conversation.messages,
95
+ });
96
+ const shouldRebuildCanonical = canonicalHistory.shouldRebuildCanonical;
97
+ const harnessMessages = [...canonicalHistory.messages];
98
+ const historyMessages = [...conversation.messages];
99
+ const preRunMessages = [...conversation.messages];
100
+
101
+ // Build user content — upload any files first so the persisted message
102
+ // carries stable refs instead of fat base64 blobs.
103
+ let userContent: Message["content"] = opts.task;
104
+ if (opts.files && opts.files.length > 0 && opts.harness.uploadStore) {
105
+ const uploadedParts = await Promise.all(
106
+ opts.files.map(async (f) => {
107
+ const buf = Buffer.from(f.data, "base64");
108
+ const key = deriveUploadKey(buf, f.mediaType);
109
+ const ref = await opts.harness.uploadStore!.put(key, buf, f.mediaType);
110
+ return {
111
+ type: "file" as const,
112
+ data: ref,
113
+ mediaType: f.mediaType,
114
+ filename: f.filename,
115
+ };
116
+ }),
117
+ );
118
+ userContent = [
119
+ { type: "text" as const, text: opts.task },
120
+ ...uploadedParts,
121
+ ];
122
+ }
123
+
124
+ const turnTimestamp = Date.now();
125
+ const userMessage: Message = {
126
+ role: "user",
127
+ content: userContent,
128
+ metadata: { id: randomUUID(), timestamp: turnTimestamp },
129
+ };
130
+ const assistantId = randomUUID();
131
+ const draft = createTurnDraftState();
132
+
133
+ let latestRunId = conversation.runtimeRunId ?? "";
134
+ let runCancelled = false;
135
+ let runContinuationMessages: Message[] | undefined;
136
+ let cancelHarnessMessages: Message[] | undefined;
137
+ let checkpointedRun = false;
138
+
139
+ const buildMessages = (): Message[] => {
140
+ const draftSections = cloneSections(draft.sections);
141
+ if (draft.currentTools.length > 0) {
142
+ draftSections.push({ type: "tools", content: [...draft.currentTools] });
143
+ }
144
+ if (draft.currentText.length > 0) {
145
+ draftSections.push({ type: "text", content: draft.currentText });
146
+ }
147
+ const userTurn: Message[] = [userMessage];
148
+ const hasDraftContent =
149
+ draft.assistantResponse.length > 0 ||
150
+ draft.toolTimeline.length > 0 ||
151
+ draftSections.length > 0;
152
+ if (!hasDraftContent) {
153
+ return [...historyMessages, ...userTurn];
154
+ }
155
+ return [
156
+ ...historyMessages,
157
+ ...userTurn,
158
+ {
159
+ role: "assistant" as const,
160
+ content: draft.assistantResponse,
161
+ metadata: buildAssistantMetadata(draft, draftSections, {
162
+ id: assistantId,
163
+ timestamp: turnTimestamp,
164
+ }),
165
+ },
166
+ ];
167
+ };
168
+
169
+ const persistDraft = async (): Promise<void> => {
170
+ if (draft.assistantResponse.length === 0 && draft.toolTimeline.length === 0) return;
171
+ conversation.messages = buildMessages();
172
+ conversation.updatedAt = Date.now();
173
+ await opts.conversationStore.update(conversation);
174
+ };
175
+
176
+ // Persist the user turn immediately so a crash mid-run still records what
177
+ // the user said. Fire-and-forget — don't block the run.
178
+ conversation.messages = [...historyMessages, userMessage];
179
+ conversation.subagentCallbackCount = 0;
180
+ conversation._continuationCount = undefined;
181
+ conversation.updatedAt = Date.now();
182
+ opts.conversationStore.update(conversation).catch((err) => {
183
+ log.error(
184
+ `failed to persist user turn: ${err instanceof Error ? err.message : String(err)}`,
185
+ );
186
+ });
187
+
188
+ try {
189
+ const execution = await executeConversationTurn({
190
+ harness: opts.harness,
191
+ runInput: {
192
+ task: opts.task,
193
+ conversationId: opts.conversationId,
194
+ tenantId: opts.tenantId ?? undefined,
195
+ parameters: withToolResultArchiveParam(
196
+ {
197
+ ...(opts.parameters ?? {}),
198
+ __activeConversationId: opts.conversationId,
199
+ __ownerId: conversation.ownerId,
200
+ },
201
+ conversation,
202
+ ),
203
+ messages: harnessMessages,
204
+ files: opts.files && opts.files.length > 0 ? opts.files : undefined,
205
+ abortSignal: opts.abortSignal,
206
+ },
207
+ initialContextTokens: conversation.contextTokens ?? 0,
208
+ initialContextWindow: conversation.contextWindow ?? 0,
209
+ onEvent: async (event, eventDraft) => {
210
+ // Sync our outer draft from the executor's so persistDraft sees the latest state.
211
+ draft.assistantResponse = eventDraft.assistantResponse;
212
+ draft.toolTimeline = eventDraft.toolTimeline;
213
+ draft.sections = eventDraft.sections;
214
+ draft.currentTools = eventDraft.currentTools;
215
+ draft.currentText = eventDraft.currentText;
216
+
217
+ if (event.type === "run:started") {
218
+ latestRunId = event.runId;
219
+ }
220
+ if (event.type === "run:cancelled") {
221
+ runCancelled = true;
222
+ if (event.messages) cancelHarnessMessages = event.messages;
223
+ }
224
+ if (event.type === "compaction:completed") {
225
+ if (event.compactedMessages) {
226
+ historyMessages.length = 0;
227
+ historyMessages.push(...event.compactedMessages);
228
+ const preservedFromHistory = historyMessages.length - 1;
229
+ const removedCount =
230
+ preRunMessages.length - Math.max(0, preservedFromHistory);
231
+ const existingHistory = conversation.compactedHistory ?? [];
232
+ conversation.compactedHistory = [
233
+ ...existingHistory,
234
+ ...preRunMessages.slice(0, removedCount),
235
+ ];
236
+ }
237
+ }
238
+ if (event.type === "step:completed") {
239
+ await persistDraft();
240
+ }
241
+ if (event.type === "tool:approval:required") {
242
+ const toolText = `- approval required \`${event.tool}\``;
243
+ draft.toolTimeline.push(toolText);
244
+ draft.currentTools.push(toolText);
245
+ const existing = Array.isArray(conversation.pendingApprovals)
246
+ ? conversation.pendingApprovals
247
+ : [];
248
+ if (!existing.some((a) => a.approvalId === event.approvalId)) {
249
+ conversation.pendingApprovals = [
250
+ ...existing,
251
+ {
252
+ approvalId: event.approvalId,
253
+ runId: latestRunId || conversation.runtimeRunId || "",
254
+ tool: event.tool,
255
+ toolCallId: undefined,
256
+ input: (event.input ?? {}) as Record<string, unknown>,
257
+ checkpointMessages: undefined,
258
+ baseMessageCount: historyMessages.length,
259
+ pendingToolCalls: [],
260
+ },
261
+ ];
262
+ conversation.updatedAt = Date.now();
263
+ await opts.conversationStore.update(conversation);
264
+ }
265
+ await persistDraft();
266
+ }
267
+ if (event.type === "tool:approval:checkpoint") {
268
+ conversation.messages = buildMessages();
269
+ conversation.pendingApprovals = buildApprovalCheckpoints({
270
+ approvals: event.approvals,
271
+ runId: latestRunId,
272
+ checkpointMessages: event.checkpointMessages,
273
+ baseMessageCount: historyMessages.length,
274
+ pendingToolCalls: event.pendingToolCalls,
275
+ });
276
+ conversation._toolResultArchive = opts.harness.getToolResultArchive(
277
+ opts.conversationId,
278
+ );
279
+ conversation.updatedAt = Date.now();
280
+ await opts.conversationStore.update(conversation);
281
+ checkpointedRun = true;
282
+ }
283
+ if (event.type === "run:completed") {
284
+ if (event.result.continuation && event.result.continuationMessages) {
285
+ runContinuationMessages = event.result.continuationMessages;
286
+ conversation.messages = buildMessages();
287
+ conversation._continuationMessages = runContinuationMessages;
288
+ conversation._harnessMessages = runContinuationMessages;
289
+ conversation._toolResultArchive = opts.harness.getToolResultArchive(
290
+ opts.conversationId,
291
+ );
292
+ conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
293
+ if (!checkpointedRun) {
294
+ conversation.pendingApprovals = [];
295
+ }
296
+ if ((event.result.contextTokens ?? 0) > 0) {
297
+ conversation.contextTokens = event.result.contextTokens!;
298
+ }
299
+ if ((event.result.contextWindow ?? 0) > 0) {
300
+ conversation.contextWindow = event.result.contextWindow!;
301
+ }
302
+ conversation.updatedAt = Date.now();
303
+ await opts.conversationStore.update(conversation);
304
+ }
305
+ }
306
+
307
+ if (opts.onEvent) {
308
+ await opts.onEvent(event);
309
+ }
310
+ },
311
+ });
312
+
313
+ flushTurnDraft(draft);
314
+ latestRunId = execution.latestRunId || latestRunId;
315
+
316
+ if (!checkpointedRun && !runContinuationMessages) {
317
+ conversation.messages = buildMessages();
318
+ applyTurnMetadata(
319
+ conversation,
320
+ {
321
+ latestRunId,
322
+ contextTokens: execution.runContextTokens,
323
+ contextWindow: execution.runContextWindow,
324
+ harnessMessages: execution.runHarnessMessages,
325
+ toolResultArchive: opts.harness.getToolResultArchive(opts.conversationId),
326
+ },
327
+ { shouldRebuildCanonical },
328
+ );
329
+ await opts.conversationStore.update(conversation);
330
+ }
331
+
332
+ return {
333
+ latestRunId,
334
+ cancelled: runCancelled,
335
+ errored: false,
336
+ continuation: !!runContinuationMessages,
337
+ checkpointed: checkpointedRun,
338
+ contextTokens: execution.runContextTokens,
339
+ contextWindow: execution.runContextWindow,
340
+ };
341
+ } catch (error) {
342
+ flushTurnDraft(draft);
343
+ const aborted = opts.abortSignal?.aborted === true;
344
+ if (aborted || runCancelled) {
345
+ if (
346
+ draft.assistantResponse.length > 0 ||
347
+ draft.toolTimeline.length > 0 ||
348
+ draft.sections.length > 0
349
+ ) {
350
+ conversation.messages = buildMessages();
351
+ applyTurnMetadata(
352
+ conversation,
353
+ {
354
+ latestRunId,
355
+ contextTokens: 0,
356
+ contextWindow: 0,
357
+ harnessMessages: cancelHarnessMessages,
358
+ toolResultArchive: opts.harness.getToolResultArchive(opts.conversationId),
359
+ },
360
+ { shouldRebuildCanonical: true },
361
+ );
362
+ await opts.conversationStore.update(conversation);
363
+ }
364
+ if (!checkpointedRun) {
365
+ // Clear any pending approvals — the run was cancelled, they're stale.
366
+ const fresh = await opts.conversationStore.get(opts.conversationId);
367
+ if (fresh && Array.isArray(fresh.pendingApprovals) && fresh.pendingApprovals.length > 0) {
368
+ fresh.pendingApprovals = [];
369
+ await opts.conversationStore.update(fresh);
370
+ }
371
+ }
372
+ return {
373
+ latestRunId,
374
+ cancelled: true,
375
+ errored: false,
376
+ continuation: false,
377
+ checkpointed: checkpointedRun,
378
+ contextTokens: 0,
379
+ contextWindow: 0,
380
+ };
381
+ }
382
+
383
+ // Real error: emit run:error, persist whatever we have.
384
+ const errorEvent: AgentEvent = {
385
+ type: "run:error",
386
+ runId: latestRunId || "run_unknown",
387
+ error: {
388
+ code: "RUN_ERROR",
389
+ message: error instanceof Error ? error.message : "Unknown error",
390
+ },
391
+ };
392
+ if (opts.onEvent) {
393
+ try {
394
+ await opts.onEvent(errorEvent);
395
+ } catch (hookErr) {
396
+ log.error(
397
+ `onEvent threw on run:error: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
398
+ );
399
+ }
400
+ }
401
+ if (
402
+ draft.assistantResponse.length > 0 ||
403
+ draft.toolTimeline.length > 0 ||
404
+ draft.sections.length > 0
405
+ ) {
406
+ conversation.messages = buildMessages();
407
+ conversation.updatedAt = Date.now();
408
+ await opts.conversationStore.update(conversation);
409
+ }
410
+ return {
411
+ latestRunId,
412
+ cancelled: false,
413
+ errored: true,
414
+ continuation: false,
415
+ checkpointed: checkpointedRun,
416
+ contextTokens: 0,
417
+ contextWindow: 0,
418
+ };
419
+ }
420
+ };
@@ -33,6 +33,8 @@ export interface VfsDirEntry {
33
33
  // ---------------------------------------------------------------------------
34
34
 
35
35
  export interface StorageEngine {
36
+ /** Partition key: every read/write is scoped to this agent id. */
37
+ readonly agentId: string;
36
38
  /** Run migrations and prepare the storage backend. */
37
39
  initialize(): Promise<void>;
38
40
  /** Gracefully release resources. */
@@ -55,7 +55,7 @@ const vfsKey = (tenantId: string, path: string) => `${tenantId}\0${path}`;
55
55
  // ---------------------------------------------------------------------------
56
56
 
57
57
  export class InMemoryEngine implements StorageEngine {
58
- private readonly agentId: string;
58
+ readonly agentId: string;
59
59
 
60
60
  // Conversation data
61
61
  private convs = new Map<string, Conversation>();
@@ -191,7 +191,7 @@ const colBytes = (v: unknown): number => {
191
191
 
192
192
  export abstract class SqlStorageEngine implements StorageEngine {
193
193
  protected readonly dialect: Dialect;
194
- protected readonly agentId: string;
194
+ readonly agentId: string;
195
195
  protected abstract readonly executor: QueryExecutor;
196
196
  protected readonly egressMeter = new ConversationEgressMeter();
197
197
 
@@ -0,0 +1,63 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { AgentHarness } from "../src/harness.js";
6
+ import type { PonchoConfig } from "../src/config.js";
7
+
8
+ const AGENT_MD = `---
9
+ name: config-inject-agent
10
+ model:
11
+ provider: anthropic
12
+ name: claude-opus-4-5
13
+ ---
14
+
15
+ # Config injection test
16
+ `;
17
+
18
+ describe("HarnessOptions.config injection (PR 2)", () => {
19
+ it("uses an injected PonchoConfig instead of reading poncho.config.js from disk", async () => {
20
+ const dir = await mkdtemp(join(tmpdir(), "poncho-cfg-injected-"));
21
+ try {
22
+ await writeFile(join(dir, "AGENT.md"), AGENT_MD, "utf8");
23
+ // Deliberately do NOT write a poncho.config.js — the injected
24
+ // config should be used end-to-end.
25
+ const config: PonchoConfig = {
26
+ tools: { web_search: false },
27
+ storage: { provider: "memory" },
28
+ };
29
+
30
+ const harness = new AgentHarness({ workingDir: dir, config });
31
+ await harness.initialize();
32
+
33
+ const names = harness.listTools().map((t) => t.name);
34
+ // web_search was disabled in the injected config; bash is a default
35
+ // built-in that should still be registered.
36
+ expect(names).not.toContain("web_search");
37
+ expect(names).toContain("bash");
38
+ } finally {
39
+ await rm(dir, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ it("disk-loaded behaviour is unchanged when no config option is provided", async () => {
44
+ const dir = await mkdtemp(join(tmpdir(), "poncho-cfg-disk-"));
45
+ try {
46
+ await writeFile(join(dir, "AGENT.md"), AGENT_MD, "utf8");
47
+ // Write a poncho.config.js that disables a tool — proves loadPonchoConfig
48
+ // ran (otherwise web_search would be present).
49
+ await writeFile(
50
+ join(dir, "poncho.config.js"),
51
+ "export default { tools: { web_search: false }, storage: { provider: 'memory' } };\n",
52
+ "utf8",
53
+ );
54
+ const harness = new AgentHarness({ workingDir: dir });
55
+ await harness.initialize();
56
+ const names = harness.listTools().map((t) => t.name);
57
+ expect(names).not.toContain("web_search");
58
+ expect(names).toContain("bash");
59
+ } finally {
60
+ await rm(dir, { recursive: true, force: true });
61
+ }
62
+ });
63
+ });
@@ -0,0 +1,93 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { AgentHarness } from "../src/harness.js";
6
+ import { InMemoryEngine } from "../src/storage/memory-engine.js";
7
+
8
+ const AGENT_MD = `---
9
+ name: injected-agent
10
+ model:
11
+ provider: anthropic
12
+ name: claude-opus-4-5
13
+ ---
14
+
15
+ # Injected agent
16
+
17
+ You are a test agent.
18
+ `;
19
+
20
+ describe("HarnessOptions injection (PR 1)", () => {
21
+ it("initializes without an AGENT.md on disk when agentDefinition + storageEngine are provided", async () => {
22
+ const dir = await mkdtemp(join(tmpdir(), "poncho-injection-"));
23
+ try {
24
+ const engine = new InMemoryEngine("user-123");
25
+ const harness = new AgentHarness({
26
+ workingDir: dir,
27
+ agentDefinition: AGENT_MD,
28
+ storageEngine: engine,
29
+ });
30
+ await expect(harness.initialize()).resolves.toBeUndefined();
31
+ // No AGENT.md was written into `dir` — confirm initialize ran from
32
+ // injected content alone.
33
+ expect(harness.frontmatter?.name).toBe("injected-agent");
34
+ } finally {
35
+ await rm(dir, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ it("mirrors storageEngine.agentId onto frontmatter.id on the injected path", async () => {
40
+ const dir = await mkdtemp(join(tmpdir(), "poncho-injection-id-"));
41
+ try {
42
+ const engine = new InMemoryEngine("user-456");
43
+ const harness = new AgentHarness({
44
+ workingDir: dir,
45
+ agentDefinition: AGENT_MD,
46
+ storageEngine: engine,
47
+ });
48
+ await harness.initialize();
49
+ expect(harness.frontmatter?.id).toBe("user-456");
50
+ } finally {
51
+ await rm(dir, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ it("accepts a pre-parsed ParsedAgent as agentDefinition", async () => {
56
+ const dir = await mkdtemp(join(tmpdir(), "poncho-injection-parsed-"));
57
+ try {
58
+ const engine = new InMemoryEngine("user-789");
59
+ const parsed = {
60
+ frontmatter: {
61
+ name: "preparsed-agent",
62
+ model: { provider: "anthropic" as const, name: "claude-opus-4-5" },
63
+ },
64
+ body: "# Pre-parsed agent\n",
65
+ };
66
+ const harness = new AgentHarness({
67
+ workingDir: dir,
68
+ agentDefinition: parsed,
69
+ storageEngine: engine,
70
+ });
71
+ await harness.initialize();
72
+ expect(harness.frontmatter?.name).toBe("preparsed-agent");
73
+ expect(harness.frontmatter?.id).toBe("user-789");
74
+ } finally {
75
+ await rm(dir, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ it("throws when agentDefinition is provided without storageEngine", () => {
80
+ expect(
81
+ () =>
82
+ new AgentHarness({
83
+ agentDefinition: AGENT_MD,
84
+ }),
85
+ ).toThrow(/agentDefinition requires HarnessOptions\.storageEngine/);
86
+ });
87
+
88
+ it("falls back to disk path when neither agentDefinition nor storageEngine is provided (existing behaviour unchanged)", async () => {
89
+ // This is implicitly covered by every other test in harness.test.ts —
90
+ // we simply assert that the constructor accepts no injection options.
91
+ expect(() => new AgentHarness({ workingDir: tmpdir() })).not.toThrow();
92
+ });
93
+ });