@poncho-ai/harness 0.61.0 → 0.61.2

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.61.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.61.2 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
4
4
 
5
5
  [embed-docs] Generated poncho-docs.ts with 4 topics
@@ -8,9 +8,9 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
+ ESM dist/index.js 581.91 KB
11
12
  ESM dist/isolate-F2PPSUL6.js 53.82 KB
12
- ESM dist/index.js 579.17 KB
13
- ESM ⚡️ Build success in 267ms
13
+ ESM ⚡️ Build success in 239ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 8217ms
16
- DTS dist/index.d.ts 108.62 KB
15
+ DTS ⚡️ Build success in 7703ms
16
+ DTS dist/index.d.ts 110.56 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.61.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [`c881cd2`](https://github.com/cesr/poncho-ai/commit/c881cd2cafd4cb70f9614601dd233aafac334b8c) Thanks [@cesr](https://github.com/cesr)! - Telemetry: make context compaction observable and tag subagent runs.
8
+ - When auto-compaction fires mid-run, stamp the active `invoke_agent` span with
9
+ `poncho.compaction.occurred` / `tokens_before` / `tokens_after` /
10
+ `context_window` / `trigger`. Compaction rewrites the message prefix — which
11
+ wipes the per-model prompt cache and forces the next turn to re-create a large
12
+ cached span cold — so "which turns compacted, and what did that cost" was
13
+ previously invisible at the span level. No-op when telemetry is suppressed
14
+ (no active span).
15
+ - Subagent runs spawned by the orchestrator now carry
16
+ `telemetryAttributes` (`poncho.run.kind: "subagent"`, `latitude.tags`,
17
+ and a `latitude.metadata` link to the parent conversation) so subagent spend
18
+ segments out of the parent chat/job on the trace instead of being
19
+ indistinguishable from it.
20
+
21
+ ## 0.61.1
22
+
23
+ ### Patch Changes
24
+
25
+ - [`a16f4fa`](https://github.com/cesr/poncho-ai/commit/a16f4fa32f0809d6ff73f0041cb0e0a3fb980bb1) Thanks [@cesr](https://github.com/cesr)! - Fix: checkpoint-resume no longer drops a turn's user message from the model
26
+ transcript. Extract the checkpoint→transcript assembly the orchestrator's
27
+ `resumeRunFromCheckpoint` used into shared, exported helpers —
28
+ `assembleCheckpointMessages`, `buildToolResultMessage`, `buildResumeCheckpoints`
29
+ — so external embedders (which execute gated tools themselves) reconstruct the
30
+ canonical transcript identically instead of re-deriving the index arithmetic
31
+ and drifting. Add a transcript-integrity guard in `applyTurnMetadata` that
32
+ logs when a finalize would leave the latest user message out of the
33
+ model-facing transcript.
34
+
3
35
  ## 0.61.0
4
36
 
5
37
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -2222,6 +2222,40 @@ declare const buildApprovalCheckpoints: ({ approvals, runId, checkpointMessages,
2222
2222
  pendingToolCalls: PendingToolCall[];
2223
2223
  kind?: "approval" | "device";
2224
2224
  }) => NonNullable<Conversation["pendingApprovals"]>;
2225
+ /** Reconstruct the full canonical transcript a checkpoint resumes from: the
2226
+ * prior history (before the checkpointed turn) + the checkpoint delta.
2227
+ * Handles both storage conventions via `normalizeApprovalCheckpoint` — the
2228
+ * initial checkpoint (base = prior length, delta) and a resume-created one
2229
+ * (base 0, full canonical). */
2230
+ declare const assembleCheckpointMessages: (conversation: Conversation, checkpoint: StoredApproval) => Message[];
2231
+ /** Pair an assistant tool-call message's `tool_calls` with externally-computed
2232
+ * results into the single `role:"tool"` message a continuation reads.
2233
+ * Returns undefined when `assistantMsg` isn't an assistant message with
2234
+ * parseable tool calls. Used to persist the resume's canonical history so it
2235
+ * matches what `continueFromToolResult` fed the model. Missing results
2236
+ * default to the deferred-error marker (same text the run uses). */
2237
+ declare const buildToolResultMessage: (assistantMsg: Message | undefined, toolResults: Array<{
2238
+ callId: string;
2239
+ toolName: string;
2240
+ result?: unknown;
2241
+ error?: string;
2242
+ }>) => Message | undefined;
2243
+ /** Build the `pendingApprovals` rows for a checkpoint reached DURING a resume
2244
+ * continuation. Stores the WHOLE canonical history the continuation ran with
2245
+ * (`priorMessages` = prior + tool result + new delta) with
2246
+ * `baseMessageCount: 0`. Keeping the full history here — rather than a delta
2247
+ * + a base count into a different message array — is what lets the next
2248
+ * resume reconstruct with no index arithmetic. */
2249
+ declare const buildResumeCheckpoints: ({ priorMessages, checkpointEvent, runId, kind, }: {
2250
+ priorMessages: Message[];
2251
+ checkpointEvent: {
2252
+ approvals: ApprovalEventItem[];
2253
+ checkpointMessages: Message[];
2254
+ pendingToolCalls: PendingToolCall[];
2255
+ };
2256
+ runId: string;
2257
+ kind?: "approval" | "device";
2258
+ }) => NonNullable<Conversation["pendingApprovals"]>;
2225
2259
  declare const applyTurnMetadata: (conv: Conversation, meta: TurnResultMetadata, opts?: {
2226
2260
  clearContinuation?: boolean;
2227
2261
  clearApprovals?: boolean;
@@ -2503,4 +2537,4 @@ type NewEntryNoId = NewConversationEntry extends infer T ? T extends NewConversa
2503
2537
  */
2504
2538
  declare const appendEntriesSafe: (store: ConversationStore, conversation: Pick<Conversation, "conversationId" | "ownerId" | "tenantId">, entries: NewEntryNoId[], log: Logger) => Promise<ConversationEntry[]>;
2505
2539
 
2506
- export { type ActiveConversationRun, type ActiveSubagentRun, type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, AgentOrchestrator, type ApprovalEventItem, type ArchivedToolResult$1 as ArchivedToolResult, type BashConfig, BashEnvironmentManager, type BashExecutionLimits, type BuiltInToolToggles, CALLBACK_LOCK_STALE_MS, type CallbackStartedEntry, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type ContinuationHooks, type Conversation, type ConversationCreateInit, type ConversationEntry, type ConversationState, type ConversationStatusSnapshot, type ConversationStore, type ConversationSummary, type CreateSkillToolsOptions, type CronJobConfig, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_NAME, DEFAULT_MAX_STEPS, DEFAULT_MODEL_NAME, DEFAULT_MODEL_PROVIDER, DEFAULT_TAGLINE, DEFAULT_TEMPERATURE, DEFAULT_TIMEOUT, type DefaultAgentDefinitionOptions, type EventSink, type ExecuteTurnResult, type HarnessOptions, type HarnessRunOutput, type HistorySource, InMemoryConversationStore, InMemoryEngine, InMemoryStateStore, type IsolateBinding, type IsolateConfig, LocalMcpBridge, LocalUploadStore, MAX_CONCURRENT_SUBAGENTS, MAX_CONTINUATION_COUNT, MAX_SUBAGENT_CALLBACK_COUNT, MAX_SUBAGENT_NESTING, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, type MountProvider, type NetworkConfig, type NewConversationEntry, OPENAI_CODEX_CLIENT_ID, type OpenAICodexAuthConfig, type OpenAICodexDeviceAuthRequest, type OpenAICodexSession, type OrchestratorHooks, type OrchestratorOptions, type OtlpConfig, type OtlpOption, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentApproval, type PendingSubagentResult, type PendingToolCall, type PonchoConfig, PonchoFsAdapter, PostgresEngine, type ProviderConfig, type Recurrence, type RecurrenceType, type Reminder, type ReminderCreateInput, type ReminderStatus, type ReminderStore, type RemoteMcpServerConfig, type RunConversationTurnOpts, type RunConversationTurnResult, type RunOutcome, type RunRequest, type RuntimeRenderContext, S3UploadStore, STALE_SUBAGENT_THRESHOLD_MS, STORAGE_SCHEMA_VERSION, type SecretsStore, type SkillContextEntry, type SkillMetadata, type SkillSource, SqliteEngine, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type StorageEngine, type StorageFactoryOptions, type StorageProvider, type StoredApproval, type SubagentManager, type SubagentResult, type SubagentResultEntry, type SubagentSpawnResult, type SubagentSummary, type SubagentTaskOutcome, type SubagentTranscript, type SubagentTranscriptMode, TOOL_RESULT_ARCHIVE_PARAM, type TelemetryConfig, TelemetryEmitter, type TenantTokenPayload, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type TurnDraftState, type TurnResultMetadata, type TurnSection, type UploadStore, type UploadsConfig, VFS_SCHEME, VercelBlobUploadStore, type VfsDirEntry, type VfsStat, type VirtualMount, abnormalEndResponse, appendEntriesSafe, applyTurnMetadata, buildAgentDirectoryName, buildApprovalCheckpoints, buildAssistantMetadata, buildSkillContextWindow, buildToolCompletedText, cloneSections, compactMessages, completeOpenAICodexDeviceAuth, computeNextOccurrence, createBashTool, createConversationStore, createConversationStoreFromEngine, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createMemoryStore, createMemoryStoreFromEngine, createMemoryTools, createModelProvider, createReminderStore, createReminderStoreFromEngine, createReminderTools, createSearchTools, createSecretsStore, createSkillTools, createStateStore, createStorageEngine, createSubagentTools, createTodoStoreFromEngine, createTurnDraftState, createUploadStore, createWriteTool, decodeFileInputData, defaultAgentDefinition, deleteOpenAICodexSession, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, executeConversationTurn, findSafeSplitPoint, findSafeSplitPointByTurns, flushTurnDraft, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getOpenAICodexAccessToken, getOpenAICodexAuthFilePath, getOpenAICodexRequiredScopes, getPendingSubagentResults, getPonchoStoreRoot, isMessageArray, jsonSchemaToZod, lastAssistantText, loadCanonicalHistory, loadPonchoConfig, loadRunHistory, loadSkillContext, loadSkillInstructions, loadSkillMetadata, loadSkillMetadataFromDirs, loadVfsSkillMetadata, mergeSkills, normalizeApprovalCheckpoint, normalizeOtlp, normalizeScriptPolicyPath, normalizeToolAccess, parseAgentFile, parseAgentMarkdown, parseSkillFrontmatter, ponchoDocsTool, readOpenAICodexSession, readSkillResource, realResponseText, recordStandardTurnEvent, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveEnv, resolveMemoryConfig, resolveRunRequest, resolveSkillDirs, resolveStateConfig, runConversationTurn, slugifyStorageComponent, startOpenAICodexDeviceAuth, stripPillMetaTokens, verifyTenantToken, withToolResultArchiveParam, writeOpenAICodexSession };
2540
+ export { type ActiveConversationRun, type ActiveSubagentRun, type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, AgentOrchestrator, type ApprovalEventItem, type ArchivedToolResult$1 as ArchivedToolResult, type BashConfig, BashEnvironmentManager, type BashExecutionLimits, type BuiltInToolToggles, CALLBACK_LOCK_STALE_MS, type CallbackStartedEntry, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type ContinuationHooks, type Conversation, type ConversationCreateInit, type ConversationEntry, type ConversationState, type ConversationStatusSnapshot, type ConversationStore, type ConversationSummary, type CreateSkillToolsOptions, type CronJobConfig, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_NAME, DEFAULT_MAX_STEPS, DEFAULT_MODEL_NAME, DEFAULT_MODEL_PROVIDER, DEFAULT_TAGLINE, DEFAULT_TEMPERATURE, DEFAULT_TIMEOUT, type DefaultAgentDefinitionOptions, type EventSink, type ExecuteTurnResult, type HarnessOptions, type HarnessRunOutput, type HistorySource, InMemoryConversationStore, InMemoryEngine, InMemoryStateStore, type IsolateBinding, type IsolateConfig, LocalMcpBridge, LocalUploadStore, MAX_CONCURRENT_SUBAGENTS, MAX_CONTINUATION_COUNT, MAX_SUBAGENT_CALLBACK_COUNT, MAX_SUBAGENT_NESTING, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, type MountProvider, type NetworkConfig, type NewConversationEntry, OPENAI_CODEX_CLIENT_ID, type OpenAICodexAuthConfig, type OpenAICodexDeviceAuthRequest, type OpenAICodexSession, type OrchestratorHooks, type OrchestratorOptions, type OtlpConfig, type OtlpOption, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentApproval, type PendingSubagentResult, type PendingToolCall, type PonchoConfig, PonchoFsAdapter, PostgresEngine, type ProviderConfig, type Recurrence, type RecurrenceType, type Reminder, type ReminderCreateInput, type ReminderStatus, type ReminderStore, type RemoteMcpServerConfig, type RunConversationTurnOpts, type RunConversationTurnResult, type RunOutcome, type RunRequest, type RuntimeRenderContext, S3UploadStore, STALE_SUBAGENT_THRESHOLD_MS, STORAGE_SCHEMA_VERSION, type SecretsStore, type SkillContextEntry, type SkillMetadata, type SkillSource, SqliteEngine, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type StorageEngine, type StorageFactoryOptions, type StorageProvider, type StoredApproval, type SubagentManager, type SubagentResult, type SubagentResultEntry, type SubagentSpawnResult, type SubagentSummary, type SubagentTaskOutcome, type SubagentTranscript, type SubagentTranscriptMode, TOOL_RESULT_ARCHIVE_PARAM, type TelemetryConfig, TelemetryEmitter, type TenantTokenPayload, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type TurnDraftState, type TurnResultMetadata, type TurnSection, type UploadStore, type UploadsConfig, VFS_SCHEME, VercelBlobUploadStore, type VfsDirEntry, type VfsStat, type VirtualMount, abnormalEndResponse, appendEntriesSafe, applyTurnMetadata, assembleCheckpointMessages, buildAgentDirectoryName, buildApprovalCheckpoints, buildAssistantMetadata, buildResumeCheckpoints, buildSkillContextWindow, buildToolCompletedText, buildToolResultMessage, cloneSections, compactMessages, completeOpenAICodexDeviceAuth, computeNextOccurrence, createBashTool, createConversationStore, createConversationStoreFromEngine, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createMemoryStore, createMemoryStoreFromEngine, createMemoryTools, createModelProvider, createReminderStore, createReminderStoreFromEngine, createReminderTools, createSearchTools, createSecretsStore, createSkillTools, createStateStore, createStorageEngine, createSubagentTools, createTodoStoreFromEngine, createTurnDraftState, createUploadStore, createWriteTool, decodeFileInputData, defaultAgentDefinition, deleteOpenAICodexSession, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, executeConversationTurn, findSafeSplitPoint, findSafeSplitPointByTurns, flushTurnDraft, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getOpenAICodexAccessToken, getOpenAICodexAuthFilePath, getOpenAICodexRequiredScopes, getPendingSubagentResults, getPonchoStoreRoot, isMessageArray, jsonSchemaToZod, lastAssistantText, loadCanonicalHistory, loadPonchoConfig, loadRunHistory, loadSkillContext, loadSkillInstructions, loadSkillMetadata, loadSkillMetadataFromDirs, loadVfsSkillMetadata, mergeSkills, normalizeApprovalCheckpoint, normalizeOtlp, normalizeScriptPolicyPath, normalizeToolAccess, parseAgentFile, parseAgentMarkdown, parseSkillFrontmatter, ponchoDocsTool, readOpenAICodexSession, readSkillResource, realResponseText, recordStandardTurnEvent, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveEnv, resolveMemoryConfig, resolveRunRequest, resolveSkillDirs, resolveStateConfig, runConversationTurn, slugifyStorageComponent, startOpenAICodexDeviceAuth, stripPillMetaTokens, verifyTenantToken, withToolResultArchiveParam, writeOpenAICodexSession };
package/dist/index.js CHANGED
@@ -11461,6 +11461,13 @@ ${textContent}` };
11461
11461
  const tokensAfterCompaction = estimateTotalTokens(systemPrompt, messages, toolDefsJsonForEstimate);
11462
11462
  latestContextTokens = tokensAfterCompaction;
11463
11463
  toolOutputEstimateSinceModel = 0;
11464
+ trace.getActiveSpan()?.setAttributes({
11465
+ "poncho.compaction.occurred": true,
11466
+ "poncho.compaction.tokens_before": effectiveTokens,
11467
+ "poncho.compaction.tokens_after": tokensAfterCompaction,
11468
+ "poncho.compaction.context_window": contextWindow,
11469
+ "poncho.compaction.trigger": compactionConfig.trigger
11470
+ });
11464
11471
  yield pushEvent({
11465
11472
  type: "compaction:completed",
11466
11473
  tokensBefore: effectiveTokens,
@@ -12772,6 +12779,7 @@ var resolveRunRequest = (conversation, request) => {
12772
12779
  };
12773
12780
 
12774
12781
  // src/orchestrator/turn.ts
12782
+ import { randomUUID as randomUUID6 } from "crypto";
12775
12783
  var createTurnDraftState = () => ({
12776
12784
  assistantResponse: "",
12777
12785
  toolTimeline: [],
@@ -12993,6 +13001,52 @@ var buildApprovalCheckpoints = ({
12993
13001
  pendingToolCalls,
12994
13002
  kind
12995
13003
  }));
13004
+ var assembleCheckpointMessages = (conversation, checkpoint) => {
13005
+ const n = normalizeApprovalCheckpoint(checkpoint, conversation.messages);
13006
+ const base = n.baseMessageCount != null ? conversation.messages.slice(0, n.baseMessageCount) : [];
13007
+ return [...base, ...n.checkpointMessages ?? []];
13008
+ };
13009
+ var buildToolResultMessage = (assistantMsg, toolResults) => {
13010
+ if (assistantMsg?.role !== "assistant") return void 0;
13011
+ let toolCalls = [];
13012
+ try {
13013
+ const parsed = JSON.parse(typeof assistantMsg.content === "string" ? assistantMsg.content : "");
13014
+ toolCalls = parsed.tool_calls ?? [];
13015
+ } catch {
13016
+ return void 0;
13017
+ }
13018
+ if (toolCalls.length === 0) return void 0;
13019
+ const provided = new Map(toolResults.map((r) => [r.callId, r]));
13020
+ return {
13021
+ role: "tool",
13022
+ content: JSON.stringify(
13023
+ toolCalls.map((tc) => {
13024
+ const r = provided.get(tc.id);
13025
+ return {
13026
+ type: "tool_result",
13027
+ tool_use_id: tc.id,
13028
+ tool_name: r?.toolName ?? tc.name,
13029
+ content: r ? r.error ? `Tool error: ${r.error}` : JSON.stringify(r.result ?? null) : "Tool error: Tool execution deferred (pending approval checkpoint)"
13030
+ };
13031
+ })
13032
+ ),
13033
+ metadata: { timestamp: Date.now(), id: randomUUID6() }
13034
+ };
13035
+ };
13036
+ var buildResumeCheckpoints = ({
13037
+ priorMessages,
13038
+ checkpointEvent,
13039
+ runId,
13040
+ kind = "approval"
13041
+ }) => buildApprovalCheckpoints({
13042
+ approvals: checkpointEvent.approvals,
13043
+ runId,
13044
+ checkpointMessages: [...priorMessages, ...checkpointEvent.checkpointMessages],
13045
+ baseMessageCount: 0,
13046
+ pendingToolCalls: checkpointEvent.pendingToolCalls,
13047
+ kind
13048
+ });
13049
+ var messageText = (m) => typeof m.content === "string" ? m.content : Array.isArray(m.content) ? m.content.map((p) => p.text ?? "").join("") : "";
12996
13050
  var applyTurnMetadata = (conv, meta, opts = {}) => {
12997
13051
  const {
12998
13052
  clearContinuation = true,
@@ -13011,6 +13065,20 @@ var applyTurnMetadata = (conv, meta, opts = {}) => {
13011
13065
  } else if (shouldRebuildCanonical) {
13012
13066
  conv._harnessMessages = conv.messages;
13013
13067
  }
13068
+ if (isMessageArray(conv._harnessMessages) && conv._harnessMessages.length > 0) {
13069
+ const canonical = conv._harnessMessages;
13070
+ const summarized = canonical.some((m) => m.metadata?.isCompactionSummary);
13071
+ const lastUser = [...conv.messages].reverse().find((m) => m.role === "user");
13072
+ const lastUserText = lastUser ? messageText(lastUser).trim() : "";
13073
+ if (!summarized && lastUserText) {
13074
+ const present = canonical.some((m) => m.role === "user" && messageText(m).trim() === lastUserText);
13075
+ if (!present) {
13076
+ console.error(
13077
+ `[transcript-guard] conversation ${conv.conversationId}: model-facing transcript is missing the latest user message \u2014 it diverged from the display transcript. This is a resume/finalize message-assembly bug; the model will not see that turn's input.`
13078
+ );
13079
+ }
13080
+ }
13081
+ }
13014
13082
  if (meta.toolResultArchive !== void 0) {
13015
13083
  conv._toolResultArchive = meta.toolResultArchive;
13016
13084
  }
