@north-light/crouter 0.3.206 → 0.3.208

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 (46) hide show
  1. package/dist/api/dto/messages.d.ts +4 -3
  2. package/dist/clients/attach/__tests__/group-activity.test.js +8 -1
  3. package/dist/clients/attach/render/card-presentation.d.ts +31 -0
  4. package/dist/clients/attach/render/card-presentation.js +163 -0
  5. package/dist/clients/attach/render/chat-view.d.ts +0 -2
  6. package/dist/clients/attach/render/chat-view.js +12 -23
  7. package/dist/clients/attach/render/context-message.d.ts +5 -9
  8. package/dist/clients/attach/render/context-message.js +20 -43
  9. package/dist/clients/attach/render/group-activity.d.ts +28 -4
  10. package/dist/clients/attach/render/group-activity.js +65 -11
  11. package/dist/clients/attach/render/group-recap.d.ts +3 -0
  12. package/dist/clients/attach/render/group-recap.js +17 -0
  13. package/dist/clients/attach/viewer.js +516 -516
  14. package/dist/core/__tests__/canvas-inbox-watcher.test.js +1 -1
  15. package/dist/core/__tests__/helpers/broker-clients.d.ts +3 -1
  16. package/dist/core/__tests__/helpers/broker-clients.js +7 -2
  17. package/dist/core/__tests__/seam/broker-attach-multiclient.test.js +9 -0
  18. package/dist/core/__tests__/serial/broker-snapshot-history.test.js +52 -1
  19. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +1 -1
  20. package/dist/core/canvas/paths.d.ts +2 -2
  21. package/dist/core/canvas/paths.js +2 -2
  22. package/dist/core/runtime/broker/fault-retry.js +2 -7
  23. package/dist/core/runtime/broker/frame-dispatch.js +1 -1
  24. package/dist/core/runtime/broker/tool-groups.js +7 -6
  25. package/dist/core/runtime/broker.js +4 -4
  26. package/dist/core/runtime/kickoff.js +4 -10
  27. package/dist/core/runtime/node-read.js +7 -1
  28. package/dist/core/runtime/situational-context.d.ts +0 -2
  29. package/dist/core/runtime/situational-context.js +3 -2
  30. package/dist/core/runtime/situational-live.js +1 -1
  31. package/dist/core/runtime/spawn.d.ts +4 -4
  32. package/dist/core/runtime/stop-guard.js +3 -3
  33. package/dist/daemon/messaging/node-message.d.ts +4 -1
  34. package/dist/daemon/review/comment-notify.js +1 -8
  35. package/dist/daemon/review/deliver.js +1 -1
  36. package/dist/daemon/review/finish.js +1 -1
  37. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +1 -1
  38. package/dist/pi-extensions/canvas-inbox-watcher.js +4 -2
  39. package/dist/pi-extensions/canvas-review-boundary.js +1 -1
  40. package/dist/shared/__tests__/generated-context-grammar.test.js +19 -22
  41. package/dist/shared/generated-context.d.ts +0 -18
  42. package/dist/shared/generated-context.js +16 -120
  43. package/dist/shared/tool-groups.d.ts +1 -0
  44. package/dist/shared/tool-groups.js +7 -0
  45. package/package.json +1 -1
  46. package/runtime.lock.json +2 -2
