mixdog 0.9.136 → 0.9.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +0 -1
  3. package/src/runtime/agent/orchestrator/context/collect.mjs +2 -15
  4. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -3
  5. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +31 -57
  6. package/src/runtime/agent/orchestrator/session/evidence-union.mjs +12 -1
  7. package/src/runtime/agent/orchestrator/session/evidence-union.test.mjs +16 -0
  8. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -5
  9. package/src/runtime/agent/orchestrator/session/provider-prefix-guard.mjs +63 -0
  10. package/src/runtime/agent/orchestrator/session/provider-prefix-guard.test.mjs +95 -0
  11. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +1 -1
  12. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +0 -1
  13. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +1 -6
  14. package/src/runtime/memory/lib/memory-embed.mjs +2 -2
  15. package/src/runtime/memory/lib/memory.mjs +3 -4
  16. package/src/runtime/memory/lib/session-ingest.mjs +5 -9
  17. package/src/runtime/shared/background-tasks.mjs +3 -1
  18. package/src/runtime/shared/task-notification-envelope.mjs +1 -1
  19. package/src/runtime/shared/tool-card-model.mjs +4 -3
  20. package/src/runtime/shared/tool-execution-contract.mjs +16 -33
  21. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  22. package/src/session-runtime/notification-bus.test.mjs +1 -1
  23. package/src/session-runtime/provider-request-snapshot.mjs +10 -17
  24. package/src/session-runtime/provider-request-snapshot.test.mjs +37 -0
  25. package/src/standalone/hook-bus/handlers.mjs +0 -2
  26. package/src/standalone/hook-bus.mjs +0 -3
  27. package/src/tui/dist/index.mjs +3 -3
  28. package/src/tui/session/agent-envelope.mjs +24 -19
  29. package/src/tui/session/agent-job-feed.mjs +6 -5
  30. package/src/tui/session/completion-card-restore.test.mjs +2 -2
  31. package/src/tui/session/notification-plan.mjs +4 -0
  32. package/src/tui/session/session-api-ext.mjs +6 -6
  33. package/src/tui/session-local.mjs +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.136",