@@ -13041,12 +13109,12 @@ var STALE_SUBAGENT_THRESHOLD_MS = 5 * 60 * 1e3;
13041
13109
  import { createLogger as createLogger8, getTextContent as getTextContent3 } from "@poncho-ai/sdk";
13042
13110
 
13043
13111
  // src/orchestrator/entries-dual-write.ts
13044
- import { randomUUID as randomUUID6 } from "crypto";
13112
+ import { randomUUID as randomUUID7 } from "crypto";
13045
13113
  var appendEntriesSafe = async (store, conversation, entries, log2) => {
13046
13114
  if (entries.length === 0) return [];
13047
13115
  try {
13048
13116
  const withIds = entries.map(
13049
- (e) => ({ id: randomUUID6(), ...e })
13117
+ (e) => ({ id: randomUUID7(), ...e })
13050
13118
  );
13051
13119
  return await store.appendEntries(
13052
13120
  conversation.conversationId,
@@ -13285,34 +13353,11 @@ var AgentOrchestrator = class {
13285
13353
  });
13286
13354
  let latestRunId = conversation.runtimeRunId ?? "";
13287
13355
  let checkpointedRun = false;
13288
- const normalizedCheckpoint = normalizeApprovalCheckpoint(checkpoint, conversation.messages);
13289
- const baseMessages = normalizedCheckpoint.baseMessageCount != null ? conversation.messages.slice(0, normalizedCheckpoint.baseMessageCount) : [];
13290
- const fullCheckpointMessages = [...baseMessages, ...normalizedCheckpoint.checkpointMessages];
13291
- let resumeToolResultMsg;
13292
- const lastCpMsg = fullCheckpointMessages[fullCheckpointMessages.length - 1];
13293
- if (lastCpMsg?.role === "assistant") {
13294
- try {
13295
- const parsed = JSON.parse(typeof lastCpMsg.content === "string" ? lastCpMsg.content : "");
13296
- const cpToolCalls = parsed.tool_calls ?? [];
13297
- if (cpToolCalls.length > 0) {
13298
- const providedMap = new Map(toolResults.map((r) => [r.callId, r]));
13299
- resumeToolResultMsg = {
13300
- role: "tool",
13301
- content: JSON.stringify(cpToolCalls.map((tc) => {
13302
- const provided = providedMap.get(tc.id);
13303
- return {
13304
- type: "tool_result",
13305
- tool_use_id: tc.id,
13306
- tool_name: provided?.toolName ?? tc.name,
13307
- content: provided ? provided.error ? `Tool error: ${provided.error}` : JSON.stringify(provided.result ?? null) : "Tool error: Tool execution deferred (pending approval checkpoint)"
13308
- };
13309
- })),
13310
- metadata: { timestamp: Date.now() }
13311
- };
13312
- }
13313
- } catch {
13314
- }
13315
- }
13356
+ const fullCheckpointMessages = assembleCheckpointMessages(conversation, checkpoint);
13357
+ const resumeToolResultMsg = buildToolResultMessage(
13358
+ fullCheckpointMessages[fullCheckpointMessages.length - 1],
13359
+ toolResults
13360
+ );
13316
13361
  const fullCheckpointWithResults = resumeToolResultMsg ? [...fullCheckpointMessages, resumeToolResultMsg] : fullCheckpointMessages;
13317
13362
  let draftRef;
13318
13363
  let execution;
@@ -13357,12 +13402,10 @@ var AgentOrchestrator = class {
13357
13402
  const cpEvent = event;
13358
13403
  const conv = await this.conversationStore.get(conversationId);
13359
13404
  if (conv) {
13360
- conv.pendingApprovals = buildApprovalCheckpoints({
13361
- approvals: cpEvent.approvals,
13362
- runId: latestRunId,
13363
- checkpointMessages: [...fullCheckpointWithResults, ...cpEvent.checkpointMessages],
13364
- baseMessageCount: 0,
13365
- pendingToolCalls: cpEvent.pendingToolCalls
13405
+ conv.pendingApprovals = buildResumeCheckpoints({
13406
+ priorMessages: fullCheckpointWithResults,
13407
+ checkpointEvent: cpEvent,
13408
+ runId: latestRunId
13366
13409
  });
13367
13410
  conv.updatedAt = Date.now();
13368
13411
  await this.conversationStore.update(conv);
@@ -13671,7 +13714,17 @@ var AgentOrchestrator = class {
13671
13714
  messages: harnessMessages,
13672
13715
  abortSignal: childAbortController.signal,
13673
13716
  // Inherit the parent run's telemetry choice (e.g. incognito).
13674
- suppressTelemetry: conversation.subagentMeta?.suppressTelemetry
13717
+ suppressTelemetry: conversation.subagentMeta?.suppressTelemetry,
13718
+ // Distinguish subagent turns from the parent chat/job on the trace:
13719
+ // `poncho.run.kind` is the queryable attribute PonchOS already sets on
13720
+ // foreground/job runs, and `latitude.tags` (JSON-array string) is what
13721
+ // Latitude reads into its filterable tags field, so subagent spend can
13722
+ // be segmented out. `latitude.metadata` links back to the parent.
13723
+ telemetryAttributes: {
13724
+ "poncho.run.kind": "subagent",
13725
+ "latitude.tags": '["subagent"]',
13726
+ ...conversation.parentConversationId ? { "latitude.metadata": JSON.stringify({ parentConversationId: conversation.parentConversationId }) } : {}
13727
+ }
13675
13728
  })) {
13676
13729
  if (event.type === "run:started") {
13677
13730
  latestRunId = event.runId;
@@ -14164,7 +14217,14 @@ ${resultBody}`,
14164
14217
  messages: continuationMessages,
14165
14218
  abortSignal: childAbortController.signal,
14166
14219
  // Inherit the parent run's telemetry choice (e.g. incognito).
14167
- suppressTelemetry: conversation.subagentMeta?.suppressTelemetry
14220
+ suppressTelemetry: conversation.subagentMeta?.suppressTelemetry,
14221
+ // Tag the subagent continuation the same way as the initial run (see
14222
+ // the fan-out call above) so its spend segments out of the parent's.
14223
+ telemetryAttributes: {
14224
+ "poncho.run.kind": "subagent",
14225
+ "latitude.tags": '["subagent"]',
14226
+ "latitude.metadata": JSON.stringify({ parentConversationId })
14227
+ }
14168
14228
  })) {
14169
14229
  if (event.type === "run:started") {
14170
14230
  const active = this.activeConversationRuns.get(conversationId);
@@ -14610,7 +14670,7 @@ ${resultBody}`,
14610
14670
  };
14611
14671
 
14612
14672
  // src/orchestrator/run-conversation-turn.ts
14613
- import { randomUUID as randomUUID7 } from "crypto";
14673
+ import { randomUUID as randomUUID8 } from "crypto";
14614
14674
  import { createLogger as createLogger9 } from "@poncho-ai/sdk";
14615
14675
  var log = createLogger9("orchestrator");
14616
14676
  var runConversationTurn = async (opts) => {
@@ -14650,9 +14710,9 @@ var runConversationTurn = async (opts) => {
14650
14710
  const userMessage = {
14651
14711
  role: "user",
14652
14712
  content: userContent,
14653
- metadata: { id: randomUUID7(), timestamp: turnTimestamp }
14713
+ metadata: { id: randomUUID8(), timestamp: turnTimestamp }
14654
14714
  };
14655
- const assistantId = randomUUID7();
14715
+ const assistantId = randomUUID8();
14656
14716
  const draft = createTurnDraftState();
14657
14717
  let latestRunId = conversation.runtimeRunId ?? "";
14658
14718
  let runCancelled = false;
@@ -15028,11 +15088,14 @@ export {
15028
15088
  abnormalEndResponse,
15029
15089
  appendEntriesSafe,
15030
15090
  applyTurnMetadata,
15091
+ assembleCheckpointMessages,
15031
15092
  buildAgentDirectoryName,
15032
15093
  buildApprovalCheckpoints,
15033
15094
  buildAssistantMetadata,
15095
+ buildResumeCheckpoints,
15034
15096
  buildSkillContextWindow,
15035
15097
  buildToolCompletedText,
15098
+ buildToolResultMessage,
15036
15099
  cloneSections,
15037
15100
  compactMessages,
15038
15101
  completeOpenAICodexDeviceAuth,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.61.0",
3
+ "version": "0.61.2",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/harness.ts CHANGED
@@ -3115,6 +3115,21 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3115
3115
  const tokensAfterCompaction = estimateTotalTokens(systemPrompt, messages, toolDefsJsonForEstimate);
3116
3116
  latestContextTokens = tokensAfterCompaction;
3117
3117
  toolOutputEstimateSinceModel = 0;
3118
+ // Stamp the compaction onto the active OTel span (the invoke_agent
3119
+ // root, active here because run() executes inside its context) so
3120
+ // observability backends can find compacted turns and measure the
3121
+ // cost they cause — a compaction rewrites the message prefix, which
3122
+ // wipes the per-model prompt cache and forces the next turn to
3123
+ // re-create a large cached span cold. Without this, "which turns
3124
+ // compacted, and what did it cost" is invisible at the span level.
3125
+ // No-op when telemetry is off (no active span).
3126
+ trace.getActiveSpan()?.setAttributes({
3127
+ "poncho.compaction.occurred": true,
3128
+ "poncho.compaction.tokens_before": effectiveTokens,
3129
+ "poncho.compaction.tokens_after": tokensAfterCompaction,
3130
+ "poncho.compaction.context_window": contextWindow,
3131
+ "poncho.compaction.trigger": compactionConfig.trigger,
3132
+ });
3118
3133
  yield pushEvent({
3119
3134
  type: "compaction:completed",
3120
3135
  tokensBefore: effectiveTokens,
@@ -19,6 +19,9 @@ export {
19
19
  executeConversationTurn,
20
20
  normalizeApprovalCheckpoint,
21
21
  buildApprovalCheckpoints,
22
+ assembleCheckpointMessages,
23
+ buildToolResultMessage,
24
+ buildResumeCheckpoints,
22
25
  applyTurnMetadata,
23
26
  type StoredApproval,
24
27
  type PendingToolCall,
@@ -10,6 +10,9 @@ import {
10
10
  applyTurnMetadata,
11
11
  normalizeApprovalCheckpoint,
12
12
  buildApprovalCheckpoints,
13
+ assembleCheckpointMessages,
14
+ buildToolResultMessage,
15
+ buildResumeCheckpoints,
13
16
  createTurnDraftState,
14
17
  recordStandardTurnEvent,
15
18
  type TurnDraftState,
@@ -421,38 +424,11 @@ export class AgentOrchestrator {
421
424
  let latestRunId = conversation.runtimeRunId ?? "";
422
425
  let checkpointedRun = false;
423
426
 
424
- const normalizedCheckpoint = normalizeApprovalCheckpoint(checkpoint, conversation.messages);
425
- const baseMessages = normalizedCheckpoint.baseMessageCount != null
426
- ? conversation.messages.slice(0, normalizedCheckpoint.baseMessageCount)
427
- : [];
428
- const fullCheckpointMessages = [...baseMessages, ...normalizedCheckpoint.checkpointMessages!];
429
-
430
- let resumeToolResultMsg: Message | undefined;
431
- const lastCpMsg = fullCheckpointMessages[fullCheckpointMessages.length - 1];
432
- if (lastCpMsg?.role === "assistant") {
433
- try {
434
- const parsed = JSON.parse(typeof lastCpMsg.content === "string" ? lastCpMsg.content : "");
435
- const cpToolCalls: Array<{ id: string; name: string }> = parsed.tool_calls ?? [];
436
- if (cpToolCalls.length > 0) {
437
- const providedMap = new Map(toolResults.map(r => [r.callId, r]));
438
- resumeToolResultMsg = {
439
- role: "tool",
440
- content: JSON.stringify(cpToolCalls.map(tc => {
441
- const provided = providedMap.get(tc.id);
442
- return {
443
- type: "tool_result",
444
- tool_use_id: tc.id,
445
- tool_name: provided?.toolName ?? tc.name,
446
- content: provided
447
- ? (provided.error ? `Tool error: ${provided.error}` : JSON.stringify(provided.result ?? null))
448
- : "Tool error: Tool execution deferred (pending approval checkpoint)",
449
- };
450
- })),
451
- metadata: { timestamp: Date.now() },
452
- };
453
- }
454
- } catch { /* last message is not a parseable assistant-with-tools -- skip */ }
455
- }
427
+ const fullCheckpointMessages = assembleCheckpointMessages(conversation, checkpoint);
428
+ const resumeToolResultMsg = buildToolResultMessage(
429
+ fullCheckpointMessages[fullCheckpointMessages.length - 1],
430
+ toolResults,
431
+ );
456
432
  const fullCheckpointWithResults = resumeToolResultMsg
457
433
  ? [...fullCheckpointMessages, resumeToolResultMsg]
458
434
  : fullCheckpointMessages;
@@ -505,12 +481,10 @@ export class AgentOrchestrator {
505
481
  };
506
482
  const conv = await this.conversationStore.get(conversationId);
507
483
  if (conv) {
508
- conv.pendingApprovals = buildApprovalCheckpoints({
509
- approvals: cpEvent.approvals,
484
+ conv.pendingApprovals = buildResumeCheckpoints({
485
+ priorMessages: fullCheckpointWithResults,
486
+ checkpointEvent: cpEvent,
510
487
  runId: latestRunId,
511
- checkpointMessages: [...fullCheckpointWithResults, ...cpEvent.checkpointMessages],
512
- baseMessageCount: 0,
513
- pendingToolCalls: cpEvent.pendingToolCalls,
514
488
  });
515
489
  conv.updatedAt = Date.now();
516
490
  await this.conversationStore.update(conv);
@@ -888,6 +862,18 @@ export class AgentOrchestrator {
888
862
  abortSignal: childAbortController.signal,
889
863
  // Inherit the parent run's telemetry choice (e.g. incognito).
890
864
  suppressTelemetry: conversation.subagentMeta?.suppressTelemetry,
865
+ // Distinguish subagent turns from the parent chat/job on the trace:
866
+ // `poncho.run.kind` is the queryable attribute PonchOS already sets on
867
+ // foreground/job runs, and `latitude.tags` (JSON-array string) is what
868
+ // Latitude reads into its filterable tags field, so subagent spend can
869
+ // be segmented out. `latitude.metadata` links back to the parent.
870
+ telemetryAttributes: {
871
+ "poncho.run.kind": "subagent",
872
+ "latitude.tags": "[\"subagent\"]",
873
+ ...(conversation.parentConversationId
874
+ ? { "latitude.metadata": JSON.stringify({ parentConversationId: conversation.parentConversationId }) }
875
+ : {}),
876
+ },
891
877
  })) {
892
878
  if (event.type === "run:started") {
893
879
  latestRunId = event.runId;
@@ -1478,6 +1464,13 @@ export class AgentOrchestrator {
1478
1464
  abortSignal: childAbortController.signal,
1479
1465
  // Inherit the parent run's telemetry choice (e.g. incognito).
1480
1466
  suppressTelemetry: conversation.subagentMeta?.suppressTelemetry,
1467
+ // Tag the subagent continuation the same way as the initial run (see
1468
+ // the fan-out call above) so its spend segments out of the parent's.
1469
+ telemetryAttributes: {
1470
+ "poncho.run.kind": "subagent",
1471
+ "latitude.tags": "[\"subagent\"]",
1472
+ "latitude.metadata": JSON.stringify({ parentConversationId }),
1473
+ },
1481
1474
  })) {
1482
1475
  if (event.type === "run:started") {
1483
1476
  const active = this.activeConversationRuns.get(conversationId);
@@ -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
  }
@@ -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
+ });