@poncho-ai/harness 0.60.1 → 0.61.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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +48 -0
- package/dist/index.d.ts +92 -1
- package/dist/index.js +191 -61
- package/package.json +2 -2
- package/src/agent-parser.ts +15 -1
- package/src/compaction.ts +163 -15
- package/src/harness.ts +18 -2
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/orchestrator.ts +75 -44
- package/src/orchestrator/run-conversation-turn.ts +7 -0
- package/src/orchestrator/turn.ts +130 -0
- package/src/state.ts +13 -0
- package/test/compaction.test.ts +124 -2
- package/test/resume-transcript-integrity.test.ts +156 -0
package/src/orchestrator/turn.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import type { AgentEvent, Message } from "@poncho-ai/sdk";
|
|
2
3
|
import type { Conversation } from "../state.js";
|
|
3
4
|
import type { AgentHarness } from "../harness.js";
|
|
@@ -341,6 +342,110 @@ export const buildApprovalCheckpoints = ({
|
|
|
341
342
|
kind,
|
|
342
343
|
}));
|
|
343
344
|
|
|
345
|
+
// ── Checkpoint transcript reconstruction (single source of truth) ──
|
|
346
|
+
//
|
|
347
|
+
// Resuming a checkpointed turn requires rebuilding the FULL canonical model
|
|
348
|
+
// transcript from what was stored at the pause. Getting this right — in the
|
|
349
|
+
// canonical message space, not the display one — is subtle, and re-deriving
|
|
350
|
+
// it by hand in each embedder is exactly how the model-facing transcript
|
|
351
|
+
// drifted from the display transcript and silently dropped a turn's user
|
|
352
|
+
// message after an approval. These helpers are that logic, exported so the
|
|
353
|
+
// orchestrator's own resume AND external embedders (e.g. PonchOS, which
|
|
354
|
+
// executes gated tools itself) share one implementation that can't drift.
|
|
355
|
+
|
|
356
|
+
/** Reconstruct the full canonical transcript a checkpoint resumes from: the
|
|
357
|
+
* prior history (before the checkpointed turn) + the checkpoint delta.
|
|
358
|
+
* Handles both storage conventions via `normalizeApprovalCheckpoint` — the
|
|
359
|
+
* initial checkpoint (base = prior length, delta) and a resume-created one
|
|
360
|
+
* (base 0, full canonical). */
|
|
361
|
+
export const assembleCheckpointMessages = (
|
|
362
|
+
conversation: Conversation,
|
|
363
|
+
checkpoint: StoredApproval,
|
|
364
|
+
): Message[] => {
|
|
365
|
+
const n = normalizeApprovalCheckpoint(checkpoint, conversation.messages);
|
|
366
|
+
const base = n.baseMessageCount != null ? conversation.messages.slice(0, n.baseMessageCount) : [];
|
|
367
|
+
return [...base, ...(n.checkpointMessages ?? [])];
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** Pair an assistant tool-call message's `tool_calls` with externally-computed
|
|
371
|
+
* results into the single `role:"tool"` message a continuation reads.
|
|
372
|
+
* Returns undefined when `assistantMsg` isn't an assistant message with
|
|
373
|
+
* parseable tool calls. Used to persist the resume's canonical history so it
|
|
374
|
+
* matches what `continueFromToolResult` fed the model. Missing results
|
|
375
|
+
* default to the deferred-error marker (same text the run uses). */
|
|
376
|
+
export const buildToolResultMessage = (
|
|
377
|
+
assistantMsg: Message | undefined,
|
|
378
|
+
toolResults: Array<{ callId: string; toolName: string; result?: unknown; error?: string }>,
|
|
379
|
+
): Message | undefined => {
|
|
380
|
+
if (assistantMsg?.role !== "assistant") return undefined;
|
|
381
|
+
let toolCalls: Array<{ id: string; name: string }> = [];
|
|
382
|
+
try {
|
|
383
|
+
const parsed = JSON.parse(typeof assistantMsg.content === "string" ? assistantMsg.content : "");
|
|
384
|
+
toolCalls = parsed.tool_calls ?? [];
|
|
385
|
+
} catch {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
if (toolCalls.length === 0) return undefined;
|
|
389
|
+
const provided = new Map(toolResults.map((r) => [r.callId, r]));
|
|
390
|
+
return {
|
|
391
|
+
role: "tool",
|
|
392
|
+
content: JSON.stringify(
|
|
393
|
+
toolCalls.map((tc) => {
|
|
394
|
+
const r = provided.get(tc.id);
|
|
395
|
+
return {
|
|
396
|
+
type: "tool_result",
|
|
397
|
+
tool_use_id: tc.id,
|
|
398
|
+
tool_name: r?.toolName ?? tc.name,
|
|
399
|
+
content: r
|
|
400
|
+
? (r.error ? `Tool error: ${r.error}` : JSON.stringify(r.result ?? null))
|
|
401
|
+
: "Tool error: Tool execution deferred (pending approval checkpoint)",
|
|
402
|
+
};
|
|
403
|
+
}),
|
|
404
|
+
),
|
|
405
|
+
metadata: { timestamp: Date.now(), id: randomUUID() },
|
|
406
|
+
};
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/** Build the `pendingApprovals` rows for a checkpoint reached DURING a resume
|
|
410
|
+
* continuation. Stores the WHOLE canonical history the continuation ran with
|
|
411
|
+
* (`priorMessages` = prior + tool result + new delta) with
|
|
412
|
+
* `baseMessageCount: 0`. Keeping the full history here — rather than a delta
|
|
413
|
+
* + a base count into a different message array — is what lets the next
|
|
414
|
+
* resume reconstruct with no index arithmetic. */
|
|
415
|
+
export const buildResumeCheckpoints = ({
|
|
416
|
+
priorMessages,
|
|
417
|
+
checkpointEvent,
|
|
418
|
+
runId,
|
|
419
|
+
kind = "approval",
|
|
420
|
+
}: {
|
|
421
|
+
priorMessages: Message[];
|
|
422
|
+
checkpointEvent: {
|
|
423
|
+
approvals: ApprovalEventItem[];
|
|
424
|
+
checkpointMessages: Message[];
|
|
425
|
+
pendingToolCalls: PendingToolCall[];
|
|
426
|
+
};
|
|
427
|
+
runId: string;
|
|
428
|
+
kind?: "approval" | "device";
|
|
429
|
+
}): NonNullable<Conversation["pendingApprovals"]> =>
|
|
430
|
+
buildApprovalCheckpoints({
|
|
431
|
+
approvals: checkpointEvent.approvals,
|
|
432
|
+
runId,
|
|
433
|
+
checkpointMessages: [...priorMessages, ...checkpointEvent.checkpointMessages],
|
|
434
|
+
baseMessageCount: 0,
|
|
435
|
+
pendingToolCalls: checkpointEvent.pendingToolCalls,
|
|
436
|
+
kind,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/** Text of a message for the transcript-integrity guard — flattens the two
|
|
440
|
+
* content shapes so a user message can be matched across the display and
|
|
441
|
+
* canonical transcripts regardless of how each stored it. */
|
|
442
|
+
const messageText = (m: Message): string =>
|
|
443
|
+
typeof m.content === "string"
|
|
444
|
+
? m.content
|
|
445
|
+
: Array.isArray(m.content)
|
|
446
|
+
? m.content.map((p) => (p as { text?: string }).text ?? "").join("")
|
|
447
|
+
: "";
|
|
448
|
+
|
|
344
449
|
// ── Turn metadata persistence ──
|
|
345
450
|
|
|
346
451
|
export const applyTurnMetadata = (
|
|
@@ -373,6 +478,31 @@ export const applyTurnMetadata = (
|
|
|
373
478
|
conv._harnessMessages = conv.messages;
|
|
374
479
|
}
|
|
375
480
|
|
|
481
|
+
// Invariant guard: the model-facing transcript must not drop the current
|
|
482
|
+
// turn's user message. A resume that reconstructed `_harnessMessages`
|
|
483
|
+
// incorrectly silently lost it (the display transcript kept it, the model
|
|
484
|
+
// didn't), so the agent second-guessed an approval it never saw. This is the
|
|
485
|
+
// one choke point every finalize flows through — assert the latest user
|
|
486
|
+
// message survived into canonical, and log loudly if not. Matched by content
|
|
487
|
+
// (robust across the display/canonical shapes) and skipped when compaction
|
|
488
|
+
// has legitimately summarized history. Log-only; never throws.
|
|
489
|
+
if (isMessageArray(conv._harnessMessages) && conv._harnessMessages.length > 0) {
|
|
490
|
+
const canonical = conv._harnessMessages;
|
|
491
|
+
const summarized = canonical.some((m) => m.metadata?.isCompactionSummary);
|
|
492
|
+
const lastUser = [...conv.messages].reverse().find((m) => m.role === "user");
|
|
493
|
+
const lastUserText = lastUser ? messageText(lastUser).trim() : "";
|
|
494
|
+
if (!summarized && lastUserText) {
|
|
495
|
+
const present = canonical.some((m) => m.role === "user" && messageText(m).trim() === lastUserText);
|
|
496
|
+
if (!present) {
|
|
497
|
+
console.error(
|
|
498
|
+
`[transcript-guard] conversation ${conv.conversationId}: model-facing transcript ` +
|
|
499
|
+
`is missing the latest user message — it diverged from the display transcript. ` +
|
|
500
|
+
`This is a resume/finalize message-assembly bug; the model will not see that turn's input.`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
376
506
|
if (meta.toolResultArchive !== undefined) {
|
|
377
507
|
conv._toolResultArchive = meta.toolResultArchive;
|
|
378
508
|
}
|
package/src/state.ts
CHANGED
|
@@ -17,10 +17,23 @@ export interface StateStore {
|
|
|
17
17
|
delete(runId: string): Promise<void>;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Whether a subagent actually accomplished its task — distinct from the *run*
|
|
22
|
+
* status (`status` below), which is "completed" whenever the run merely
|
|
23
|
+
* finished. A subagent that ran to completion but could not do the task (e.g.
|
|
24
|
+
* lacked the required tools) is `runStatus:"completed"` but `taskOutcome:
|
|
25
|
+
* "failed"`. Derived at subagent finalize; "unknown" when it can't be
|
|
26
|
+
* determined. Consumed by the compaction subagent ledger so a failed task can
|
|
27
|
+
* never be summarized as "completed".
|
|
28
|
+
*/
|
|
29
|
+
export type SubagentTaskOutcome = "succeeded" | "failed" | "partial" | "unknown";
|
|
30
|
+
|
|
20
31
|
export interface PendingSubagentResult {
|
|
21
32
|
subagentId: string;
|
|
22
33
|
task: string;
|
|
23
34
|
status: "completed" | "error" | "stopped";
|
|
35
|
+
/** Did the subagent accomplish its task? See {@link SubagentTaskOutcome}. */
|
|
36
|
+
taskOutcome: SubagentTaskOutcome;
|
|
24
37
|
result?: import("@poncho-ai/sdk").RunResult;
|
|
25
38
|
error?: import("@poncho-ai/sdk").AgentFailure;
|
|
26
39
|
timestamp: number;
|
package/test/compaction.test.ts
CHANGED
|
@@ -5,8 +5,10 @@ import type { Message } from "@poncho-ai/sdk";
|
|
|
5
5
|
import {
|
|
6
6
|
compactMessages,
|
|
7
7
|
findSafeSplitPoint,
|
|
8
|
+
findSafeSplitPointByTurns,
|
|
8
9
|
resolveCompactionConfig,
|
|
9
10
|
} from "../src/compaction.js";
|
|
11
|
+
import { deriveTaskOutcome, stripOutcomeVerdict } from "../src/orchestrator/orchestrator.js";
|
|
10
12
|
|
|
11
13
|
// ── Fake model ──────────────────────────────────────────────────────────
|
|
12
14
|
// A MockLanguageModelV3 whose doGenerate returns a fixed text and records the
|
|
@@ -254,9 +256,9 @@ describe("compactMessages", () => {
|
|
|
254
256
|
expect(sentPrompt).toContain("MERGE AND UPDATE");
|
|
255
257
|
});
|
|
256
258
|
|
|
257
|
-
it("still truncates non-prior-summary long messages to
|
|
259
|
+
it("still truncates non-prior-summary long messages to the per-message cap", async () => {
|
|
258
260
|
const { model, prompts } = fakeModel("S");
|
|
259
|
-
const longUser = "X".repeat(
|
|
261
|
+
const longUser = "X".repeat(6000); // > SUMMARIZATION_MESSAGE_TRUNCATION_CHARS (4000)
|
|
260
262
|
const messages: Message[] = [
|
|
261
263
|
userMsg(longUser),
|
|
262
264
|
assistantText("a0"),
|
|
@@ -272,3 +274,123 @@ describe("compactMessages", () => {
|
|
|
272
274
|
expect(sentPrompt).not.toContain("MERGE AND UPDATE");
|
|
273
275
|
});
|
|
274
276
|
});
|
|
277
|
+
|
|
278
|
+
describe("findSafeSplitPointByTurns", () => {
|
|
279
|
+
const turns = (n: number, assistantText = "a"): Message[] => {
|
|
280
|
+
const msgs: Message[] = [];
|
|
281
|
+
for (let i = 0; i < n; i++) {
|
|
282
|
+
msgs.push(userMsg(`u${i}`));
|
|
283
|
+
msgs.push({ role: "assistant", content: `${assistantText}${i}` });
|
|
284
|
+
}
|
|
285
|
+
return msgs;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
it("preserves the last N whole turns verbatim", () => {
|
|
289
|
+
const messages = turns(6); // userIdx = [0,2,4,6,8,10]
|
|
290
|
+
// keepRecentTurns=4, generous token budget → split before the 4th-from-last
|
|
291
|
+
// user message (index 4), preserving turns u2..u5.
|
|
292
|
+
const idx = findSafeSplitPointByTurns(messages, 4, 6, 1_000_000);
|
|
293
|
+
expect(idx).toBe(4);
|
|
294
|
+
const preserved = messages.slice(idx);
|
|
295
|
+
const userCount = preserved.filter((m) => m.role === "user").length;
|
|
296
|
+
expect(userCount).toBe(4);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("reduces N when N turns exceed the preserved-token budget", () => {
|
|
300
|
+
// Each assistant turn is large (~460 tokens); 4 turns preserved would blow a
|
|
301
|
+
// small budget, so it must fall back to fewer turns.
|
|
302
|
+
const big = "Z".repeat(1600);
|
|
303
|
+
const messages = turns(6, big); // userIdx=[0,2,4,6,8,10]
|
|
304
|
+
const idx = findSafeSplitPointByTurns(messages, 4, 6, 1200);
|
|
305
|
+
// n=4 (~1840 tok) and n=3 (~1380 tok) exceed 1200; n=2 (~920 tok) fits →
|
|
306
|
+
// split at userIdx[len-2] = index 8, preserving 2 turns.
|
|
307
|
+
expect(idx).toBe(8);
|
|
308
|
+
expect(messages.slice(idx).filter((m) => m.role === "user").length).toBe(2);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("returns -1 for a single giant turn with no earlier user boundary", () => {
|
|
312
|
+
// One user message followed by many tool rounds — no safe boundary to
|
|
313
|
+
// compact against. (The message-based fallback also finds none.)
|
|
314
|
+
const messages: Message[] = [userMsg("do a big thing")];
|
|
315
|
+
for (let i = 0; i < 8; i++) {
|
|
316
|
+
messages.push(assistantToolCall(`step ${i}`, "run_code"));
|
|
317
|
+
messages.push(toolResult(`out ${i}`));
|
|
318
|
+
}
|
|
319
|
+
expect(findSafeSplitPointByTurns(messages, 4, 6, 1_000_000)).toBe(-1);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("deriveTaskOutcome / stripOutcomeVerdict", () => {
|
|
324
|
+
it("parses the self-declared verdict", () => {
|
|
325
|
+
expect(deriveTaskOutcome("did it [[OUTCOME: succeeded]] all good", false)).toBe("succeeded");
|
|
326
|
+
expect(deriveTaskOutcome("partial [[OUTCOME: partial]] some left", false)).toBe("partial");
|
|
327
|
+
expect(deriveTaskOutcome("couldn't [[OUTCOME: failed]] no tools", false)).toBe("failed");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("defaults to unknown when no verdict is present (never assumes success)", () => {
|
|
331
|
+
expect(deriveTaskOutcome("I finished everything.", false)).toBe("unknown");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("treats abnormal ends and empty output as failed", () => {
|
|
335
|
+
expect(deriveTaskOutcome("whatever", true)).toBe("failed");
|
|
336
|
+
expect(deriveTaskOutcome(" ", false)).toBe("failed");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("strips the verdict marker from delivered text", () => {
|
|
340
|
+
expect(stripOutcomeVerdict("Here is the report.\n[[OUTCOME: failed]] missing API key")).toBe(
|
|
341
|
+
"Here is the report.",
|
|
342
|
+
);
|
|
343
|
+
expect(stripOutcomeVerdict("No verdict here")).toBe("No verdict here");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("compactMessages — failure fidelity", () => {
|
|
348
|
+
const config = resolveCompactionConfig({ keepRecentTurns: 1 });
|
|
349
|
+
|
|
350
|
+
it("renders a failed subagent as failed (never 'completed') and keeps its reason", async () => {
|
|
351
|
+
const { model } = fakeModel("SUMMARY");
|
|
352
|
+
const messages: Message[] = [
|
|
353
|
+
userMsg("please read my chats"),
|
|
354
|
+
assistantText("delegating to a subagent"),
|
|
355
|
+
userMsg(
|
|
356
|
+
'[Subagent Result] Subagent "read chats" (sub_fail) failed:\n\nI could not access the LinkedIn tools, so I read nothing.',
|
|
357
|
+
{
|
|
358
|
+
_subagentCallback: true,
|
|
359
|
+
subagentId: "sub_fail",
|
|
360
|
+
task: "read chats",
|
|
361
|
+
taskOutcome: "failed",
|
|
362
|
+
} as Message["metadata"],
|
|
363
|
+
),
|
|
364
|
+
assistantText("continuing"),
|
|
365
|
+
userMsg("how did it go?"),
|
|
366
|
+
assistantText("let me check"),
|
|
367
|
+
];
|
|
368
|
+
const res = await compactMessages(model, messages, config);
|
|
369
|
+
const content = res.messages[0]!.content as string;
|
|
370
|
+
expect(content).toContain("## Subagents");
|
|
371
|
+
expect(content).toContain("sub_fail");
|
|
372
|
+
// Rendered with the failed outcome, not "completed".
|
|
373
|
+
expect(content).toContain("— failed");
|
|
374
|
+
expect(content).not.toContain("(sub_fail) — completed");
|
|
375
|
+
// The failure reason survives verbatim.
|
|
376
|
+
expect(content).toContain("could not access the LinkedIn tools");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("keeps failure-bearing messages when the input budget forces drops", async () => {
|
|
380
|
+
const { model, prompts } = fakeModel("S");
|
|
381
|
+
// Many bulky non-failure messages plus one early failure. The failure must
|
|
382
|
+
// survive into the summarizer input even if older bulk is dropped.
|
|
383
|
+
const messages: Message[] = [userMsg("start")];
|
|
384
|
+
messages.push(toolResult("ERROR: the deploy failed because the token expired"));
|
|
385
|
+
for (let i = 0; i < 400; i++) {
|
|
386
|
+
messages.push(assistantText("filler ".repeat(200) + i));
|
|
387
|
+
messages.push(userMsg(`ok ${i}`));
|
|
388
|
+
}
|
|
389
|
+
messages.push(userMsg("final question"));
|
|
390
|
+
messages.push(assistantText("final answer"));
|
|
391
|
+
await compactMessages(model, messages, config);
|
|
392
|
+
const sentPrompt = prompts.join("\n");
|
|
393
|
+
// The failure line is marked important and never dropped.
|
|
394
|
+
expect(sentPrompt).toContain("the deploy failed because the token expired");
|
|
395
|
+
});
|
|
396
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { Message } from "@poncho-ai/sdk";
|
|
3
|
+
import {
|
|
4
|
+
assembleCheckpointMessages,
|
|
5
|
+
buildToolResultMessage,
|
|
6
|
+
buildResumeCheckpoints,
|
|
7
|
+
applyTurnMetadata,
|
|
8
|
+
type StoredApproval,
|
|
9
|
+
} from "../src/orchestrator/index.js";
|
|
10
|
+
import type { Conversation } from "../src/state.js";
|
|
11
|
+
|
|
12
|
+
const conv = (overrides: Partial<Conversation> & Pick<Conversation, "messages">): Conversation => ({
|
|
13
|
+
conversationId: "conv_test",
|
|
14
|
+
title: "test",
|
|
15
|
+
ownerId: "owner",
|
|
16
|
+
tenantId: null,
|
|
17
|
+
createdAt: Date.now(),
|
|
18
|
+
updatedAt: Date.now(),
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// An assistant tool-call message is JSON `{ text, tool_calls: [...] }` in the
|
|
23
|
+
// canonical transcript.
|
|
24
|
+
const assistantToolCall = (text: string, calls: Array<{ id: string; name: string }>): Message => ({
|
|
25
|
+
role: "assistant",
|
|
26
|
+
content: JSON.stringify({ text, tool_calls: calls.map((c) => ({ ...c, input: {} })) }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const meta = (harnessMessages?: Message[]) => ({
|
|
30
|
+
latestRunId: "",
|
|
31
|
+
contextTokens: 0,
|
|
32
|
+
contextWindow: 0,
|
|
33
|
+
harnessMessages,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("assembleCheckpointMessages", () => {
|
|
37
|
+
it("initial checkpoint: prior history (sliced by base) + delta", () => {
|
|
38
|
+
const prior: Message[] = [
|
|
39
|
+
{ role: "user", content: "hi" },
|
|
40
|
+
{ role: "assistant", content: "hello" },
|
|
41
|
+
];
|
|
42
|
+
// Task-bearing turn: the delta carries the turn's user message + the
|
|
43
|
+
// assistant preamble/tool-call. base = prior length.
|
|
44
|
+
const delta: Message[] = [
|
|
45
|
+
{ role: "user", content: "yeah those are a lot better" },
|
|
46
|
+
assistantToolCall("I found the two PE events…", [{ id: "call_1", name: "update_event" }]),
|
|
47
|
+
];
|
|
48
|
+
const checkpoint = { baseMessageCount: prior.length, checkpointMessages: delta } as StoredApproval;
|
|
49
|
+
const out = assembleCheckpointMessages(conv({ messages: prior }), checkpoint);
|
|
50
|
+
|
|
51
|
+
expect(out).toHaveLength(4);
|
|
52
|
+
expect(out.some((m) => m.role === "user" && m.content === "yeah those are a lot better")).toBe(true);
|
|
53
|
+
expect(out.some((m) => typeof m.content === "string" && m.content.includes("I found the two PE events"))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("resume convention: base 0 + full canonical returns the full canonical verbatim", () => {
|
|
57
|
+
const full: Message[] = [
|
|
58
|
+
{ role: "user", content: "hi" },
|
|
59
|
+
{ role: "user", content: "approve this" },
|
|
60
|
+
assistantToolCall("preamble", [{ id: "call_1", name: "t" }]),
|
|
61
|
+
];
|
|
62
|
+
const checkpoint = { baseMessageCount: 0, checkpointMessages: full } as StoredApproval;
|
|
63
|
+
// Display messages are irrelevant when base is 0 — the reconstruction must
|
|
64
|
+
// NOT depend on them (this is what the old PonchOS hand-merge got wrong).
|
|
65
|
+
const out = assembleCheckpointMessages(conv({ messages: [{ role: "user", content: "unrelated display" }] }), checkpoint);
|
|
66
|
+
expect(out).toEqual(full);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("buildToolResultMessage", () => {
|
|
71
|
+
const asst = assistantToolCall("", [{ id: "call_1", name: "update_event" }]);
|
|
72
|
+
|
|
73
|
+
it("pairs a provided result by callId", () => {
|
|
74
|
+
const msg = buildToolResultMessage(asst, [{ callId: "call_1", toolName: "update_event", result: { ok: true } }]);
|
|
75
|
+
expect(msg?.role).toBe("tool");
|
|
76
|
+
expect(msg?.content).toContain("call_1");
|
|
77
|
+
expect(msg?.content).toContain("ok");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("emits the deferred-error marker when a result is missing", () => {
|
|
81
|
+
const msg = buildToolResultMessage(asst, []);
|
|
82
|
+
expect(msg?.content).toContain("Tool execution deferred");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns undefined for a non-assistant / non-tool-call message", () => {
|
|
86
|
+
expect(buildToolResultMessage({ role: "user", content: "hi" }, [])).toBeUndefined();
|
|
87
|
+
expect(buildToolResultMessage({ role: "assistant", content: "plain text" }, [])).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("buildResumeCheckpoints + round-trip (the regression)", () => {
|
|
92
|
+
it("stores full canonical (base 0) so the NEXT resume reconstructs without dropping the user turn", () => {
|
|
93
|
+
// The continuation ran with this full canonical (incl. the turn's user
|
|
94
|
+
// message + preamble) and appended a tool result.
|
|
95
|
+
const fullWithResults: Message[] = [
|
|
96
|
+
{ role: "user", content: "hi" },
|
|
97
|
+
{ role: "assistant", content: "hello" },
|
|
98
|
+
{ role: "user", content: "approve this" },
|
|
99
|
+
assistantToolCall("preamble text", [{ id: "call_1", name: "t1" }]),
|
|
100
|
+
{ role: "tool", content: JSON.stringify([{ type: "tool_result", tool_use_id: "call_1", content: "done" }]) },
|
|
101
|
+
];
|
|
102
|
+
// It then gated again on a new tool (the re-checkpoint event delta).
|
|
103
|
+
const delta2: Message[] = [assistantToolCall("", [{ id: "call_2", name: "t2" }])];
|
|
104
|
+
const stored = buildResumeCheckpoints({
|
|
105
|
+
priorMessages: fullWithResults,
|
|
106
|
+
checkpointEvent: {
|
|
107
|
+
approvals: [{ approvalId: "a2", tool: "t2", toolCallId: "call_2", input: {} }],
|
|
108
|
+
checkpointMessages: delta2,
|
|
109
|
+
pendingToolCalls: [{ id: "call_2", name: "t2", input: {} }],
|
|
110
|
+
},
|
|
111
|
+
runId: "run_2",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(stored[0]!.baseMessageCount).toBe(0);
|
|
115
|
+
expect(stored[0]!.checkpointMessages).toEqual([...fullWithResults, ...delta2]);
|
|
116
|
+
|
|
117
|
+
// Next resume reconstructs from the stored checkpoint — display is
|
|
118
|
+
// deliberately unrelated to prove no dependence on it.
|
|
119
|
+
const reconstructed = assembleCheckpointMessages(
|
|
120
|
+
conv({ messages: [{ role: "user", content: "unrelated" }], pendingApprovals: stored }),
|
|
121
|
+
stored[0]!,
|
|
122
|
+
);
|
|
123
|
+
// The user turn + preamble that PonchOS used to drop MUST survive.
|
|
124
|
+
expect(reconstructed.some((m) => m.role === "user" && m.content === "approve this")).toBe(true);
|
|
125
|
+
expect(reconstructed.some((m) => typeof m.content === "string" && m.content.includes("preamble text"))).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("applyTurnMetadata transcript-integrity guard", () => {
|
|
130
|
+
const latestUser: Message = { role: "user", content: "the latest question", metadata: { id: "u_latest" } };
|
|
131
|
+
|
|
132
|
+
it("logs when the latest user message is missing from the canonical transcript", () => {
|
|
133
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
134
|
+
const c = conv({ messages: [{ role: "assistant", content: "earlier" }, latestUser] });
|
|
135
|
+
// Canonical is missing "the latest question" — the exact divergence.
|
|
136
|
+
applyTurnMetadata(c, meta([{ role: "user", content: "hi" }, { role: "assistant", content: "x" }]));
|
|
137
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining("[transcript-guard]"));
|
|
138
|
+
spy.mockRestore();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("stays silent when the latest user message is present in canonical", () => {
|
|
142
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
143
|
+
const c = conv({ messages: [latestUser] });
|
|
144
|
+
applyTurnMetadata(c, meta([{ role: "user", content: "the latest question" }, { role: "assistant", content: "reply" }]));
|
|
145
|
+
expect(spy).not.toHaveBeenCalled();
|
|
146
|
+
spy.mockRestore();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("skips the check when canonical was legitimately compacted", () => {
|
|
150
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
151
|
+
const c = conv({ messages: [latestUser] });
|
|
152
|
+
applyTurnMetadata(c, meta([{ role: "assistant", content: "summary", metadata: { isCompactionSummary: true } }]));
|
|
153
|
+
expect(spy).not.toHaveBeenCalled();
|
|
154
|
+
spy.mockRestore();
|
|
155
|
+
});
|
|
156
|
+
});
|