@@ -106,7 +106,7 @@ test('coalesce renders one inbox card whose entries inline short report bodies a
106
106
  const card = parseCard({ role: 'user', content: digest });
107
107
  assert.notEqual(card, null);
108
108
  assert.equal(card.kind, 'inbox');
109
- assert.deepEqual({ ...card.facts }, { senders: '1', updates: '1' });
109
+ assert.deepEqual({ ...card.facts }, {});
110
110
  assert.equal(card.senders.length, 1);
111
111
  assert.equal(card.senders[0].id, 'child');
112
112
  // The label is NOT reprinted above the body it duplicates.
@@ -25,7 +25,9 @@ export interface RawClient {
25
25
  }
26
26
  export interface AttachKit {
27
27
  /** Connect a BrokerClient to a node's running broker, hello, await welcome. */
28
- attach(id: string, role: ClientRole, clientId: string): Promise<Attached>;
28
+ attach(id: string, role: ClientRole, clientId: string, opts?: {
29
+ snapshotTail?: number;
30
+ }): Promise<Attached>;
29
31
  /** Attach (as `role`) and retry until the welcome satisfies `pred`. */
30
32
  attachUntil(id: string, role: ClientRole, clientId: string, pred: (a: Attached) => boolean, label: string): Promise<Attached>;
31
33
  /** A raw node:net peer. read:false leaves the socket PAUSED (G8 stalled viewer). */
@@ -37,7 +37,7 @@ export function createAttachKit(getH) {
37
37
  // awaitBoot returns once the boot proof is written — which is BEFORE the broker's
38
38
  // server.listen() binds view.sock — so a fresh attach can momentarily race the
39
39
  // bind; retry the connect on BrokerUnavailable until it is listening.
40
- async function attach(id, role, clientId) {
40
+ async function attach(id, role, clientId, opts = {}) {
41
41
  const h = getH();
42
42
  await h.waitFor(() => existsSync(h.brokerSock(id)), { label: `view.sock for ${id}`, timeoutMs: 20_000 });
43
43
  const frames = [];
@@ -65,7 +65,12 @@ export function createAttachKit(getH) {
65
65
  // round-trip — so a welcome timeout cannot leak a connected socket past the test.
66
66
  liveClients.push({ close: () => client.close() });
67
67
  const waitFrame = (pred, label, timeoutMs = 15_000) => h.waitFor(() => frames.find(pred) ?? null, { label, timeoutMs });
68
- client.send({ type: 'hello', role, client_id: clientId });
68
+ client.send({
69
+ type: 'hello',
70
+ role,
71
+ client_id: clientId,
72
+ ...(opts.snapshotTail === undefined ? {} : { snapshot_tail: opts.snapshotTail }),
73
+ });
69
74
  let welcome;
70
75
  try {
71
76
  welcome = (await waitFrame((f) => f.type === 'welcome', `welcome for ${clientId}`));
@@ -253,3 +253,12 @@ test('G10 — retained extension display state rides IN welcome.snapshot.display
253
253
  assert.equal(live.method, 'setStatus');
254
254
  assert.equal(live.statusText, 'painted while attached');
255
255
  });
256
+ test('snapshot_tail is ignored when the session has no stable message ids', async () => {
257
+ const token = tok('snapshot-tail-no-ids');
258
+ h.fakeCmd(id, { cmd: 'stream', text: token });
259
+ const full = await attachUntil(id, 'observer', 'snapshot-tail-full', (a) => JSON.stringify(a.welcome.snapshot.messages).includes(token), 'full snapshot after a tail-less attach');
260
+ assert.equal(full.welcome.snapshot.messageIds, undefined, 'the fake session has no stable ids');
261
+ const tailed = await attach(id, 'observer', 'snapshot-tail-request', { snapshotTail: 1 });
262
+ assert.equal(tailed.welcome.snapshot.messageIds, undefined, 'the tail request cannot invent stable ids');
263
+ assert.deepEqual(tailed.welcome.snapshot.messages, full.welcome.snapshot.messages, 'a welcome without stable ids keeps the full history instead of stranding an unpageable tail');
264
+ });
@@ -34,7 +34,7 @@ import { CANVAS_EXTENSIONS } from '../../runtime/launch.js';
34
34
  import { buildBrokerSession, snapshotMessages } from '../../runtime/broker.js';
35
35
  import { createNode } from '../../canvas/canvas.js';
36
36
  import { closeDb } from '../../canvas/db.js';
37
- import { transcriptMarkdown } from '../../runtime/node-read.js';
37
+ import { readNodeMessagesPage, transcriptMarkdown } from '../../runtime/node-read.js';
38
38
  // M-9 (broker.ts fault-and-die model gate): a boot with no cfg.model now calls
39
39
  // the REAL SDK's modelRegistry.getAvailable() and faults if it's empty, which it
40
40
  // always is on a host/CI runner with no authenticated provider. This test only
@@ -128,6 +128,57 @@ test('broker welcome snapshot serves the persisted history, not pi\u2019s sliced
128
128
  rmSync(cwd, { recursive: true, force: true });
129
129
  }
130
130
  });
131
+ test('readNodeMessagesPage keeps a tool call with all following tool results across cursor pages', async () => {
132
+ const home = mkdtempSync(join(tmpdir(), 'crtr-page-tools-home-'));
133
+ const sessionCwd = mkdtempSync(join(tmpdir(), 'crtr-page-tools-session-'));
134
+ const prevHome = process.env['CRTR_HOME'];
135
+ process.env['CRTR_HOME'] = home;
136
+ closeDb();
137
+ try {
138
+ const seed = SessionManager.create(sessionCwd);
139
+ seed.appendMessage(userMsg('before the tool run'));
140
+ seed.appendMessage({
141
+ ...assistantMsg('calling tools'),
142
+ content: [
143
+ { type: 'toolCall', id: 'tool-1', name: 'read', arguments: { path: 'one.txt' } },
144
+ { type: 'toolCall', id: 'tool-2', name: 'read', arguments: { path: 'two.txt' } },
145
+ ],
146
+ });
147
+ seed.appendMessage({ role: 'toolResult', toolCallId: 'tool-1', toolName: 'read', content: [{ type: 'text', text: 'one' }], isError: false, timestamp: Date.now() });
148
+ seed.appendMessage({ role: 'toolResult', toolCallId: 'tool-2', toolName: 'read', content: [{ type: 'text', text: 'two' }], isError: false, timestamp: Date.now() });
149
+ seed.appendMessage(userMsg('after the tool run'));
150
+ seed.appendMessage(assistantMsg('finished'));
151
+ const sessionFile = seed.getSessionFile();
152
+ const nodeId = 'tool-page-boundary';
153
+ createNode({
154
+ node_id: nodeId,
155
+ name: nodeId,
156
+ created: new Date().toISOString(),
157
+ cwd: sessionCwd,
158
+ kind: 'general',
159
+ mode: 'base',
160
+ lifecycle: 'terminal',
161
+ status: 'active',
162
+ pi_session_file: sessionFile,
163
+ });
164
+ const newest = await readNodeMessagesPage(nodeId, { limit: 3 });
165
+ assert.equal(newest.messages[0].role, 'assistant', 'the page walks back from the tool-result cut to its call');
166
+ assert.deepEqual(newest.messages.slice(1, 3).map((message) => message.role), ['toolResult', 'toolResult'], 'all tool results share the page with their call');
167
+ assert.ok(newest.nextCursor, 'the widened page still has an older cursor');
168
+ const older = await readNodeMessagesPage(nodeId, { cursor: newest.nextCursor, limit: 3 });
169
+ const persisted = SessionManager.open(sessionFile).buildSessionContext().messages;
170
+ assert.deepEqual([...older.messages, ...newest.messages], persisted, 'cursor pages remain gap-free and non-overlapping after widening');
171
+ assert.deepEqual([...(older.messageIds ?? []), ...(newest.messageIds ?? [])], SessionManager.open(sessionFile).getEntries().map(({ id }) => id), 'stable ids remain aligned across the widened page boundary');
172
+ }
173
+ finally {
174
+ closeDb();
175
+ process.env['CRTR_HOME'] = prevHome;
176
+ if (prevHome === undefined)
177
+ delete process.env['CRTR_HOME'];
178
+ rmSync(home, { recursive: true, force: true });
179
+ rmSync(sessionCwd, { recursive: true, force: true });
180
+ }
181
+ });
131
182
  // C3 (inline memory references, Wave 1A): transcriptMarkdown is the human-
132
183
  // facing render of a dormant node's persisted session. It must generically
133
184
  // skip any custom message whose `display === false` (the same hidden-guidance
@@ -229,7 +229,7 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
229
229
  const stop = evaluateStop(B, { pushedFinal: false, requestedHumanReply: false });
230
230
  assert.equal(stop.action, 'reprompt', 'orchestrator B with no live sub, no final → reprompt');
231
231
  assert.equal(stop.reason, 'stalled', 'classified stalled');
232
- assert.equal(stop.action === 'reprompt' ? stop.message : '', formatCard('stop-guard', { reason: 'stalled' }, STALL_REPROMPT), 'the stall reprompt nudge, delivered as a whole-message stop-guard envelope');
232
+ assert.equal(stop.action === 'reprompt' ? stop.message : '', formatCard('stop-guard', {}, STALL_REPROMPT), 'the stall reprompt nudge, delivered as a whole-message stop-guard envelope');
233
233
  }
234
234
  // ===================================================================
235
235
  // S10 — B YIELDS. The real plain `node yield` verb sets intent=refresh
@@ -59,8 +59,8 @@ export declare function reportsDir(nodeId: string): string;
59
59
  export declare function messagesDir(nodeId: string): string;
60
60
  export declare function nodeMetaPath(nodeId: string): string;
61
61
  export declare function inboxPath(nodeId: string): string;
62
- /** The node-owned ambient "current situation" sidecar — the latest hidden
63
- * `<situational-context>` text injected into bearings/kickoff (see
62
+ /** The node-owned ambient "current situation" sidecar — ONE rendered runtime
63
+ * card, restated by the session-start bearings (see
64
64
  * `runtime/situational-context.ts`), upserted in place rather than appended so
65
65
  * only the newest applet/situation state is ever carried. Lives beside
66
66
  * meta.json/inbox.jsonl (node-runtime state) rather than under context/ (the
@@ -137,8 +137,8 @@ export function nodeMetaPath(nodeId) {
137
137
  export function inboxPath(nodeId) {
138
138
  return join(nodeDir(nodeId), 'inbox.jsonl');
139
139
  }
140
- /** The node-owned ambient "current situation" sidecar — the latest hidden
141
- * `<situational-context>` text injected into bearings/kickoff (see
140
+ /** The node-owned ambient "current situation" sidecar — ONE rendered runtime
141
+ * card, restated by the session-start bearings (see
142
142
  * `runtime/situational-context.ts`), upserted in place rather than appended so
143
143
  * only the newest applet/situation state is ever carried. Lives beside
144
144
  * meta.json/inbox.jsonl (node-runtime state) rather than under context/ (the
@@ -208,12 +208,7 @@ export class FaultRetry {
208
208
  return;
209
209
  if (unconfigured)
210
210
  clearFault(this.deps.nodeId, { link: 'pi→provider' });
211
- await operationIdContext.fresh(() => candidateSession.prompt(formatCard('recovery', {
212
- reason: 'model-fallback',
213
- from: currentSpec,
214
- to: resolved,
215
- cause: unconfigured ? 'credential' : 'not-found',
216
- }, formatModelFallbackRecovery(currentSpec, resolved, unconfigured ? 'credential' : 'not-found'))));
211
+ await operationIdContext.fresh(() => candidateSession.prompt(formatCard('recovery', {}, formatModelFallbackRecovery(currentSpec, resolved, unconfigured ? 'credential' : 'not-found'))));
217
212
  })().catch((err) => {
218
213
  emitEvent({ level: 'debug', event: 'broker.runtime.diagnostic', fields: { message: `${unconfigured ? 'unconfigured-provider' : 'not_found'} fallback failed: ${err instanceof Error ? err.message : String(err)}` } });
219
214
  }).finally(() => {
@@ -300,6 +295,6 @@ export class FaultRetry {
300
295
  if (this.deps.installedGeneration() !== generation || this.deps.currentSession() !== candidateSession)
301
296
  return;
302
297
  recordFault(this.deps.nodeId, plan.fault);
303
- await operationIdContext.fresh(() => candidateSession.prompt(formatCard('recovery', { reason: fault.kind === 'connection' ? 'connection' : 'provider' }, fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY)));
298
+ await operationIdContext.fresh(() => candidateSession.prompt(formatCard('recovery', {}, fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY)));
304
299
  }
305
300
  }
@@ -1442,7 +1442,7 @@ export function createFrameDispatchContext(deps) {
1442
1442
  fresh.since !== authFault.since)
1443
1443
  return;
1444
1444
  clearFault(nodeId, { link: 'pi→provider' });
1445
- await authFaultSession.prompt(formatCard('recovery', { reason: 'auth' }, AUTH_FAULT_RECOVERY_BODY));
1445
+ await authFaultSession.prompt(formatCard('recovery', {}, AUTH_FAULT_RECOVERY_BODY));
1446
1446
  }
1447
1447
  catch (err) {
1448
1448
  emitEvent({
@@ -4,7 +4,7 @@ import { jobDir } from '../../canvas/paths.js';
4
4
  import { readConfig } from '../../config.js';
5
5
  import { emitEvent } from '../../events/emit.js';
6
6
  import { atomicWriteJson, readJsonIfExists } from '../../fs-utils.js';
7
- import { assistantVisibleText, isTrueUserMessage, normalizeToolGroupSummaryRecord, } from '../../../shared/tool-groups.js';
7
+ import { assistantVisibleText, isGroupBoundaryKind, isTrueUserMessage, normalizeToolGroupSummaryRecord, } from '../../../shared/tool-groups.js';
8
8
  import { generatedContextText, parseCard } from '../../../shared/generated-context.js';
9
9
  import { headlessToolGroupSummary, serializeToolGroup } from '../tool-group-summary.js';
10
10
  const PENDING_TOOL_SUMMARY_KEY_MAX_CHARS = 1_000;
@@ -140,11 +140,12 @@ export class ToolGroupTracker {
140
140
  if (item.display === false)
141
141
  return;
142
142
  const text = generatedContextText(item);
143
- const kind = parseCard(item)?.kind;
144
- const isBearingsOrRevive = kind === 'bearings' || kind === 'revive';
145
- if (isBearingsOrRevive) {
146
- if (this.openToolGroup !== undefined && text !== '')
147
- this.openToolGroup.pieces.push({ type: 'text', text });
143
+ const card = parseCard(item);
144
+ // A boundary card ends the preceding run and becomes the next group's
145
+ // context, exactly as a person's message does; the viewer reads the same
146
+ // set, so both sides fold at the same seams.
147
+ if (card !== null && isGroupBoundaryKind(card.kind)) {
148
+ this.visibleBoundary(text);
148
149
  return;
149
150
  }
150
151
  const group = this.ensureOpenToolGroup();
@@ -359,13 +359,13 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
359
359
  const liveSession = rebind.session();
360
360
  const identified = snapshotMessages(liveSession, boundaryReviewId);
361
361
  const tail = normalizeSnapshotTail(client.snapshotTail);
362
+ // A tail is only usable with stable ids, which let its recipient page back
363
+ // to the omitted history.
362
364
  const retained = identified === undefined || tail === undefined ? identified : identified.slice(-tail);
363
365
  const messages = retained?.map(({ message }) => message)
364
- ?? (tail === undefined
365
- ? liveSession.sessionManager.buildSessionContext().messages
366
- : liveSession.sessionManager.buildSessionContext().messages.slice(-tail));
366
+ ?? liveSession.sessionManager.buildSessionContext().messages;
367
367
  const fullToolGroupSummaries = toolGroups.summaries();
368
- const retainedToolCallIds = tail === undefined ? undefined : toolCallIds(messages);
368
+ const retainedToolCallIds = retained !== undefined && tail !== undefined ? toolCallIds(messages) : undefined;
369
369
  const toolGroupSummaries = retainedToolCallIds === undefined
370
370
  ? fullToolGroupSummaries
371
371
  : Object.fromEntries(Object.entries(fullToolGroupSummaries).filter(([id]) => retainedToolCallIds.has(id)));
@@ -318,14 +318,8 @@ export function buildReviveKickoff(meta, bearings, wakeReason) {
318
318
  if (bearings.driftGuidance !== null) {
319
319
  parts.push(`<persona-transition>\nYour role was changed while you were away. ${bearings.driftGuidance}\n</persona-transition>`);
320
320
  }
321
- // The whole kickoff is one runtime card. Its facts are what this producer
322
- // knows about the revive, and they are the ONLY thing a reader may classify or
323
- // summarize on — the prose above stays free to be reworded. The envelope is
324
- // also what keeps a fresh revive out of canvas-goal-capture's first-real-input
325
- // path, which would otherwise overwrite a bare root's mandate and rename it.
326
- return formatCard('revive', {
327
- mode: meta.mode,
328
- ...(meta.cycle_pending === true ? { cycle: true } : {}),
329
- ...(wakeReason === undefined ? {} : { wake: wakeReason.kind }),
330
- }, `\n${parts.join('\n\n')}\n`);
321
+ // The envelope keeps a fresh revive out of canvas-goal-capture's
322
+ // first-real-input path, which would otherwise overwrite a bare root's
323
+ // mandate and rename it.
324
+ return formatCard('revive', {}, `\n${parts.join('\n\n')}\n`);
331
325
  }
@@ -263,7 +263,13 @@ export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
263
263
  }
264
264
  const { messages, identified } = await reconstructVisibleMessages(nodeId);
265
265
  const before = cursor === undefined ? messages.length : decodeMessagesCursor(cursor, messages.length);
266
- const start = Math.max(0, before - limit);
266
+ let start = Math.max(0, before - limit);
267
+ while (start > 0
268
+ && typeof messages[start] === 'object'
269
+ && messages[start] !== null
270
+ && messages[start].role === 'toolResult') {
271
+ start--;
272
+ }
267
273
  const page = identified?.slice(start, before);
268
274
  return {
269
275
  messages: page?.map(({ message }) => message) ?? messages.slice(start, before),
@@ -12,8 +12,6 @@ export declare function formatSituationalProse(prose: string): string;
12
12
  * No-op for blank text, so a caller can pass through an optional/empty value
13
13
  * with no guard at the call site. */
14
14
  export declare function appendSituationalContext(nodeId: string, text: string): void;
15
- /** Read the current situational context, or null if none was ever set. */
16
- export declare function readSituationalContext(nodeId: string): string | null;
17
15
  /** The node's stored situational card, verbatim, or null when none is set.
18
16
  * The ONE read of the sidecar. A sidecar written before the card cut holds
19
17
  * bare prose; that single legacy branch lives here alone and self-clears on
@@ -46,8 +46,9 @@ export function appendSituationalContext(nodeId, text) {
46
46
  mkdirSync(nodeDir(nodeId), { recursive: true });
47
47
  writeFileSync(situationalContextPath(nodeId), body + '\n', 'utf8');
48
48
  }
49
- /** Read the current situational context, or null if none was ever set. */
50
- export function readSituationalContext(nodeId) {
49
+ /** Read the stored text, or null if none was ever set. Module-private:
50
+ * `situationalContextEnvelope` is the one read, and it shape-checks. */
51
+ function readSituationalContext(nodeId) {
51
52
  const p = situationalContextPath(nodeId);
52
53
  if (!existsSync(p))
53
54
  return null;
@@ -32,7 +32,7 @@ export async function setSituationalContextLive(nodeId, text) {
32
32
  throw new Error(`setSituationalContextLive: unknown node ${nodeId}`);
33
33
  const envelope = situationalContextEnvelope(nodeId);
34
34
  if (envelope === null)
35
- return; // blank text — appendSituationalContext no-ops, nothing to deliver
35
+ return; // no sidecar at all — blank text on a node that never had one
36
36
  await deliverCustomMessageLive(nodeId, {
37
37
  customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
38
38
  content: envelope,
@@ -67,10 +67,10 @@ export interface SpawnChildOpts {
67
67
  /** Preallocated node id, used when a managed worktree must be named before birth. */
68
68
  nodeId?: string;
69
69
  /** Hidden ambient "current situation" text — NOT the visible prompt/body.
70
- * Persisted via `appendSituationalContext` BEFORE launch so canvas-context-
71
- * intro's session_start bearings carry it as a `<situational-context>`
72
- * sibling from the node's very first turn. Passed through unchanged into a
73
- * scheduled node-birth command. Omit for no ambient context. */
70
+ * Stored as a `situational` card BEFORE launch so canvas-context-intro's
71
+ * session_start bearings nest it from the node's very first turn. Passed
72
+ * through unchanged into a scheduled node-birth command. Omit for no
73
+ * ambient context. */
74
74
  situationalContext?: string;
75
75
  }
76
76
  /** Resolve a `--fork-from` value to an ABSOLUTE `.jsonl` source path for the
@@ -28,11 +28,11 @@ import { readOutputRequest } from './structured-output.js';
28
28
  export { STALL_REPROMPT } from '../../shared/generated-context.js';
29
29
  /** Format the stop guard's reprompt for a valid structured-output request. */
30
30
  function formatValidStructuredOutputReprompt(schema) {
31
- return formatCard('stop-guard', { reason: 'structured-output' }, formatStructuredOutputReprompt(JSON.stringify(schema, null, 2)));
31
+ return formatCard('stop-guard', {}, formatStructuredOutputReprompt(JSON.stringify(schema, null, 2)));
32
32
  }
33
33
  /** Format the stop guard's reprompt for an invalid structured-output request file. */
34
34
  function formatInvalidStructuredOutputReprompt(error) {
35
- return formatCard('stop-guard', { reason: 'structured-output' }, `Structured-output request file is invalid and cannot be used: ${error}\n\n` +
35
+ return formatCard('stop-guard', {}, `Structured-output request file is invalid and cannot be used: ${error}\n\n` +
36
36
  `You have two options:\n` +
37
37
  `1. Remove the invalid file (the output-schema.json in your node directory has been corrupted)\n` +
38
38
  `2. Fix the corruption if you know the original request schema\n\n` +
@@ -99,6 +99,6 @@ export function evaluateStop(nodeId, signals, backgroundJobsRunning = activeBack
99
99
  return {
100
100
  action: 'reprompt',
101
101
  reason: 'stalled',
102
- message: formatCard('stop-guard', { reason: 'stalled' }, STALL_REPROMPT),
102
+ message: formatCard('stop-guard', {}, STALL_REPROMPT),
103
103
  };
104
104
  }
@@ -5,7 +5,10 @@ export interface NodeMessageDelivery {
5
5
  revived: boolean;
6
6
  }
7
7
  export interface RuntimeMessageCard {
8
- kind: string;
8
+ /** A bare core kind only. This renders with `formatCard`, which leaves the
9
+ * body raw — a namespaced kind's body is decoded on read (D12) and would be
10
+ * corrupted. Extend the union rather than widening to `string`. */
11
+ kind: 'review-comment' | 'review-queued' | 'review-approval';
9
12
  facts: Record<string, string | number | boolean | undefined>;
10
13
  }
11
14
  /**
@@ -70,14 +70,7 @@ export async function notifyComment(args) {
70
70
  comment_id: args.comment.comment_id,
71
71
  verb: args.verb,
72
72
  },
73
- runtime: {
74
- kind: 'review-comment',
75
- facts: {
76
- review_id: args.review.review_id,
77
- comment_id: args.comment.comment_id,
78
- verb: args.verb,
79
- },
80
- },
73
+ runtime: { kind: 'review-comment', facts: {} },
81
74
  });
82
75
  }
83
76
  catch (error) {
@@ -9,7 +9,7 @@ function approvalBody(review, resultPath) {
9
9
  }
10
10
  /** Render the immutable approval fact without replaying comment text. */
11
11
  export function renderApprovalMessage(review, resultPath) {
12
- return formatCard('review-approval', { review: review.review_id, file: review.file }, approvalBody(review, resultPath));
12
+ return formatCard('review-approval', {}, approvalBody(review, resultPath));
13
13
  }
14
14
  function recordedFailedFanout(review, reportBasename) {
15
15
  const error = review.delivery_error;
@@ -164,7 +164,7 @@ async function noteSubmitQueued(review) {
164
164
  mode: 'quiet',
165
165
  label: 'human review submitted — result to follow',
166
166
  data: { review_id: review.review_id },
167
- runtime: { kind: 'review-queued', facts: { review_id: review.review_id } },
167
+ runtime: { kind: 'review-queued', facts: {} },
168
168
  body: `The human submitted their review of \`${review.file}\`; the document is still being edited. `
169
169
  + "You'll be notified with the result once the review completes. Nothing is needed from you now.",
170
170
  });
@@ -409,7 +409,7 @@ test('stalled leaf (nothing live to await, no final) is still reprompted', async
409
409
  let shutdown = false;
410
410
  await settle(pi, stopEvent('I think I am basically done here'), { shutdown: () => { shutdown = true; } });
411
411
  assert.equal(pi.injected.length, 1, 'the stall reprompt fired');
412
- assert.equal(pi.injected[0].content, `<runtime kind="stop-guard" reason="stalled">${STALL_REPROMPT}</runtime>`, 'reprompt carries the stall nudge to push final / ask');
412
+ assert.equal(pi.injected[0].content, `<runtime kind="stop-guard">${STALL_REPROMPT}</runtime>`, 'reprompt carries the stall nudge to push final / ask');
413
413
  assert.equal(pi.injected[0].deliverAs, 'followUp', 'reprompt delivered as a followUp');
414
414
  assert.equal(shutdown, false, 'a stalled leaf is NOT shut down — it is re-prompted to finish');
415
415
  assert.notEqual(getNode('leaf')?.intent, 'idle-release', 'a stalled leaf does not idle-release');
@@ -705,8 +705,10 @@ class CanvasInboxWatcher {
705
705
  });
706
706
  }, stage);
707
707
  }
708
- /** The visible delivery and its own entry's one-shot cards in ONE send, so a
709
- * failed body never leaves its cards delivered and requeued for a resend. */
708
+ /** The visible delivery and its own entry's one-shot cards share ONE failure
709
+ * unit: a throw anywhere requeues the whole entry, so a retry may repeat a
710
+ * card already handed off (context is restatable; the human's body is not,
711
+ * and it is sent last). */
710
712
  sendVisible(entries, cards, content, route, stage) {
711
713
  return this.sendDelivery('visible', entries, () => {
712
714
  if (route !== 'idle' && route !== 'steer' && route !== 'followUp') {
@@ -82,7 +82,7 @@ export function registerCanvasReviewBoundary(pi) {
82
82
  ].join('\n');
83
83
  pi.sendMessage({
84
84
  customType: REVIEW_BOUNDARY_CUSTOM_TYPE,
85
- content: formatCard('review-boundary', { review: reviewId, file: targetFile }, content),
85
+ content: formatCard('review-boundary', {}, content),
86
86
  display: true,
87
87
  details: { reviewId, originNodeId, targetFile, kind: binding.kind ?? 'review' },
88
88
  });
@@ -1,14 +1,18 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { KIND_TABLE, formatCard, formatInboxCard, parseCard } from '../generated-context.js';
4
- test('core runtime cards preserve kind and facts', () => {
5
- for (const kind of Object.keys(KIND_TABLE)) {
6
- const facts = { source: 'grammar', enabled: true, count: 2 };
7
- const card = parseCard({ role: 'user', content: formatCard(kind, facts, 'model-visible body') });
8
- assert.ok(card, kind);
9
- assert.equal(card.kind, kind);
10
- assert.deepEqual(card.facts, { source: 'grammar', enabled: 'true', count: '2' });
11
- }
3
+ import { formatCard, formatDataCard, formatInboxCard, parseCard } from '../generated-context.js';
4
+ test('runtime cards preserve kind, facts, and their body encoding', () => {
5
+ const facts = { source: 'grammar', enabled: true, count: 2 };
6
+ const bare = parseCard({ role: 'user', content: formatCard('revive', facts, '<model-visible/>') });
7
+ assert.ok(bare);
8
+ assert.equal(bare.kind, 'revive');
9
+ assert.equal(bare.body, '<model-visible/>');
10
+ assert.deepEqual(bare.facts, { source: 'grammar', enabled: 'true', count: '2' });
11
+ const namespaced = parseCard({ role: 'user', content: formatDataCard('northlight:surface', facts, '<data body>') });
12
+ assert.ok(namespaced);
13
+ assert.equal(namespaced.kind, 'northlight:surface');
14
+ assert.equal(namespaced.body, '<data body>');
15
+ assert.deepEqual(namespaced.facts, { source: 'grammar', enabled: 'true', count: '2' });
12
16
  });
13
17
  test('inbox entries retain their typed disposition and escaped sender name', () => {
14
18
  const name = 'designer [cards] & "notes"\nnext line';
@@ -58,21 +62,14 @@ test('an envelope wins over customType, with a custom-role fallback for old sess
58
62
  assert.ok(preEnvelope);
59
63
  assert.equal(preEnvelope.kind, 'bearings');
60
64
  });
61
- test('interactive review notices classify from their typed facts', () => {
62
- const comment = parseCard({
65
+ test('parsed cards expose only the shared semantic grammar', () => {
66
+ const card = parseCard({
63
67
  role: 'user',
64
- content: formatCard('review-comment', { review_id: 'review-1', comment_id: 'comment-1', verb: 'create' }, 'Review comment created.'),
68
+ content: formatCard('review-comment', { review_id: 'review-1', comment_id: 'comment-1' }, 'Review comment created.'),
65
69
  });
66
- assert.ok(comment);
67
- assert.equal(comment.label, 'crtr review');
68
- assert.equal(comment.summary, 'review comment created');
69
- assert.deepEqual(comment.facts, { review_id: 'review-1', comment_id: 'comment-1', verb: 'create' });
70
- const queued = parseCard({
71
- role: 'user',
72
- content: formatCard('review-queued', { review_id: 'review-1' }, 'Review submission is queued.'),
73
- });
74
- assert.ok(queued);
75
- assert.equal(queued.summary, 'review submitted; completion pending');
70
+ assert.ok(card);
71
+ assert.deepEqual(card.facts, { review_id: 'review-1', comment_id: 'comment-1' });
72
+ assert.deepEqual(Object.keys(card).sort(), ['body', 'facts', 'kind', 'senders']);
76
73
  });
77
74
  test('only the whole outer runtime element classifies a user message', () => {
78
75
  const body = 'A nested <runtime kind="inbox">is model-visible body content</runtime> remains content.';
@@ -58,24 +58,7 @@ export interface GeneratedCard {
58
58
  facts: Readonly<Record<string, string>>;
59
59
  body: string;
60
60
  senders: CardSender[];
61
- label: string;
62
- summary: string;
63
- expandable: boolean;
64
61
  }
65
- type CardSummary = (facts: Readonly<Record<string, string>>) => string;
66
- export interface CardKindDefinition {
67
- label: string;
68
- summary: CardSummary;
69
- expandable: boolean;
70
- customTypes?: Readonly<Record<string, Partial<Pick<CardKindDefinition, 'label' | 'summary' | 'expandable'>>>>;
71
- }
72
- /** The closed crouter vocabulary, plus custom-role aliases for the same cards. */
73
- export declare const KIND_TABLE: Readonly<Record<string, CardKindDefinition>>;
74
- /** Contribute presentation for namespaced kinds this process will render.
75
- * Idempotent and last-write-wins, so a re-imported module cannot fail a boot.
76
- * A process that never registers is not degraded: an unregistered kind still
77
- * parses and only falls back to the generic label and summary. */
78
- export declare function registerCardKinds(kinds: Readonly<Record<string, CardKindDefinition>>): void;
79
62
  /** Plain text from a generated message's string or text-block content. */
80
63
  export declare function generatedContextText(message: GeneratedContextMessageLike): string;
81
64
  /** Wrap a user-role runtime message in its whole-message card envelope. */
@@ -91,4 +74,3 @@ export declare function parseCard(message: GeneratedContextMessageLike): Generat
91
74
  /** Format a complete inbox card from report bodies resolved by the caller. */
92
75
  export declare function formatInboxCard(sections: readonly InboxCardSection[]): string;
93
76
  export declare function isLegacyRuntimeText(text: string): boolean;
94
- export {};