3
+ "version": "0.9.138",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -311,7 +311,6 @@ export function buildStableProviderPromptCacheKey(provider, opts, prefix = {}) {
311
311
  effort: cleanString(prefix.effort ?? opts?.effort),
312
312
  fast: prefix.fast === true || opts?.fast === true,
313
313
  serviceTier: cleanString(prefix.serviceTier),
314
- toolChoice: cleanString(prefix.toolChoice),
315
314
  parallelToolCalls: prefix.parallelToolCalls === false ? false : true,
316
315
  cacheLaneSlot: laneEnabled ? shardSlot : null,
317
316
  cacheLaneShards: autoLane ? 'auto' : shardCount > 1 ? shardCount : null,
@@ -828,7 +828,7 @@ export function loadScopedRoleInstructions(agent, provider = null) {
828
828
  }
829
829
 
830
830
  // --- Compose system prompt — 4-BP cache layout ---
831
- // Returns { baseRules, stableSystemContext, sessionMarker, volatileTail } mapping
831
+ // Returns the three stable system blocks and the BP3 core used for refreshes.
832
832
  // directly to the breakpoint plan:
833
833
  // BP1 (1h, system block #1) = baseRules — shared tool policy
834
834
  // BP2 (1h, system block #2) = stableSystemContext — profile, skills, deferred/MCP
@@ -894,20 +894,7 @@ export function composeSystemPrompt(opts) {
894
894
  ? sessionMarkerParts.join('\n\n---\n\n')
895
895
  : '';
896
896
 
897
- // ── BP4: live message tail ─────────────────────────────────────────
898
- // Raw role, permission, and task labels are intentionally omitted: role
899
- // selection already shapes the session/rules/tools, permissions are
900
- // enforced structurally, and the task body is sent as the actual user turn
901
- // by askSession().
902
- const volatileParts = [];
903
- // workspaceContext's discovered-project list is intentionally not injected:
904
- // BP3 already carries the scoped session/project environment, while the
905
- // full layout would add redundant cache fragmentation.
906
- const volatileTail = volatileParts.length > 0
907
- ? volatileParts.join('\n\n')
908
- : '';
909
-
910
- return { baseRules, stableSystemContext, sessionMarkerCore, sessionMarker, volatileTail };
897
+ return { baseRules, stableSystemContext, sessionMarkerCore, sessionMarker };
911
898
  }
912
899
  // --- Helpers ---
913
900
  function readSafe(path) {
@@ -325,9 +325,9 @@ export function _toAnthropicMessagesForTest(messages) {
325
325
  // sees, so the cache breakpoint is stable across turns.
326
326
  // message-anchor: prefer a safe tool_result tail, then a previous real user
327
327
  // text turn if another slot remains. Synthetic
328
- // <system-reminder> messages and current pure-text prompts
329
- // are excluded so per-call volatileTail/current prompt
330
- // content never becomes a 1h prefix key.
328
+ // synthetic reminder messages and current pure-text prompts
329
+ // are excluded so per-call prompt content never becomes a
330
+ // 1h prefix key.
331
331
  // messageTtl === null disables the tail. BP3 (tier3) now rides a system block,
332
332
  // so it is no longer marked here.
333
333
  // ANTHROPIC_MSG_SLOTS=0 is honoured upstream by passing messageTtl = null.
@@ -15,7 +15,6 @@ import {
15
15
  DEFAULT_COMPACT_TYPE,
16
16
  } from './compact.mjs';
17
17
  import { isContextOverflowError } from '../providers/retry-classifier.mjs';
18
- import { stripSoftWarns } from '../tool-loop-guard.mjs';
19
18
  import { tryReadCached, setReadCached, invalidatePathForSession, clearReadDedupSession, extractTouchedPathsFromPatch, tryScopedToolCached, setScopedToolCached, clearScopedToolsForSession, clearScopedToolsForSessionPaths, invalidatePrefetchCache } from './read-dedup.mjs';
20
19
  import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
21
20
 
@@ -34,6 +33,7 @@ import { executeTool, _scopedCacheOutcomeForCall, resolveLiveToolCwd } from './l
34
33
  // this file; import it from there directly rather than via this module.
35
34
  import { recordToolBatch } from '../tools/tool-batch-trace.mjs';
36
35
  import { projectProviderEvidence } from './evidence-union.mjs';
36
+ import { prepareProviderPrefixGuard } from './provider-prefix-guard.mjs';
37
37
 
38
38
 
39
39
  import { resolve as resolvePath, isAbsolute } from 'path';
@@ -185,9 +185,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
185
185
  const signal = opts.signal || null;
186
186
  const sessionAgent = opts.session?.agent;
187
187
  const forcedFirstTool = opts.forcedFirstTool ?? null;
188
- const forcedFirstToolDef = forcedFirstTool
189
- ? tools.find(tool => tool?.name === forcedFirstTool)
190
- : null;
191
188
  // Opaque providerState passthrough. The loop never inspects provider-native
192
189
  // payloads; the originating provider owns them. Stateful Responses
193
190
  // providers may use it for continuation anchors.
@@ -205,6 +202,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
205
202
  }
206
203
  };
207
204
  const sessionRef = opts.session || null;
205
+ let _providerPrefixGuardState = sessionRef?._providerPrefixGuardState || null;
206
+ let _fixedProviderToolSurface = sessionRef?._providerToolSurfaceSnapshot || null;
208
207
  const loopUsageMetricsEpoch = () => Number(sessionRef?.usageMetricsEpoch) || 0;
209
208
  const loopUsageMetricsTurnId = () => Number(sessionRef?.usageMetricsTurnId) || 0;
210
209
  // Sub-agent (worker/heavy-worker/reviewer/…) sessions
@@ -427,25 +426,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
427
426
  // sessionRef.cwd is the live SSOT. The legacy positional cwd is only the
428
427
  // turn-start snapshot and becomes stale after an in-turn cwd tool call.
429
428
  cwd = resolveLiveToolCwd(cwd, sessionRef);
430
- // Staged pre-cap warnings + one true hard stop. The ONLY count-based
431
- // forced termination is the hard cap at maxLoopIterations (default 200):
432
- // a genuine runaway guard. Before it, staged warnings fire at 50%/75%/90%
433
- // of the cap steering the model to converge — warnings only, nothing is
434
- // cut off early. Other runaway protection is behavior-based (steering
435
- // ladder hints, REPEAT_FAIL_LIMIT), never a lower iteration count.
436
- let _iterWarnStage = 0;
437
- // Tiny-cap loops can't afford staged 50/75/90%
438
- // steers — the 50% stage lands on iteration 1 in every session, spamming
439
- // the normal batch→answer path. For caps < 10 emit ONE wrap-up warning at
440
- // the penultimate iteration instead; caps >= 10 keep staged behavior.
441
- const _singleWarn = maxLoopIterations < 10;
442
- const _iterWarnAt = _singleWarn
443
- ? [Math.max(1, maxLoopIterations - 1)]
444
- : [
445
- Math.floor(maxLoopIterations * 0.5),
446
- Math.floor(maxLoopIterations * 0.75),
447
- Math.floor(maxLoopIterations * 0.9),
448
- ];
429
+ // The hard cap is the sole count-based steering injection. Behavioral
430
+ // guards below handle repeated failures/dedup without periodic reminders.
449
431
  while (true) {
450
432
  // A cwd tool call updates sessionRef in place. Refresh before building
451
433
  // this iteration's eager dispatcher and cache keys so every following
@@ -478,26 +460,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
478
460
  messages.push({ role: 'user', content: `<system-reminder>\n${finalTurnReminder}\n</system-reminder>`, meta: 'hook' });
479
461
  process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); forcing final text turn.\n`);
480
462
  }
481
- if (_iterWarnStage < _iterWarnAt.length && iterations >= _iterWarnAt[_iterWarnStage]) {
482
- _iterWarnStage += 1;
483
- const warnAt = _iterWarnAt[_iterWarnStage - 1];
484
- const stageMsg = _singleWarn
485
- ? `Iteration budget nearly spent: ${warnAt} of ${maxLoopIterations} iterations used — answer NOW with the best anchors you already hold.`
486
- : _iterWarnStage === 1
487
- ? `Iteration budget notice: ${warnAt} of ${maxLoopIterations} iterations used. Converge on a conclusion: prefer finishing the current objective over opening new exploration.`
488
- : `Iteration budget warning (stage ${_iterWarnStage}): ${warnAt} of ${maxLoopIterations} iterations used — the loop hard-stops at ${maxLoopIterations}. Wrap up now: summarize progress, state what remains, and finish with your best current result.`;
489
- messages.push({ role: 'user', content: `<system-reminder>\n${stageMsg}\n</system-reminder>`, meta: 'hook' });
490
- process.stderr.write(`[loop] iteration warning stage ${_iterWarnStage} at ${iterations} (sess=${sessionId || 'unknown'}); continuing with steer.\n`);
491
- try {
492
- appendAgentTrace({
493
- sessionId,
494
- iteration: iterations,
495
- kind: 'steer',
496
- payload: { tag: 'iteration_warning', stage: _iterWarnStage, at: iterations, unit: maxLoopIterations },
497
- agent: sessionAgent || null,
498
- });
499
- } catch { /* best-effort */ }
500
- }
501
463
  // Drain queued steering/prompts BEFORE the pre-send compact check, but
502
464
  // only immediately after a tool batch has completed: queued entries
503
465
  // are attached after tool results are appended and before the recursive
@@ -510,9 +472,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
510
472
  _toolBatchJustCompleted = false;
511
473
  _lastToolBatchHadSleep = false;
512
474
  }
513
- const baseSendTools = _capFinalToolsDisabled
514
- ? tools
515
- : (forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools);
475
+ const baseSendTools = tools;
516
476
  let sendTools;
517
477
  let requestToolScope;
518
478
  let compactChanged;
@@ -527,23 +487,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
527
487
  || messages.some((message, index) => message !== messagesBeforeTranscriptRepair[index]))) {
528
488
  opts.cacheBreakIntent = 'transcript_rebuild';
529
489
  }
530
- for (let _i = 0; _i < messages.length; _i++) {
531
- const _m = messages[_i];
532
- if (_m && _m.role === 'tool' && typeof _m.content === 'string' && _m.content.includes('⚠')) {
533
- const _stripped = stripSoftWarns(_m.content);
534
- if (_stripped !== _m.content) {
535
- _m.content = _stripped;
536
- if (!opts.cacheBreakIntent) opts.cacheBreakIntent = 'soft_warn_strip';
537
- }
538
- }
539
- }
540
- sendTools = snapshotProviderRequestTools({
490
+ const _candidateSendTools = snapshotProviderRequestTools({
541
491
  provider: sessionRef?.provider || provider?.name,
542
492
  tools: baseSendTools,
543
493
  nativeTools: opts.nativeTools,
544
494
  messages,
545
495
  session: sessionRef,
546
496
  });
497
+ if (!_fixedProviderToolSurface) {
498
+ _fixedProviderToolSurface = _candidateSendTools;
499
+ if (sessionRef) sessionRef._providerToolSurfaceSnapshot = _fixedProviderToolSurface;
500
+ }
501
+ sendTools = _fixedProviderToolSurface;
547
502
  requestToolScope = {
548
503
  session: sessionRef,
549
504
  provider: sessionRef?.provider || provider?.name,
@@ -655,8 +610,25 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
655
610
  const _evidenceProjection = projectProviderEvidence(_providerMessageSource, {
656
611
  enabled: !_evidenceUnionDisabled,
657
612
  apply: !_evidenceUnionShadow,
613
+ // Path aliases are a whole-history projection: a later repeated
614
+ // path can rewrite already-sent tool results and invalidate every
615
+ // provider's prefix cache. Row/exact-result references are
616
+ // append-only, so retain those and disable only the unsafe pass.
617
+ pathAliases: false,
658
618
  });
659
619
  const _providerMessages = _evidenceProjection.messages;
620
+ const _providerPrefixGuardCandidate = prepareProviderPrefixGuard(
621
+ _providerPrefixGuardState,
622
+ _providerMessages,
623
+ {
624
+ tools: sendTools,
625
+ nativeTools: Array.isArray(opts.nativeTools) ? opts.nativeTools : [],
626
+ },
627
+ {
628
+ provider: sessionRef?.provider || provider?.name || null,
629
+ cacheBreakIntent: opts.cacheBreakIntent,
630
+ },
631
+ );
660
632
  if (_evidenceProjection.stats.reusedRows > 0
661
633
  || _evidenceProjection.stats.exactResultRefs > 0
662
634
  || _evidenceProjection.stats.pathAliases > 0) {
@@ -723,6 +695,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
723
695
  continue;
724
696
  }
725
697
  response = _sendResult.response;
698
+ _providerPrefixGuardState = _providerPrefixGuardCandidate;
699
+ if (sessionRef) sessionRef._providerPrefixGuardState = _providerPrefixGuardState;
726
700
  if (_imageStripActive && Array.isArray(_pendingImageStripPersistMessages)) {
727
701
  messages.splice(0, messages.length, ..._pendingImageStripPersistMessages);
728
702
  _imageStripActive = false;
@@ -526,7 +526,18 @@ export function projectProviderEvidence(messages, options = {}) {
526
526
  }
527
527
  }
528
528
 
529
- const pathProjection = projectProviderPathAliases(projected || messages, { apply });
529
+ const pathProjection = options.pathAliases === false
530
+ ? {
531
+ messages: projected || messages,
532
+ stats: {
533
+ pathFacts: 0,
534
+ pathAliases: 0,
535
+ reusedPathFacts: 0,
536
+ pathAliasBytesSaved: 0,
537
+ changedIndexes: [],
538
+ },
539
+ }
540
+ : projectProviderPathAliases(projected || messages, { apply });
530
541
  stats.pathFacts = pathProjection.stats.pathFacts;
531
542
  stats.pathAliases = pathProjection.stats.pathAliases;
532
543
  stats.reusedPathFacts = pathProjection.stats.reusedPathFacts;
@@ -209,3 +209,19 @@ test('shadow mode reports savings without changing provider messages', () => {
209
209
  assert.equal(projected.stats.reusedRows, 1);
210
210
  assert.ok(projected.stats.afterBytes < projected.stats.beforeBytes);
211
211
  });
212
+
213
+ test('provider projection stays append-only when path aliases are disabled', () => {
214
+ const repeated = `src/${'nested/'.repeat(10)}feature.mjs`;
215
+ const firstMessages = [
216
+ call('glob_1', 'glob'),
217
+ result('glob_1', `${repeated}\nsrc/unique.mjs`),
218
+ ];
219
+ const first = projectProviderEvidence(firstMessages, { pathAliases: false });
220
+ const later = projectProviderEvidence([
221
+ ...firstMessages,
222
+ call('read_1', 'read', { file_path: repeated }),
223
+ result('read_1', `${repeated} [ok]\n1→export const value = 1;`),
224
+ ], { pathAliases: false });
225
+ assert.deepEqual(later.messages.slice(0, first.messages.length), first.messages);
226
+ assert.equal(later.stats.pathAliases, 0);
227
+ });
@@ -319,7 +319,7 @@ export function createSession(opts) {
319
319
  ? describeShellStartupPolicy()
320
320
  : '',
321
321
  ].filter(Boolean).join('\n');
322
- const { baseRules, stableSystemContext, sessionMarkerCore, sessionMarker, volatileTail } = composeSystemPrompt({
322
+ const { baseRules, stableSystemContext, sessionMarkerCore, sessionMarker } = composeSystemPrompt({
323
323
  userPrompt: opts.systemPrompt,
324
324
  agentRules: injectedRules || undefined,
325
325
  roleRules: roleRules || undefined,
@@ -357,10 +357,6 @@ export function createSession(opts) {
357
357
  // the field and serialize content as a normal system instruction).
358
358
  messages.push({ role: 'system', content: sessionMarker, cacheTier: 'tier3' });
359
359
  }
360
- if (volatileTail) {
361
- messages.push({ role: 'user', content: `<system-reminder>\n${volatileTail}\n</system-reminder>` });
362
- messages.push({ role: 'assistant', content: '.' });
363
- }
364
360
  if (opts.files?.length) {
365
361
  const fileContext = opts.files
366
362
  .map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
@@ -0,0 +1,63 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ const COMPACTION_INTENTS = new Set([
4
+ 'automatic_compaction',
5
+ 'deferred_body_compaction',
6
+ 'manual_compaction',
7
+ 'post_turn_compaction',
8
+ ]);
9
+
10
+ function digest(value) {
11
+ const encoded = JSON.stringify(value);
12
+ if (typeof encoded !== 'string') {
13
+ throw new TypeError('provider prefix value is not serializable');
14
+ }
15
+ return createHash('sha256').update(encoded).digest('hex');
16
+ }
17
+
18
+ function snapshot(messages, requestPrefix) {
19
+ return {
20
+ messageHashes: messages.map(digest),
21
+ requestPrefixHash: digest(requestPrefix),
22
+ };
23
+ }
24
+
25
+ export class ProviderPrefixMutationError extends Error {
26
+ constructor(message, details = {}) {
27
+ super(message);
28
+ this.name = 'ProviderPrefixMutationError';
29
+ this.code = 'PROVIDER_PREFIX_MUTATION';
30
+ this.details = details;
31
+ }
32
+ }
33
+
34
+ export function isCompactionPrefixReset(intent) {
35
+ return COMPACTION_INTENTS.has(String(intent || ''));
36
+ }
37
+
38
+ export function prepareProviderPrefixGuard(previous, messages, requestPrefix, options = {}) {
39
+ const next = snapshot(Array.isArray(messages) ? messages : [], requestPrefix);
40
+ if (!previous || isCompactionPrefixReset(options.cacheBreakIntent)) return next;
41
+
42
+ if (previous.requestPrefixHash !== next.requestPrefixHash) {
43
+ throw new ProviderPrefixMutationError('provider request prefix changed outside compaction', {
44
+ provider: options.provider || null,
45
+ kind: 'request_prefix',
46
+ });
47
+ }
48
+ if (next.messageHashes.length < previous.messageHashes.length) {
49
+ throw new ProviderPrefixMutationError('provider message history shrank outside compaction', {
50
+ provider: options.provider || null,
51
+ kind: 'history_shrink',
52
+ });
53
+ }
54
+ for (let index = 0; index < previous.messageHashes.length; index += 1) {
55
+ if (previous.messageHashes[index] === next.messageHashes[index]) continue;
56
+ throw new ProviderPrefixMutationError('provider message prefix changed outside compaction', {
57
+ provider: options.provider || null,
58
+ kind: 'message_prefix',
59
+ index,
60
+ });
61
+ }
62
+ return next;
63
+ }
@@ -0,0 +1,95 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ prepareProviderPrefixGuard,
5
+ ProviderPrefixMutationError,
6
+ } from './provider-prefix-guard.mjs';
7
+
8
+ const CACHE_PROVIDERS = [
9
+ 'anthropic',
10
+ 'anthropic-oauth',
11
+ 'openai',
12
+ 'openai-oauth',
13
+ 'xai',
14
+ 'grok-oauth',
15
+ 'gemini',
16
+ 'deepseek',
17
+ 'opencode-go',
18
+ 'cursor-oauth',
19
+ 'ollama',
20
+ 'lmstudio',
21
+ ];
22
+
23
+ test('accepts append-only provider history for every provider surface', () => {
24
+ for (const provider of CACHE_PROVIDERS) {
25
+ const first = prepareProviderPrefixGuard(
26
+ null,
27
+ [{ role: 'user', content: 'one' }],
28
+ { tools: [{ name: 'read' }], nativeTools: [] },
29
+ { provider },
30
+ );
31
+ assert.doesNotThrow(() => prepareProviderPrefixGuard(
32
+ first,
33
+ [
34
+ { role: 'user', content: 'one' },
35
+ { role: 'assistant', content: 'two' },
36
+ ],
37
+ { tools: [{ name: 'read' }], nativeTools: [] },
38
+ { provider },
39
+ ), provider);
40
+ }
41
+ });
42
+
43
+ test('rejects prior-message and tool-prefix rewrites outside compaction', () => {
44
+ const first = prepareProviderPrefixGuard(
45
+ null,
46
+ [{ role: 'user', content: 'one' }],
47
+ { tools: [{ name: 'read' }], nativeTools: [] },
48
+ );
49
+ assert.throws(
50
+ () => prepareProviderPrefixGuard(
51
+ first,
52
+ [{ role: 'user', content: 'rewritten' }],
53
+ { tools: [{ name: 'read' }], nativeTools: [] },
54
+ ),
55
+ ProviderPrefixMutationError,
56
+ );
57
+ assert.throws(
58
+ () => prepareProviderPrefixGuard(
59
+ first,
60
+ [{ role: 'user', content: 'one' }],
61
+ { tools: [{ name: 'read' }, { name: 'shell' }], nativeTools: [] },
62
+ ),
63
+ ProviderPrefixMutationError,
64
+ );
65
+ });
66
+
67
+ test('allows only compaction intents to establish a new prefix', () => {
68
+ const first = prepareProviderPrefixGuard(
69
+ null,
70
+ [{ role: 'user', content: 'one' }],
71
+ { tools: [{ name: 'read' }], nativeTools: [] },
72
+ );
73
+ for (const cacheBreakIntent of [
74
+ 'automatic_compaction',
75
+ 'deferred_body_compaction',
76
+ 'manual_compaction',
77
+ 'post_turn_compaction',
78
+ ]) {
79
+ assert.doesNotThrow(() => prepareProviderPrefixGuard(
80
+ first,
81
+ [{ role: 'user', content: 'compacted' }],
82
+ { tools: [{ name: 'read' }], nativeTools: [] },
83
+ { cacheBreakIntent },
84
+ ));
85
+ }
86
+ assert.throws(
87
+ () => prepareProviderPrefixGuard(
88
+ first,
89
+ [{ role: 'user', content: 'repaired' }],
90
+ { tools: [{ name: 'read' }], nativeTools: [] },
91
+ { cacheBreakIntent: 'transcript_rebuild' },
92
+ ),
93
+ ProviderPrefixMutationError,
94
+ );
95
+ });
@@ -67,7 +67,7 @@ function archivedAgentNotification(content, sessionId) {
67
67
  const text = typeof content === 'string' ? content : '';
68
68
  const marker = '\n\nResult:\n';
69
69
  const markerAt = text.indexOf(marker);
70
- if (markerAt < 0 || !/The async agent task .* has finished \(/.test(text.slice(0, markerAt))) return null;
70
+ if (markerAt < 0 || !/Async agent task .* finished\./.test(text.slice(0, markerAt))) return null;
71
71
  const lines = text.slice(markerAt + marker.length)
72
72
  .split(/\r?\n/)
73
73
  .map((line) => line.replace(/^>\s?/, ''));
@@ -684,7 +684,6 @@ export async function executeBashTool(args, workDir, options = {}) {
684
684
  task ? renderBackgroundTask(task) : (result.jobId ? `[task_id: ${result.jobId}]` : null),
685
685
  '',
686
686
  result.backgroundMessage || 'auto-backgrounded; still running — judge from the partial output whether waiting can finish in budget, or diagnose and pursue an alternative.',
687
- result.jobId ? 'You will be notified when it completes; do not poll.' : null,
688
687
  partialOutput ? `\n${partialOutput}` : '',
689
688
  ].filter((l) => l !== null && l !== '');
690
689
  return _prependDestructiveWarning(command, lines.join('\n'));
@@ -135,12 +135,7 @@ export async function executeTaskTool(args, options = {}) {
135
135
  if (isShellTask) refreshShellTask(taskId, { includeRunning: true });
136
136
  const latest = getBackgroundTask(taskId, { context: options }) || task;
137
137
  const rendered = renderBackgroundTask(latest, { includeResult: true });
138
- if (latest.status !== 'running') return rendered;
139
- return [
140
- rendered,
141
- '',
142
- 'Still running. Completion will be delivered automatically; do not poll or call task again unless the user explicitly asks for another snapshot.',
143
- ].join('\n');
138
+ return rendered;
144
139
  }
145
140
 
146
141
  if (action === 'cancel') {
@@ -104,7 +104,7 @@ const RAW_EMBED_EXCLUDE_CONTENT_RES = [
104
104
  /^\s*\[tool_call\b/i,
105
105
  /^\s*\[tool_result\b/i,
106
106
  /^\s*\[mixdog-runtime\]/i,
107
- /^\s*The async (?:shell task|agent task|\S+ execution|\S+) .*has finished\b.*review this result in your next step/i,
107
+ /^\s*Async .+ finished\./i,
108
108
  /^\s*background task\b/i,
109
109
  ]
110
110
  const RAW_EMBED_EXCLUDE_NON_CONVERSATION_CONTENT_RES = [
@@ -126,7 +126,7 @@ function isRawEmbeddable(role, content) {
126
126
 
127
127
  const RAW_EMBED_SQL_EXCLUDE_ROLE_VALUES = [...RAW_EMBED_EXCLUDE_ROLES]
128
128
  const RAW_EMBED_SQL_ALWAYS_EXCLUDE_CONTENT_RE =
129
- '^\\s*(\\[tool_call(?:\\s|\\]|$)|\\[tool_result(?:\\s|\\]|$)|\\[mixdog-runtime\\]|The async (shell task|agent task|\\S+ execution|\\S+) .*has finished(\\s|$).*review this result in your next step|background task(\\s|$))'
129
+ '^\\s*(\\[tool_call(?:\\s|\\]|$)|\\[tool_result(?:\\s|\\]|$)|\\[mixdog-runtime\\]|Async .+ finished\\.|background task(\\s|$))'
130
130
  const RAW_EMBED_SQL_NON_CONVERSATION_EXCLUDE_CONTENT_RE =
131
131
  '^\\s*\\[(system|log|offload|debug|trace|info|warn|warning|error|fatal)\\]'
132
132
 
@@ -447,9 +447,8 @@ export async function ensureCurrentSchemaExtensions(db, dims) {
447
447
  } catch (err) {
448
448
  __mixdogMemoryLog(`[memory] attachment-placeholder cleanup failed: ${err?.message || err}\n`)
449
449
  }
450
- // One-time cleanup: runtime tool-completion notification rows ("The async
451
- // shell/agent task ... has finished ... - review this result in your next
452
- // step." followed by an unquoted or `> `-quoted Result body, and
450
+ // One-time cleanup: runtime tool-completion notification rows ("Async ...
451
+ // finished." followed by an unquoted or `> `-quoted Result body, and
453
452
  // "[mixdog-runtime] ..." nudges) that were persisted by the transcript
454
453
  // watcher before it gained the shouldExcludeIngestMessage exclusion
455
454
  // (transcript-ingest.mjs). Gated by a meta flag so this DELETE runs at
@@ -467,7 +466,7 @@ export async function ensureCurrentSchemaExtensions(db, dims) {
467
466
  const candidates = await db.query(
468
467
  `SELECT id, content FROM entries
469
468
  WHERE role = 'user' AND (
470
- content LIKE 'The async % task % has finished%'
469
+ content LIKE 'Async % finished.%'
471
470
  OR content LIKE '[mixdog-runtime]%'
472
471
  OR content LIKE 'background task%'
473
472
  )`,
@@ -274,16 +274,13 @@ export function sessionMessageContentForIngest(m) {
274
274
  return base
275
275
  }
276
276
 
277
- // Head-line shape of toolCompletionInstruction() (tool-execution-contract.mjs)
278
- // as it is ACTUALLY persisted by mgr.enqueuePendingMessage UNQUOTED, no
279
- // `> ` prefix on the Result body (real DB rows look like:
280
- // "The async shell task <id> has finished (completed, exit 0) - review this
281
- // result in your next step.\nResult:\nbackground task\ntask_id: ...").
277
+ // Head-line shape of toolCompletionInstruction() as persisted by
278
+ // mgr.enqueuePendingMessage unquoted, with no `> ` prefix on Result.
282
279
  // isModelVisibleToolCompletionWrapper only matches the QUOTED (`> `) shape
283
280
  // mirrored via the notify-wrapper's own quoting, so it misses this unquoted
284
281
  // persisted form. The instruction head alone is a sufficient, unambiguous
285
282
  // fingerprint (only the runtime ever emits this exact phrase).
286
- const UNQUOTED_TOOL_COMPLETION_HEAD_RE = /^The async (?:shell task|agent task|\S+ execution|\S+) .*has finished\b.*review this result in your next step/i
283
+ const UNQUOTED_TOOL_COMPLETION_HEAD_RE = /^Async .+ finished\./i
287
284
 
288
285
  // Exported so memory.mjs's one-time cleanup (ensureCurrentSchemaExtensions)
289
286
  // can confirm SQL-prefiltered candidate rows with the SAME predicate the live
@@ -328,9 +325,8 @@ export function shouldExcludeIngestMessage(m) {
328
325
  const text = typeof raw === 'string' ? raw : ''
329
326
  if (/^\[mixdog-runtime\]/.test(text.trimStart())) return true
330
327
  if (isInternalRuntimeNotificationText(text)) return true
331
- // Model-visible tool-completion mirror rows (mgr.enqueuePendingMessage of
332
- // modelVisibleToolCompletionMessage "The async shell/agent task ... has
333
- // finished ... - review this result in your next step.\n\nResult:\n> ...").
328
+ // Model-visible tool-completion mirror rows from
329
+ // modelVisibleToolCompletionMessage ("Async ... finished.\n\nResult:\n> ...").
334
330
  // These are runtime notifications, not conversation, on both ingest paths.
335
331
  if (isModelVisibleToolCompletionWrapper(text)) return true
336
332
  // Unquoted persisted form of the same wrapper (see comment above).
@@ -466,6 +466,8 @@ export function renderBackgroundTask(taskOrId, { includeResult = false } = {}) {
466
466
  const lines = [
467
467
  'background task',
468
468
  `task_id: ${task.taskId}`,
469
+ `surface: ${task.surface}`,
470
+ `operation: ${task.operation}`,
469
471
  task.label ? `label: ${task.label}` : null,
470
472
  `status: ${task.status}`,
471
473
  `started: ${task.startedAt}`,
@@ -479,7 +481,7 @@ export function renderBackgroundTask(taskOrId, { includeResult = false } = {}) {
479
481
  && _so.slice(0, -11) === _se.slice(0, -11)
480
482
  ? _so.slice(0, -11) : null;
481
483
  for (const [key, value] of Object.entries(visibleMeta)) {
482
- if (key === 'task_id') continue; // already in the envelope header
484
+ if (key === 'task_id' || key === 'surface' || key === 'operation') continue;
483
485
  if (logsBase && (key === 'stdout' || key === 'stderr')) continue;
484
486
  lines.push(`${key}: ${value}`);
485
487
  }
@@ -70,7 +70,7 @@ export function renderShellCompletionEnvelope({
70
70
  }
71
71
 
72
72
  // Build the shell completion instruction via the shared wording so all async
73
- // surfaces read identically ("The async shell task … has finished"). The
73
+ // surfaces read identically ("Async shell task … finished."). The
74
74
  // exit detail is folded into the shared detail slot.
75
75
  export function shellCompletionInstruction({ jobId, status, exitCode = null } = {}) {
76
76
  return toolCompletionInstruction({
@@ -260,10 +260,11 @@ function joinActionAgent(action, agent) {
260
260
  export function agentResponseTitle(args, count = 1) {
261
261
  const total = Math.max(1, Number(count) || 1);
262
262
  if (total > 1) return `Responses ${total} agents`;
263
- const name = titleizeAgentName(args?.agent || args?.subagent_type || args?.name || '');
263
+ const name = titleizeAgentName(args?.agent || args?.subagent_type || args?.name || '') || 'Agent';
264
264
  // The agent + model identify the responder; the response summary itself
265
265
  // is hidden in the collapsed card (expanding still shows the full body).
266
- // No generic "Agent" fallback render just "Response" when the agent is empty.
266
+ // Keep the surface identifiable even when a failed/legacy completion has no
267
+ // concrete agent identity.
267
268
  return withModelAndTag(joinActionAgent('Response', name), args);
268
269
  }
269
270
 
@@ -322,7 +323,7 @@ export function hasAgentResponseResult(value) {
322
323
  }
323
324
  if (/^agent result\b/i.test(trimmed)) continue;
324
325
  if (/^(?:undefined|null)$/i.test(trimmed)) continue;
325
- if (/^<\/?(?:final-answer|task-notification|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>$/i.test(trimmed)) continue;
326
+ if (/^<\/?(?:final-answer|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>$/i.test(trimmed)) continue;
326
327
  if (!sawBlank && /^(?:agent task|background task|agent message queued\b|agent close:|task_id|surface|operation|label|status|type|target|agent|preset|model|effort|fast|limits|started|finished|error|notification|queueDepth):?\s*/i.test(trimmed)) continue;
327
328
  if (!sawBlank && /^(?:agents|tasks):\s*/i.test(trimmed)) continue;
328
329
  if (/^\(no agents or tasks\)$/i.test(trimmed)) continue;
@@ -38,26 +38,9 @@ export function backgroundTaskHeaderStatus(text) {
38
38
  return clean(match?.[1]).toLowerCase();
39
39
  }
40
40
 
41
- function notificationHead(text) {
42
- const value = String(text || '').trim();
43
- const match = /\n\s*\n/.exec(value);
44
- if (!match) return value;
45
- return value.slice(0, match.index).trim();
46
- }
47
-
48
- function isInternalTaskNotificationEnvelope(text) {
49
- const value = String(text || '').trim();
50
- if (!value) return false;
51
- if (/^background task\b/i.test(value)) return false;
52
- if (/^<task-notification\b/i.test(value)) return true;
53
- const head = notificationHead(value);
54
- return /^<task-notification\b/i.test(head);
55
- }
56
-
57
41
  export function shouldPersistModelVisibleToolCompletion(text, meta = {}) {
58
42
  const message = String(text || '').trim();
59
43
  if (!message) return false;
60
- if (isInternalTaskNotificationEnvelope(message)) return false;
61
44
 
62
45
  const metaStatus = clean(meta?.status).toLowerCase();
63
46
  if (NON_PERSISTENT_TOOL_STATUSES.has(metaStatus)) return false;
@@ -96,7 +79,6 @@ export function isBracketedShellNotificationEnvelope(text) {
96
79
  export function isInternalRuntimeNotificationText(text) {
97
80
  const value = String(text ?? '').trim();
98
81
  if (!value) return false;
99
- if (isInternalTaskNotificationEnvelope(value)) return true;
100
82
  if (isBracketedShellNotificationEnvelope(value)) return true;
101
83
  if (/^background task\b/i.test(value)
102
84
  && /^task_id:\s*\S+/mi.test(value)
@@ -132,7 +114,7 @@ export function toolCompletionInstruction({ surface = 'tool', id, status, detail
132
114
  ? 'agent task'
133
115
  : `${surface} execution`;
134
116
  const statusText = status ? ` (${status}${detail ? `, ${detail}` : ''})` : '';
135
- return `The async ${label} ${id || ''} has finished${statusText} - review this result in your next step. Final result follows; do not recheck.`;
117
+ return `Async ${label} ${id || ''}${statusText} finished.`;
136
118
  }
137
119
 
138
120
  function toolCompletionMeta({
@@ -156,8 +138,6 @@ function toolCompletionMeta({
156
138
  };
157
139
  }
158
140
 
159
- const MODEL_VISIBLE_COMPLETION_INSTRUCTION_RE = /\b(async (?:agent task|shell task|\w+ execution)|Async \S+)/i;
160
- const MODEL_VISIBLE_COMPLETION_REVIEW_RE = /has finished\b[\s\S]*review this result in your next step/i;
161
141
  const MODEL_VISIBLE_COMPLETION_ASYNC_HEADER_RE = /^Async .+ finished\./i;
162
142
 
163
143
  export function isModelVisibleToolCompletionWrapper(text) {
@@ -167,9 +147,7 @@ export function isModelVisibleToolCompletionWrapper(text) {
167
147
  if (!resultSplit) return false;
168
148
  const preamble = value.slice(0, resultSplit.index).trim();
169
149
  if (!preamble) return false;
170
- const instructionLike = MODEL_VISIBLE_COMPLETION_INSTRUCTION_RE.test(preamble)
171
- || MODEL_VISIBLE_COMPLETION_REVIEW_RE.test(preamble)
172
- || MODEL_VISIBLE_COMPLETION_ASYNC_HEADER_RE.test(preamble);
150
+ const instructionLike = MODEL_VISIBLE_COMPLETION_ASYNC_HEADER_RE.test(preamble);
173
151
  if (!instructionLike) return false;
174
152
  const quotedSection = value.slice(resultSplit.index + resultSplit[0].length);
175
153
  const quotedLines = quotedSection.split(/\r?\n/).filter((line) => line.length > 0);
@@ -193,9 +171,7 @@ export function isLikelyToolCompletionWrapper(text) {
193
171
  if (!resultSplit) return false;
194
172
  const preamble = value.slice(0, resultSplit.index).trim();
195
173
  if (!preamble) return false;
196
- const instructionLike = MODEL_VISIBLE_COMPLETION_INSTRUCTION_RE.test(preamble)
197
- || MODEL_VISIBLE_COMPLETION_REVIEW_RE.test(preamble)
198
- || MODEL_VISIBLE_COMPLETION_ASYNC_HEADER_RE.test(preamble);
174
+ const instructionLike = MODEL_VISIBLE_COMPLETION_ASYNC_HEADER_RE.test(preamble);
199
175
  if (!instructionLike) return false;
200
176
  const quotedSection = value.slice(resultSplit.index + resultSplit[0].length);
201
177
  const quotedLines = quotedSection.split(/\r?\n/).filter((line) => line.length > 0);
@@ -205,11 +181,10 @@ export function isLikelyToolCompletionWrapper(text) {
205
181
  }
206
182
 
207
183
  const INTERNAL_TRANSCRIPT_CONTEXT_RE =
208
- /^<(?:system-reminder|task-notification|skill|memory-context|mcp-instructions|available-deferred-tools|event)\b/i;
184
+ /^<(?:system-reminder|skill|memory-context|mcp-instructions|available-deferred-tools|event)\b/i;
209
185
  const INTERNAL_TRANSCRIPT_SYNTHETIC_RE =
210
186
  /^(?:\[mixdog-runtime\]|A previous model worked on this task and produced the compacted handoff summary below\b|Re-attached after compaction\b|Reference files:\s)/i;
211
- const INTERNAL_TRANSCRIPT_ASYNC_HEAD_RE =
212
- /^The async (?:agent task|shell task|\w+ execution) \S+ has (?:finished|completed)\b/i;
187
+ const INTERNAL_TRANSCRIPT_ASYNC_HEAD_RE = /^Async .+ finished\./i;
213
188
  // Persisted USER cancellation control rows ("[Request interrupted by user]"
214
189
  // and its tool-use variant) exist for the next model step, not for humans:
215
190
  // the human already saw the cancel they typed. The live engine path already
@@ -222,6 +197,16 @@ const INTERNAL_TRANSCRIPT_ASYNC_HEAD_RE =
222
197
  // (scripts/turn-checkpoint-crash-test.mjs).
223
198
  const INTERNAL_TRANSCRIPT_INTERRUPT_RE =
224
199
  /^\[request interrupted by user(?: for tool use)?\]$/i;
200
+ const TRANSCRIPT_CANCELLED_STATUS_RE =
201
+ /^\[request interrupted(?: by process restart)?\]$/i;
202
+
203
+ // Crash/implicit interruption markers remain in model-visible history for
204
+ // recovery, but every human transcript renders them as a Cancelled status row.
205
+ // Explicit user-cancel markers stay on the hidden-control-row path above
206
+ // because the live surface already emitted its cancellation status.
207
+ export function isTranscriptCancelledStatusText(text) {
208
+ return TRANSCRIPT_CANCELLED_STATUS_RE.test(String(text ?? '').trim());
209
+ }
225
210
 
226
211
  // One display-only policy for every transcript surface. Runtime control rows
227
212
  // may be persisted as ordinary role:user messages so the next model step can
@@ -245,9 +230,7 @@ export function isInternalTranscriptDisplayText(text) {
245
230
  const resultSplit = /\r?\n(?:[ \t]*\r?\n)?Result:[ \t]*\r?\n/i.exec(value);
246
231
  if (!resultSplit) return false;
247
232
  const preamble = value.slice(0, resultSplit.index).trim();
248
- const instructionLike = MODEL_VISIBLE_COMPLETION_INSTRUCTION_RE.test(preamble)
249
- || MODEL_VISIBLE_COMPLETION_REVIEW_RE.test(preamble)
250
- || MODEL_VISIBLE_COMPLETION_ASYNC_HEADER_RE.test(preamble);
233
+ const instructionLike = MODEL_VISIBLE_COMPLETION_ASYNC_HEADER_RE.test(preamble);
251
234
  if (!instructionLike) return false;
252
235
  const normalizedBody = value.slice(resultSplit.index + resultSplit[0].length)
253
236
  .split(/\r?\n/)
@@ -169,7 +169,7 @@ function firstAgentResultLine(text) {
169
169
  const trimmed = line.trim();
170
170
  if (!trimmed) continue;
171
171
  if (/^agent result\b/i.test(trimmed)) continue;
172
- if (/^<\/?(?:final-answer|task-notification|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>$/i.test(trimmed)) continue;
172
+ if (/^<\/?(?:final-answer|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>$/i.test(trimmed)) continue;
173
173
  if (/^(?:agent task|status|type|target|role|agent|preset|model|effort|fast|limits|session|task-id|task_id|notification|queueDepth|worker|worker_stage|last_progress|silent_for|watchdog|queued_followups|diagnostic|started|finished|elapsed|reused):\s*/i.test(trimmed)) continue;
174
174
  if (/^\[[a-z-]+:\s*[^\]]*\]$/i.test(trimmed)) continue;
175
175
  return truncateSingleLine(trimmed, AGENT_SURFACE_BRIEF_MAX);
@@ -18,7 +18,7 @@ const completionMeta = {
18
18
  execution_surface: 'agent',
19
19
  execution_id: 'task-agent-1',
20
20
  status: 'completed',
21
- instruction: 'The async agent task task-agent-1 has finished (completed) - review this result in your next step. Final result follows; do not recheck.',
21
+ instruction: 'Async agent task task-agent-1 (completed) finished.',
22
22
  };
23
23
 
24
24
  test('session-id authority delivers to only the requested Lead among simultaneous listeners', () => {
@@ -306,30 +306,23 @@ export function snapshotProviderRequestTools(options = {}) {
306
306
  || names.size === 0) {
307
307
  return finish();
308
308
  }
309
- const discovered = new Set(parseToolSelection(session?.deferredDiscoveredTools));
310
- for (const message of Array.isArray(messages) ? messages : []) {
311
- const native = message?.nativeToolSearch;
312
- const source = clean(native?.provider).toLowerCase();
313
- if (source && source !== normalizedProvider
314
- && !(ANTHROPIC_NATIVE_PROVIDERS.has(source)
315
- && ANTHROPIC_NATIVE_PROVIDERS.has(normalizedProvider))) continue;
316
- for (const name of parseToolSelection(native?.toolReferences)) discovered.add(name);
317
- }
318
- if (discovered.size === 0) return finish();
319
-
320
- // Catalog arrays have no separate key map, so `name` is their explicit
321
- // selection key contract: capture it once, skip undiscovered/duplicate
322
- // entries without touching schemas, and seed selected normalization with the
323
- // captured value to avoid a second getter evaluation.
309
+ // Anthropic tools precede every cache_control breakpoint. Adding a schema
310
+ // after tool_search therefore invalidates the whole provider cache prefix.
311
+ // Send the complete defer_loading catalog from the first request instead;
312
+ // tool_search controls callability, not provider-visible schema membership.
324
313
  const seenCatalogRefs = new WeakSet();
325
- for (const tool of Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : []) {
314
+ const catalog = [
315
+ ...(Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : []),
316
+ ...(Array.isArray(session?.deferredLateToolCatalog) ? session.deferredLateToolCatalog : []),
317
+ ];
318
+ for (const tool of catalog) {
326
319
  if (tool && typeof tool === 'object') {
327
320
  if (activeCandidateRefs.has(tool) || seenCatalogRefs.has(tool)) continue;
328
321
  seenCatalogRefs.add(tool);
329
322
  }
330
323
  const capturedName = tool?.name;
331
324
  const selectionName = typeof capturedName === 'string' ? clean(capturedName) : '';
332
- if (!selectionName || !discovered.has(selectionName) || names.has(selectionName)) continue;
325
+ if (!selectionName || names.has(selectionName)) continue;
333
326
  appendSnapshot(tool, capturedName, true);
334
327
  }
335
328
  return finish();
@@ -0,0 +1,37 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { snapshotProviderRequestTools } from './provider-request-snapshot.mjs';
4
+
5
+ test('Anthropic deferred tool surface is complete and byte-stable from the first send', () => {
6
+ const session = {
7
+ provider: 'anthropic-oauth',
8
+ deferredNativeTools: true,
9
+ deferredToolCatalog: [
10
+ { name: 'shell', inputSchema: { type: 'object', properties: {} } },
11
+ { name: 'recall', inputSchema: { type: 'object', properties: {} } },
12
+ ],
13
+ };
14
+ const tools = [{ name: 'load_tool', inputSchema: { type: 'object', properties: {} } }];
15
+ const first = snapshotProviderRequestTools({
16
+ provider: session.provider,
17
+ tools,
18
+ messages: [],
19
+ session,
20
+ });
21
+ const later = snapshotProviderRequestTools({
22
+ provider: session.provider,
23
+ tools,
24
+ messages: [{
25
+ role: 'tool',
26
+ nativeToolSearch: {
27
+ provider: 'anthropic-oauth',
28
+ toolReferences: ['shell'],
29
+ },
30
+ }],
31
+ session,
32
+ });
33
+ assert.deepEqual(JSON.parse(JSON.stringify(later)), JSON.parse(JSON.stringify(first)));
34
+ assert.deepEqual(first.map((tool) => tool.name), ['load_tool', 'shell', 'recall']);
35
+ assert.equal(first[1].deferLoading, true);
36
+ assert.equal(first[2].deferLoading, true);
37
+ });
@@ -519,7 +519,6 @@ export function parseHandlerOutput(run, eventName) {
519
519
  updatedToolName: null,
520
520
  updatedToolOutput: null,
521
521
  additionalContext: null,
522
- systemMessage: null,
523
522
  suppressOutput: false,
524
523
  continueFlag: undefined,
525
524
  askReason: null,
@@ -552,7 +551,6 @@ export function parseHandlerOutput(run, eventName) {
552
551
  out.reason = limitText(json.reason || out.reason || `blocked by ${eventName} hook`);
553
552
  }
554
553
  if (json.suppressOutput) out.suppressOutput = true;
555
- if (typeof json.systemMessage === 'string') out.systemMessage = limitText(json.systemMessage);
556
554
  if (typeof json.additionalContext === 'string') out.additionalContext = limitText(json.additionalContext);
557
555
 
558
556
  const hso = json.hookSpecificOutput;
@@ -271,7 +271,6 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
271
271
  updatedToolName: null,
272
272
  updatedToolOutput: null,
273
273
  additionalContext: [],
274
- systemMessage: null,
275
274
  ask: false,
276
275
  askReason: null,
277
276
  handlersRun: handlers.length,
@@ -303,7 +302,6 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
303
302
  }
304
303
  const parsed = parseHandlerOutput(run, eventName);
305
304
  if (parsed.additionalContext) agg.additionalContext.push(parsed.additionalContext);
306
- if (parsed.systemMessage && !agg.systemMessage) agg.systemMessage = parsed.systemMessage;
307
305
  if (parsed.updatedInput && !agg.updatedInput) agg.updatedInput = parsed.updatedInput;
308
306
  if (parsed.updatedToolName && !agg.updatedToolName) agg.updatedToolName = parsed.updatedToolName;
309
307
  if (parsed.updatedToolOutput != null && agg.updatedToolOutput == null) agg.updatedToolOutput = parsed.updatedToolOutput;
@@ -509,7 +507,6 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
509
507
  blocked: agg.blocked || undefined,
510
508
  reason: agg.reason || undefined,
511
509
  additionalContext: agg.additionalContext.length ? agg.additionalContext : undefined,
512
- systemMessage: agg.systemMessage || undefined,
513
510
  updatedInput: agg.updatedInput || undefined,
514
511
  updatedToolName: agg.updatedToolName || undefined,
515
512
  handlersRun: agg.handlersRun || undefined,
@@ -3505,7 +3505,7 @@ function firstAgentResultLine(text) {
3505
3505
  const trimmed = line.trim();
3506
3506
  if (!trimmed) continue;
3507
3507
  if (/^agent result\b/i.test(trimmed)) continue;
3508
- if (/^<\/?(?:final-answer|task-notification|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>$/i.test(trimmed)) continue;
3508
+ if (/^<\/?(?:final-answer|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>$/i.test(trimmed)) continue;
3509
3509
  if (/^(?:agent task|status|type|target|role|agent|preset|model|effort|fast|limits|session|task-id|task_id|notification|queueDepth|worker|worker_stage|last_progress|silent_for|watchdog|queued_followups|diagnostic|started|finished|elapsed|reused):\s*/i.test(trimmed)) continue;
3510
3510
  if (/^\[[a-z-]+:\s*[^\]]*\]$/i.test(trimmed)) continue;
3511
3511
  return truncateSingleLine(trimmed, AGENT_SURFACE_BRIEF_MAX);
@@ -11641,7 +11641,7 @@ function joinActionAgent(action, agent) {
11641
11641
  function agentResponseTitle(args, count = 1) {
11642
11642
  const total = Math.max(1, Number(count) || 1);
11643
11643
  if (total > 1) return `Responses ${total} agents`;
11644
- const name = titleizeAgentName(args?.agent || args?.subagent_type || args?.name || "");
11644
+ const name = titleizeAgentName(args?.agent || args?.subagent_type || args?.name || "") || "Agent";
11645
11645
  return withModelAndTag(joinActionAgent("Response", name), args);
11646
11646
  }
11647
11647
  function agentActionTitle(args) {
@@ -11684,7 +11684,7 @@ function hasAgentResponseResult(value) {
11684
11684
  }
11685
11685
  if (/^agent result\b/i.test(trimmed)) continue;
11686
11686
  if (/^(?:undefined|null)$/i.test(trimmed)) continue;
11687
- if (/^<\/?(?:final-answer|task-notification|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>$/i.test(trimmed)) continue;
11687
+ if (/^<\/?(?:final-answer|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>$/i.test(trimmed)) continue;
11688
11688
  if (!sawBlank && /^(?:agent task|background task|agent message queued\b|agent close:|task_id|surface|operation|label|status|type|target|agent|preset|model|effort|fast|limits|started|finished|error|notification|queueDepth):?\s*/i.test(trimmed)) continue;
11689
11689
  if (!sawBlank && /^(?:agents|tasks):\s*/i.test(trimmed)) continue;
11690
11690
  if (/^\(no agents or tasks\)$/i.test(trimmed)) continue;
@@ -20,7 +20,7 @@ function stripSyntheticAgentTags(text) {
20
20
  if (taskResult) return taskResult;
21
21
  return value
22
22
  .replace(/^agent result[^\n]*(?:\n|$)/i, '')
23
- .replace(/<\/?(?:final-answer|task-notification|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>/gi, '')
23
+ .replace(/<\/?(?:final-answer|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>/gi, '')
24
24
  .trim();
25
25
  }
26
26
 
@@ -153,8 +153,7 @@ export function parseModelVisibleCompletionWrapper(text) {
153
153
  const split = /\n\nResult:\n/.exec(value);
154
154
  if (!split) return null;
155
155
  const preamble = value.slice(0, split.index).trim();
156
- if (!/^The async \S+ task \S+ has finished\b[\s\S]*review this result in your next step\.?(?: Final result follows; do not recheck\.)?$/i.test(preamble)
157
- && !/^Async \S+.* finished\.$/i.test(preamble)) {
156
+ if (!/^Async \S+.* finished\.$/i.test(preamble)) {
158
157
  return null;
159
158
  }
160
159
  const quotedLines = value.slice(split.index + split[0].length).split(/\r?\n/);
@@ -243,37 +242,43 @@ export function parseSyntheticAgentMessage(text) {
243
242
  isError: /^(failed|error|timeout|cancelled|canceled|killed|denied)$/i.test(label),
244
243
  };
245
244
  }
246
- if (/<task-notification\b/i.test(value)) {
247
- const status = textBetweenTag(value, 'status') || 'completed';
248
- const summary = textBetweenTag(value, 'summary') || `Agent ${status}`;
249
- const taskId = textBetweenTag(value, 'task-id');
250
- const result = stripSyntheticAgentTags(value);
251
- return {
252
- name: 'agent',
253
- label: status,
254
- taskId,
255
- summary,
256
- result: result || summary,
257
- };
258
- }
259
245
  return null;
260
246
  }
261
247
 
262
248
  export function buildExecutionResponseToolItem(text, {
263
249
  id,
264
250
  responseKey = '',
251
+ executionSurface = '',
252
+ executionStatus = '',
265
253
  now = Date.now(),
266
254
  } = {}) {
267
- const synthetic = parseSyntheticAgentMessage(text);
255
+ const surface = String(executionSurface || '').trim().toLowerCase();
256
+ const status = String(executionStatus || '').trim().toLowerCase();
257
+ const explicitName = /^(agent|shell|search)$/.test(surface) ? surface : '';
258
+ const parsed = parseSyntheticAgentMessage(text);
259
+ const synthetic = parsed || (explicitName ? {
260
+ name: explicitName,
261
+ label: status || 'completed',
262
+ args: {
263
+ type: 'result',
264
+ status: status || undefined,
265
+ task_id: responseKey || undefined,
266
+ surface: explicitName,
267
+ },
268
+ result: String(text ?? '').trim(),
269
+ } : null);
268
270
  if (!synthetic) return null;
269
271
  const label = synthetic.label || 'notification';
270
- const isAgent = synthetic.name === 'agent';
272
+ const name = explicitName || synthetic.name || 'task';
273
+ const isAgent = name === 'agent';
271
274
  const args = {
272
275
  ...(synthetic.args && typeof synthetic.args === 'object' ? synthetic.args : {
273
276
  type: label,
274
277
  task_id: synthetic.taskId || undefined,
275
278
  description: synthetic.summary || 'execution notification',
276
279
  }),
280
+ ...(surface ? { surface } : {}),
281
+ ...(status ? { status } : {}),
277
282
  ...(isAgent ? { type: 'result' } : {}),
278
283
  };
279
284
  const rawResult = synthetic.rawResult ?? text;
@@ -282,7 +287,7 @@ export function buildExecutionResponseToolItem(text, {
282
287
  return {
283
288
  kind: 'tool',
284
289
  id,
285
- name: synthetic.name || 'task',
290
+ name,
286
291
  args,
287
292
  result: synthetic.result,
288
293
  rawResult,
@@ -326,11 +326,12 @@ export function createAgentJobFeed({
326
326
  if (firstDelivery && !successfulPreview && !bodyAlreadyDisplayed) {
327
327
  if (cardKey) rememberDisplayedExecutionNotificationKey(cardKey, terminal, executionId);
328
328
  if (executionId) rememberDisplayedExecutionResponseState(executionId, hasBody ? 'body' : 'preview', terminal);
329
- // Execution completions are inbound agent responses. The session runtime keeps
330
- // their aggregation tail-safe (only an immediately-adjacent inbound
331
- // response of the same preview/body phase can merge); the fallback
332
- // preserves the standalone path for minimal/test harnesses.
333
- (pushAsyncAgentResponse || pushUserOrSyntheticItem)(delivery.displayText, nextId(), 'injected', { responseKey: executionId });
329
+ // Preserve the canonical execution surface through card construction;
330
+ // text parsing remains a fallback for restored/legacy envelopes.
331
+ (pushAsyncAgentResponse || pushUserOrSyntheticItem)(delivery.displayText, nextId(), 'injected', {
332
+ responseKey: executionId,
333
+ ...(delivery.executionMeta || {}),
334
+ });
334
335
  }
335
336
  refreshAgentStatus(parsed);
336
337
  const resumeBody = String(delivery.modelContent || '').trim();
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
9
9
  import { restoreTranscriptItems } from './session-api-ext.mjs';
10
10
 
11
11
  const wrapper = (taskId, body) => [
12
- `The async shell task ${taskId} has finished (completed, exit 0) - review this result in your next step. Final result follows; do not recheck.`,
12
+ `Async shell task ${taskId} (completed, exit 0) finished.`,
13
13
  '',
14
14
  'Result:',
15
15
  ...body.split('\n').map((line) => `> ${line}`),
@@ -45,7 +45,7 @@ test('completion wrapper user rows restore as tool cards, not dropped rows', ()
45
45
  assert.equal(card.isError, false);
46
46
  assert.match(String(card.result || ''), /8\/8 Mean: 1\.000/);
47
47
  // The raw wrapper must never surface as a plain user bubble.
48
- assert.ok(!items.some((it) => it?.kind === 'user' && /The async shell task/.test(it.text || '')));
48
+ assert.ok(!items.some((it) => it?.kind === 'user' && /Async shell task/.test(it.text || '')));
49
49
  });
50
50
 
51
51
  test('non-wrapper internal rows stay suppressed on restore', () => {
@@ -97,5 +97,9 @@ export function resolveTuiRuntimeNotificationDelivery(event, text) {
97
97
  action: 'execution-ui',
98
98
  displayText: trimmed,
99
99
  modelContent,
100
+ executionMeta: {
101
+ executionSurface: String(meta.execution_surface || '').trim(),
102
+ executionStatus: String(meta.status || parsed?.status || '').trim(),
103
+ },
100
104
  };
101
105
  }
@@ -10,7 +10,10 @@ import { getVoiceStatus, toggleVoice } from '../lib/voice-setup.mjs';
10
10
  import { createSessionOAuthFlowRegistry } from './oauth-flows.mjs';
11
11
  import { aggregateToolCategoryEntries, aggregateDoneCategories, classifyToolCategory, formatAggregateDetail, summarizeToolResult, toolLoadingTargets } from '../../runtime/shared/tool-surface.mjs';
12
12
  import { aggregateBucketForCategory, aggregateRawResult, failureDetailText, toolCallOutcome } from './tool-result-status.mjs';
13
- import { isInternalTranscriptDisplayText } from '../../runtime/shared/tool-execution-contract.mjs';
13
+ import {
14
+ isInternalTranscriptDisplayText,
15
+ isTranscriptCancelledStatusText,
16
+ } from '../../runtime/shared/tool-execution-contract.mjs';
14
17
  import { toolResultTerminalStatus } from '../../runtime/shared/tool-status.mjs';
15
18
 
16
19
  export function restoredTranscriptMetadata(message) {
@@ -253,7 +256,7 @@ function restoredUserTranscriptItems(message, nextId) {
253
256
  const text = (typeof message?.content === 'string'
254
257
  ? message.content
255
258
  : toolResultText(message?.content)).trim();
256
- // Persisted async-completion wrappers ("The async shell task ... Result:
259
+ // Persisted async-completion wrappers ("Async shell task ... finished. Result:
257
260
  // > ...") are the only durable record of a background completion — the live
258
261
  // Response card is an event-time push that does not survive a transcript
259
262
  // rebuild. Restore them as tool cards instead of dropping them with the
@@ -288,10 +291,7 @@ function restoredUserTranscriptItems(message, nextId) {
288
291
  // Crash-recovery control row: keep the persisted marker for the next model
289
292
  // step, but render it like a live cancel tail (◈ Cancelled) instead of a
290
293
  // raw user bubble with bracketed internals.
291
- if (/^\[request interrupted by process restart\]$/i.test(text)) {
292
- return [{ kind: 'turndone', id: nextId(), status: 'cancelled', elapsedMs: 0 }];
293
- }
294
- if (/^\[request interrupted\]$/i.test(text)) {
294
+ if (isTranscriptCancelledStatusText(text)) {
295
295
  return [{ kind: 'turndone', id: nextId(), status: 'cancelled', elapsedMs: 0 }];
296
296
  }
297
297
  const synthetic = parseSyntheticAgentMessage(text);
@@ -741,6 +741,8 @@ export async function createLocalSessionRuntime({
741
741
  const responseItem = buildExecutionResponseToolItem(text, {
742
742
  id,
743
743
  responseKey: metadata.responseKey || metadata.executionId,
744
+ executionSurface: metadata.executionSurface,
745
+ executionStatus: metadata.executionStatus,
744
746
  });
745
747
  if (!responseItem) return pushUserOrSyntheticItem(text, id, origin);
746
748
  if (responseItem.name !== 'agent') {