@north-light/crouter 0.3.207 → 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.
- package/dist/clients/attach/__tests__/group-activity.test.js +8 -1
- package/dist/clients/attach/render/card-presentation.d.ts +31 -0
- package/dist/clients/attach/render/card-presentation.js +163 -0
- package/dist/clients/attach/render/chat-view.d.ts +0 -2
- package/dist/clients/attach/render/chat-view.js +12 -23
- package/dist/clients/attach/render/context-message.d.ts +5 -9
- package/dist/clients/attach/render/context-message.js +20 -43
- package/dist/clients/attach/render/group-activity.d.ts +28 -4
- package/dist/clients/attach/render/group-activity.js +65 -11
- package/dist/clients/attach/render/group-recap.d.ts +3 -0
- package/dist/clients/attach/render/group-recap.js +17 -0
- package/dist/clients/attach/viewer.js +516 -516
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +1 -1
- package/dist/core/__tests__/helpers/broker-clients.d.ts +3 -1
- package/dist/core/__tests__/helpers/broker-clients.js +7 -2
- package/dist/core/__tests__/seam/broker-attach-multiclient.test.js +9 -0
- package/dist/core/__tests__/serial/broker-snapshot-history.test.js +52 -1
- package/dist/core/__tests__/serial/flagship-lifecycle.test.js +1 -1
- package/dist/core/runtime/broker/fault-retry.js +2 -7
- package/dist/core/runtime/broker/frame-dispatch.js +1 -1
- package/dist/core/runtime/broker/tool-groups.js +7 -6
- package/dist/core/runtime/broker.js +4 -4
- package/dist/core/runtime/kickoff.js +4 -10
- package/dist/core/runtime/node-read.js +7 -1
- package/dist/core/runtime/stop-guard.js +3 -3
- package/dist/daemon/review/comment-notify.js +1 -8
- package/dist/daemon/review/deliver.js +1 -1
- package/dist/daemon/review/finish.js +1 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +1 -1
- package/dist/pi-extensions/canvas-review-boundary.js +1 -1
- package/dist/shared/__tests__/generated-context-grammar.test.js +19 -22
- package/dist/shared/generated-context.d.ts +0 -18
- package/dist/shared/generated-context.js +16 -120
- package/dist/shared/tool-groups.d.ts +1 -0
- package/dist/shared/tool-groups.js +7 -0
- package/package.json +1 -1
- 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 }, {
|
|
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
|
|
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({
|
|
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', {
|
|
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
|
|
@@ -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', {
|
|
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', {
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
?? (
|
|
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 =
|
|
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
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
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
|
-
|
|
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),
|
|
@@ -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', {
|
|
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', {
|
|
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', {
|
|
102
|
+
message: formatCard('stop-guard', {}, STALL_REPROMPT),
|
|
103
103
|
};
|
|
104
104
|
}
|
|
@@ -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', {
|
|
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: {
|
|
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"
|
|
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');
|
|
@@ -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', {
|
|
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 {
|
|
4
|
-
test('
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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('
|
|
62
|
-
const
|
|
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'
|
|
68
|
+
content: formatCard('review-comment', { review_id: 'review-1', comment_id: 'comment-1' }, 'Review comment created.'),
|
|
65
69
|
});
|
|
66
|
-
assert.ok(
|
|
67
|
-
assert.
|
|
68
|
-
assert.
|
|
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 {};
|
|
@@ -35,98 +35,13 @@ export function formatModelFallbackRecovery(previousModel, nextModel, reason) {
|
|
|
35
35
|
? `The previous model (${previousModel}) had no usable provider credential, so you were automatically switched to ${nextModel}. Continue the task from where the failed turn left off.`
|
|
36
36
|
: `The previous model (${previousModel}) was unavailable (provider 404 not_found), so you were automatically switched to ${nextModel}. Continue the task from where the failed turn left off.`;
|
|
37
37
|
}
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const updates = countFact(facts, 'updates');
|
|
46
|
-
const senders = countFact(facts, 'senders');
|
|
47
|
-
if (updates === null)
|
|
48
|
-
return 'inbox update';
|
|
49
|
-
if (senders === null || senders <= 1)
|
|
50
|
-
return `${updates === 1 ? 'message' : `${updates} messages`} received`;
|
|
51
|
-
return `${updates} messages received from ${senders} nodes`;
|
|
52
|
-
}
|
|
53
|
-
function recoverySummaryFromFacts(facts) {
|
|
54
|
-
switch (facts.reason) {
|
|
55
|
-
case 'model-fallback': return 'model route changed';
|
|
56
|
-
case 'connection': return 'network connection restored';
|
|
57
|
-
case 'provider': return 'provider retry';
|
|
58
|
-
case 'auth': return 'provider credentials updated';
|
|
59
|
-
default: return 'runtime recovery';
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
function reviewCommentSummary(facts) {
|
|
63
|
-
switch (facts.verb) {
|
|
64
|
-
case 'create': return 'review comment created';
|
|
65
|
-
case 'edit': return 'review comment edited';
|
|
66
|
-
case 'resolve': return 'review comment resolved';
|
|
67
|
-
case 'reopen': return 'review comment reopened';
|
|
68
|
-
case 'delete': return 'review comment deleted';
|
|
69
|
-
default: return 'review comment updated';
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
/** The closed crouter vocabulary, plus custom-role aliases for the same cards. */
|
|
73
|
-
export const KIND_TABLE = {
|
|
74
|
-
inbox: { label: 'crtr inbox', summary: inboxSummary, expandable: true },
|
|
75
|
-
revive: { label: 'crtr revive', summary: () => 'fresh context kickoff', expandable: true },
|
|
76
|
-
'restart-continuation': { label: 'crouter continuation', summary: () => 'continuing from where it left off', expandable: true },
|
|
77
|
-
'stop-guard': {
|
|
78
|
-
label: 'crtr stop guard',
|
|
79
|
-
summary: (facts) => facts.reason === 'structured-output' ? 'structured output required' : 'completion required',
|
|
80
|
-
expandable: true,
|
|
81
|
-
},
|
|
82
|
-
recovery: { label: 'crouter continuation', summary: recoverySummaryFromFacts, expandable: true },
|
|
83
|
-
'persona-transition': { label: 'crtr persona', summary: () => 'runtime role update', expandable: true },
|
|
84
|
-
'review-approval': { label: 'crtr review', summary: () => 'review approved by the user', expandable: true },
|
|
85
|
-
'review-comment': { label: 'crtr review', summary: reviewCommentSummary, expandable: true },
|
|
86
|
-
'review-queued': { label: 'crtr review', summary: () => 'review submitted; completion pending', expandable: true },
|
|
87
|
-
'context-nudge': {
|
|
88
|
-
label: 'crtr',
|
|
89
|
-
summary: (facts) => facts.size === undefined ? 'context-window guidance' : `Context ${facts.size}`,
|
|
90
|
-
expandable: true,
|
|
91
|
-
customTypes: { [CONTEXT_NUDGE_CUSTOM_TYPE]: {} },
|
|
92
|
-
},
|
|
93
|
-
bearings: {
|
|
94
|
-
label: 'crtr context',
|
|
95
|
-
summary: () => 'orienting bearings',
|
|
96
|
-
expandable: true,
|
|
97
|
-
customTypes: { [CONTEXT_INTRO_CUSTOM_TYPE]: {} },
|
|
98
|
-
},
|
|
99
|
-
'review-boundary': {
|
|
100
|
-
label: 'review boundary',
|
|
101
|
-
summary: () => 'earlier conversation is not shown',
|
|
102
|
-
expandable: true,
|
|
103
|
-
customTypes: { [REVIEW_BOUNDARY_CUSTOM_TYPE]: {} },
|
|
104
|
-
},
|
|
105
|
-
situational: {
|
|
106
|
-
label: 'situational context',
|
|
107
|
-
summary: () => 'ambient context update',
|
|
108
|
-
expandable: true,
|
|
109
|
-
customTypes: { [SITUATIONAL_CONTEXT_CUSTOM_TYPE]: {} },
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
const GENERIC_CARD = {
|
|
113
|
-
label: 'crtr runtime',
|
|
114
|
-
summary: () => 'runtime context',
|
|
115
|
-
expandable: true,
|
|
116
|
-
};
|
|
117
|
-
/** Kinds contributed by a product or plugin, in whatever process renders them. */
|
|
118
|
-
const KIND_REGISTRY = {};
|
|
119
|
-
/** Contribute presentation for namespaced kinds this process will render.
|
|
120
|
-
* Idempotent and last-write-wins, so a re-imported module cannot fail a boot.
|
|
121
|
-
* A process that never registers is not degraded: an unregistered kind still
|
|
122
|
-
* parses and only falls back to the generic label and summary. */
|
|
123
|
-
export function registerCardKinds(kinds) {
|
|
124
|
-
for (const [kind, definition] of Object.entries(kinds)) {
|
|
125
|
-
// Bare kinds are crouter core's closed vocabulary. A product claiming one
|
|
126
|
-
// is a programming error at boot, not a runtime condition to tolerate.
|
|
127
|
-
if (!kind.includes(':'))
|
|
128
|
-
throw new Error(`Runtime card kind must be namespaced (contain ':'): ${kind}`);
|
|
129
|
-
KIND_REGISTRY[kind] = definition;
|
|
38
|
+
function legacyCustomTypeKind(customType) {
|
|
39
|
+
switch (customType) {
|
|
40
|
+
case CONTEXT_NUDGE_CUSTOM_TYPE: return 'context-nudge';
|
|
41
|
+
case CONTEXT_INTRO_CUSTOM_TYPE: return 'bearings';
|
|
42
|
+
case REVIEW_BOUNDARY_CUSTOM_TYPE: return 'review-boundary';
|
|
43
|
+
case SITUATIONAL_CONTEXT_CUSTOM_TYPE: return 'situational';
|
|
44
|
+
default: return undefined;
|
|
130
45
|
}
|
|
131
46
|
}
|
|
132
47
|
const XML_ATTRIBUTE_NAME = /^[A-Za-z_:][A-Za-z0-9_:.-]*$/;
|
|
@@ -248,16 +163,7 @@ function parseInboxBody(body) {
|
|
|
248
163
|
return senders;
|
|
249
164
|
}
|
|
250
165
|
function cardFor(kind, facts, body, senders) {
|
|
251
|
-
|
|
252
|
-
return {
|
|
253
|
-
kind,
|
|
254
|
-
facts,
|
|
255
|
-
body,
|
|
256
|
-
senders,
|
|
257
|
-
label: definition.label,
|
|
258
|
-
summary: definition.summary(facts),
|
|
259
|
-
expandable: definition.expandable,
|
|
260
|
-
};
|
|
166
|
+
return { kind, facts, body, senders };
|
|
261
167
|
}
|
|
262
168
|
/** Parse a whole-message runtime envelope, regardless of delivery role. */
|
|
263
169
|
function parseEnvelope(text) {
|
|
@@ -286,22 +192,13 @@ export function parseCard(message) {
|
|
|
286
192
|
return null;
|
|
287
193
|
// Pre-envelope custom-message fallback for old sessions. New custom messages
|
|
288
194
|
// classify through their envelope; customType is delivery metadata only.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
facts,
|
|
297
|
-
body: text,
|
|
298
|
-
senders: [],
|
|
299
|
-
label: override.label ?? definition.label,
|
|
300
|
-
summary: (override.summary ?? definition.summary)(facts),
|
|
301
|
-
expandable: override.expandable ?? definition.expandable,
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
return null;
|
|
195
|
+
const kind = legacyCustomTypeKind(message.customType);
|
|
196
|
+
return kind === undefined ? null : {
|
|
197
|
+
kind,
|
|
198
|
+
facts: Object.freeze({}),
|
|
199
|
+
body: text,
|
|
200
|
+
senders: [],
|
|
201
|
+
};
|
|
305
202
|
}
|
|
306
203
|
/** Format a complete inbox card from report bodies resolved by the caller. */
|
|
307
204
|
export function formatInboxCard(sections) {
|
|
@@ -316,8 +213,7 @@ export function formatInboxCard(sections) {
|
|
|
316
213
|
}).join('\n');
|
|
317
214
|
return `<from${formatAttributes({ id: section.id, name: section.name, updates, finished: finished || undefined })}>${entries}</from>`;
|
|
318
215
|
}).join('\n');
|
|
319
|
-
|
|
320
|
-
return formatCard('inbox', { senders: sections.length, updates }, body);
|
|
216
|
+
return formatCard('inbox', {}, body);
|
|
321
217
|
}
|
|
322
218
|
// LEGACY — landed 2026-08-14. Pre-envelope runtime text, kept ONLY so old
|
|
323
219
|
// scrollback is not attributed to the person. Delete this section and its
|
|
@@ -11,6 +11,7 @@ export interface ToolGroupMessageLike {
|
|
|
11
11
|
content?: unknown;
|
|
12
12
|
customType?: string;
|
|
13
13
|
}
|
|
14
|
+
export declare function isGroupBoundaryKind(kind: string): boolean;
|
|
14
15
|
/** Validate the one durable summary shape and cap its display-sized fields.
|
|
15
16
|
* Invalid entries are absent rather than adapted, so their calls stay visible. */
|
|
16
17
|
export declare function normalizeToolGroupSummary(value: unknown): ToolGroupSummary | null;
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
// Keep this independent of renderer state: both live event tracking and snapshot
|
|
3
3
|
// reconstruction must agree on which prose closes a run of tools.
|
|
4
4
|
import { generatedContextText, isLegacyRuntimeText, parseCard } from './generated-context.js';
|
|
5
|
+
/** Card kinds that close a run of tools instead of joining one. The broker
|
|
6
|
+
* segments and the viewer folds on the same seams, so this one answer is shared;
|
|
7
|
+
* everything else about a card is the display's own business. */
|
|
8
|
+
const GROUP_BOUNDARY_KINDS = new Set(['restart-continuation', 'review-boundary']);
|
|
9
|
+
export function isGroupBoundaryKind(kind) {
|
|
10
|
+
return GROUP_BOUNDARY_KINDS.has(kind);
|
|
11
|
+
}
|
|
5
12
|
const TOOL_GROUP_SUMMARY_KEYS = new Set(['bullets', 'filesDeleted']);
|
|
6
13
|
const MAX_TOOL_GROUP_BULLETS = 5;
|
|
7
14
|
const MAX_TOOL_GROUP_BULLET_LENGTH = 1_000;
|