@north-light/crouter 0.3.203 → 0.3.205
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/api/dto/broker.d.ts +2 -0
- package/dist/api/dto/nodes.d.ts +8 -5
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/clients/attach/__tests__/context-message.test.js +54 -19
- package/dist/clients/attach/__tests__/page-block.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/page-block.test.js +54 -0
- package/dist/clients/attach/render/chat-view.d.ts +3 -0
- package/dist/clients/attach/render/chat-view.js +56 -27
- package/dist/clients/attach/render/context-message.d.ts +6 -0
- package/dist/clients/attach/render/context-message.js +21 -3
- package/dist/clients/attach/render/group-activity.d.ts +5 -3
- package/dist/clients/attach/render/group-activity.js +10 -6
- package/dist/clients/attach/render/page-block.js +11 -11
- package/dist/clients/attach/viewer.js +717 -703
- package/dist/clients/conversation/projection.js +7 -37
- package/dist/commands/node/create.js +1 -1
- package/dist/commands/profile/project.js +1 -1
- package/dist/commands/profile/show.js +1 -1
- package/dist/commands/profile.js +1 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +88 -21
- package/dist/core/__tests__/human-deliver.test.js +66 -5
- package/dist/core/__tests__/serial/broker-snapshot-history.test.js +5 -2
- package/dist/core/__tests__/serial/deferred-no-wake.test.js +7 -4
- package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -1
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +4 -1
- package/dist/core/__tests__/serial/revive.test.js +8 -3
- package/dist/core/__tests__/session-cycles.test.js +10 -6
- package/dist/core/canvas/render-source.js +3 -0
- package/dist/core/feed/feed.d.ts +7 -1
- package/dist/core/feed/feed.js +8 -7
- package/dist/core/feed/inbox.d.ts +16 -12
- package/dist/core/feed/inbox.js +56 -56
- package/dist/core/human/page-eval.d.ts +9 -4
- package/dist/core/human/page-eval.js +3 -1
- package/dist/core/human/page-markdown.d.ts +3 -0
- package/dist/core/human/page-markdown.js +295 -0
- package/dist/core/human/page.js +4 -0
- package/dist/core/runtime/broker/client-registry.d.ts +1 -0
- package/dist/core/runtime/broker/client-registry.js +1 -0
- package/dist/core/runtime/broker/fault-retry.js +8 -3
- package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
- package/dist/core/runtime/broker/frame-dispatch.js +4 -2
- package/dist/core/runtime/broker/inbox.d.ts +14 -2
- package/dist/core/runtime/broker/inbox.js +52 -25
- package/dist/core/runtime/broker/passive.d.ts +1 -0
- package/dist/core/runtime/broker/tool-groups.js +3 -2
- package/dist/core/runtime/broker-protocol.d.ts +9 -0
- package/dist/core/runtime/broker-protocol.js +11 -0
- package/dist/core/runtime/broker.d.ts +2 -2
- package/dist/core/runtime/broker.js +31 -6
- package/dist/core/runtime/deliver-live.js +2 -1
- package/dist/core/runtime/kickoff.d.ts +3 -4
- package/dist/core/runtime/kickoff.js +23 -13
- package/dist/core/runtime/node-read.d.ts +2 -0
- package/dist/core/runtime/node-read.js +11 -3
- package/dist/core/runtime/session-cycles.d.ts +6 -1
- package/dist/core/runtime/session-cycles.js +17 -11
- package/dist/core/runtime/session-visibility.d.ts +7 -12
- package/dist/core/runtime/session-visibility.js +7 -13
- package/dist/core/runtime/situational-live.js +2 -1
- package/dist/core/runtime/stop-guard.js +8 -4
- package/dist/core/runtime/warm-pool.d.ts +1 -1
- package/dist/core/runtime/warm-pool.js +6 -9
- package/dist/daemon/api/handlers/nodes.js +10 -7
- package/dist/daemon/companion-retire.d.ts +2 -2
- package/dist/daemon/companion-retire.js +16 -9
- package/dist/daemon/human/finish.d.ts +2 -2
- package/dist/daemon/human/finish.js +48 -8
- package/dist/daemon/human/sweep.js +3 -2
- package/dist/daemon/messaging/node-message.d.ts +5 -0
- package/dist/daemon/messaging/node-message.js +6 -1
- package/dist/daemon/review/comment-notify.js +8 -0
- package/dist/daemon/review/deliver.js +2 -2
- package/dist/daemon/review/finish.js +3 -2
- package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +96 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +1 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +2 -2
- package/dist/pi-extensions/broker-local.d.ts +0 -2
- package/dist/pi-extensions/broker-local.js +0 -2
- package/dist/pi-extensions/canvas-context-intro.js +4 -3
- package/dist/pi-extensions/canvas-goal-capture.js +10 -6
- package/dist/pi-extensions/canvas-inbox-watcher.js +36 -25
- package/dist/pi-extensions/canvas-passive-context.d.ts +10 -0
- package/dist/pi-extensions/canvas-passive-context.js +23 -3
- package/dist/pi-extensions/canvas-review-boundary.d.ts +1 -1
- package/dist/pi-extensions/canvas-review-boundary.js +11 -3
- package/dist/pi-extensions/canvas-stophook.js +3 -3
- package/dist/shared/__tests__/generated-context-grammar.test.d.ts +1 -0
- package/dist/shared/__tests__/generated-context-grammar.test.js +85 -0
- package/dist/shared/generated-context.d.ts +57 -36
- package/dist/shared/generated-context.js +273 -174
- package/dist/shared/tool-groups.js +3 -2
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/postinstall.mjs +9 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// projection.ts — the surface-neutral reducer that turns a broker snapshot and
|
|
2
2
|
// its live frames into one ordered transcript model. Renderers retain their own
|
|
3
3
|
// folding and presentation; this module owns only wire-state reconciliation.
|
|
4
|
-
import { isInboxDigest } from '../../shared/generated-context.js';
|
|
5
4
|
import { normalizeToolGroupSummary, normalizeToolGroupSummaryRecord } from '../../shared/tool-groups.js';
|
|
6
5
|
import { DEFAULT_WORKING_ACTIVITY } from '../../shared/working-activity.js';
|
|
7
6
|
export function initialConvState() {
|
|
@@ -32,34 +31,6 @@ export function restoreConvState(entry) {
|
|
|
32
31
|
toolGroupSummaryDetails: normalizeToolGroupSummaryRecord(entry.toolGroupSummaries),
|
|
33
32
|
};
|
|
34
33
|
}
|
|
35
|
-
/** Plain text of a user message (string content or concatenated text blocks),
|
|
36
|
-
* for inbox-digest detection. */
|
|
37
|
-
function userMessageText(m) {
|
|
38
|
-
const content = m.content;
|
|
39
|
-
if (typeof content === 'string')
|
|
40
|
-
return content;
|
|
41
|
-
if (Array.isArray(content)) {
|
|
42
|
-
return content
|
|
43
|
-
.filter((b) => !!b && b.type === 'text')
|
|
44
|
-
.map((b) => (typeof b.text === 'string' ? b.text : ''))
|
|
45
|
-
.join('');
|
|
46
|
-
}
|
|
47
|
-
return '';
|
|
48
|
-
}
|
|
49
|
-
/** Tag a `role:'user'` message as inbox-origin when its text matches the
|
|
50
|
-
* canvas-inbox-watcher coalesce digest; other messages pass through unchanged.
|
|
51
|
-
* The broker relays pi AgentMessages verbatim and never carries `origin` on the
|
|
52
|
-
* wire, so this is the sole writer of `origin:'inbox'` — recognized from the
|
|
53
|
-
* digest text. (Idempotent: a message already tagged is left as-is.) */
|
|
54
|
-
function tagInboxOrigin(m) {
|
|
55
|
-
if (roleOf(m) !== 'user')
|
|
56
|
-
return m;
|
|
57
|
-
if (m.origin)
|
|
58
|
-
return m;
|
|
59
|
-
if (!isInboxDigest(userMessageText(m)))
|
|
60
|
-
return m;
|
|
61
|
-
return { ...m, origin: 'inbox' };
|
|
62
|
-
}
|
|
63
34
|
/** An empty assistant message shaped so the next `message_update`/`message_end`
|
|
64
35
|
* (which replace, not append — see `reduce`'s `message_update` case) has a
|
|
65
36
|
* slot to land on. */
|
|
@@ -85,7 +56,7 @@ function placeholderAssistant() {
|
|
|
85
56
|
* first `message_update` REPLACES that message wholesale, so no duplicate or
|
|
86
57
|
* dropped turn results. */
|
|
87
58
|
export function applySnapshot(snapshot, previous) {
|
|
88
|
-
const messages = snapshot.messages
|
|
59
|
+
const messages = snapshot.messages;
|
|
89
60
|
const toolGroupSummaryDetails = snapshotToolGroupSummaries(snapshot);
|
|
90
61
|
const toolGroupSummaries = summaryTexts(toolGroupSummaryDetails);
|
|
91
62
|
const isStreaming = snapshot.state?.isStreaming === true;
|
|
@@ -263,14 +234,13 @@ export function reduce(state, event) {
|
|
|
263
234
|
executingToolIds: state.executingToolIds.size ? new Set() : state.executingToolIds,
|
|
264
235
|
};
|
|
265
236
|
case 'message_start': {
|
|
266
|
-
// User messages arrive complete on message_start (no streaming deltas)
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
const
|
|
270
|
-
const isAssistant = roleOf(tagged) === 'assistant';
|
|
237
|
+
// User messages arrive complete on message_start (no streaming deltas).
|
|
238
|
+
const message = event.message;
|
|
239
|
+
const messages = [...state.messages, message];
|
|
240
|
+
const isAssistant = roleOf(message) === 'assistant';
|
|
271
241
|
let partialToolResults = state.partialToolResults;
|
|
272
|
-
if (roleOf(
|
|
273
|
-
const id =
|
|
242
|
+
if (roleOf(message) === 'toolResult') {
|
|
243
|
+
const id = message.toolCallId;
|
|
274
244
|
if (typeof id === 'string' && partialToolResults.has(id)) {
|
|
275
245
|
const next = new Map(partialToolResults);
|
|
276
246
|
next.delete(id);
|
|
@@ -71,7 +71,7 @@ function nodeNewParams() {
|
|
|
71
71
|
{ kind: 'stdin', name: 'prompt', required: false, constraint: 'First user message. Required unless --no-kickoff is given.' },
|
|
72
72
|
{ kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind. The <kinds> list below names every top-level installable kind.' },
|
|
73
73
|
{ kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for hands-on work; orchestrator when the unit itself needs decomposition across children.' },
|
|
74
|
-
{ kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Pin the node to this directory. Defaults to the spawner\u2019s directory for a managed child, and to the
|
|
74
|
+
{ kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Pin the node to this directory. Defaults to the spawner\u2019s directory for a managed child, and to the directory this command runs in for a root.' },
|
|
75
75
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name. Defaults to the kind.' },
|
|
76
76
|
{ kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
|
|
77
77
|
{ kind: 'flag', name: 'node-id', type: 'string', required: false, constraint: 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.' },
|
|
@@ -31,7 +31,7 @@ const addLeaf = defineLeaf({
|
|
|
31
31
|
name: 'home',
|
|
32
32
|
type: 'bool',
|
|
33
33
|
required: false,
|
|
34
|
-
constraint: "Also make this directory the profile's home — where
|
|
34
|
+
constraint: "Also make this directory the profile's home — where a node under it runs when its create names no directory. Defaults to the first project dir; pass this on an already-listed dir to re-point it.",
|
|
35
35
|
},
|
|
36
36
|
],
|
|
37
37
|
output: [
|
|
@@ -22,7 +22,7 @@ export const showLeaf = defineLeaf({
|
|
|
22
22
|
{ name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
|
|
23
23
|
{ name: 'name', type: 'string', required: true, constraint: 'Manifest name.' },
|
|
24
24
|
{ name: 'projects', type: 'string[]', required: true, constraint: 'Absolute project directories, in manifest order.' },
|
|
25
|
-
{ name: 'home', type: 'string | null', required: true, constraint: '
|
|
25
|
+
{ name: 'home', type: 'string | null', required: true, constraint: 'Where a node under this profile runs when its create names no directory at all — an explicit --cwd and the directory a root is launched from both win over it. Defaults to the first project; re-point it with `profile project add --home`. Null when the profile owns no projects (the root profile).' },
|
|
26
26
|
{ name: 'created_at', type: 'string', required: true, constraint: 'ISO 8601 creation timestamp.' },
|
|
27
27
|
{ name: 'last_used_at', type: 'string | null', required: true, constraint: 'ISO 8601 timestamp of the most recent selection, or null if never selected.' },
|
|
28
28
|
{ name: 'path', type: 'string', required: true, constraint: 'Absolute path to the profile directory.' },
|
package/dist/commands/profile.js
CHANGED
|
@@ -30,7 +30,7 @@ export function registerProfile() {
|
|
|
30
30
|
help: {
|
|
31
31
|
name: 'profile',
|
|
32
32
|
summary: 'create, inspect, and manage profiles \u2014 agent identities with their own project purview and memory store',
|
|
33
|
-
model: 'A profile owns a `home` — the first of its project dirs unless `project add --home` re-points it —
|
|
33
|
+
model: 'A profile owns a `home` — the first of its project dirs unless `project add --home` re-points it — which is where a node under the profile runs when its create names no directory at all. A node lands in the directory it was launched from when there is one: `node new --cwd`, an inherited spawner directory (notably inside a managed worktree), or the directory a root was started in. So one profile can cover many working directories — cwd selects the identity, and the launch decides where the work happens. `new` creates one from a name (+ at most one initial `--project`, which becomes its home). `list` inventories every profile as JSONL. `show` prints one manifest in full. `rename` changes only the manifest name \u2014 the profile_id is stable across a rename. `delete --force` removes the profile after cascading through its nodes, matching schedules, originating inbox/review state, and default pins; `--detach --force` instead preserves nodes and human history while clearing the profile identity and globalizing its schedules. Both modes block while a matching node owns an open managed worktree and never touch the project directories the profile pointed at. `project` widens/narrows a profile\u2019s project purview (the dirs it resolves memory + config from). `default` pins which profile a directory defaults to at startup so the chooser stops asking \u2014 directory-scoped, independent of purview. `env` manages a profile-scoped 0600 store of env vars injected directly into every broker launched under that profile \u2014 set on stdin, list names only (never values), or remove one; no `spawnEnv.allow` entry needed. Every manifest verb but `new`/`list` takes <profile> as an exact id or a unique manifest name; an ambiguous name fails listing every matching id.',
|
|
34
34
|
},
|
|
35
35
|
children: [newLeaf, listLeaf, showLeaf, renameLeaf, deleteLeaf, projectBranch, defaultBranch, envBranch],
|
|
36
36
|
});
|
|
@@ -17,6 +17,7 @@ import { reportsDir } from '../canvas/paths.js';
|
|
|
17
17
|
import { closeDb } from '../canvas/db.js';
|
|
18
18
|
import { appendInbox, coalesce, finalizeInboxEntry } from '../feed/inbox.js';
|
|
19
19
|
import { appendSituationalContext, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../runtime/situational-context.js';
|
|
20
|
+
import { parseCard } from '../../shared/generated-context.js';
|
|
20
21
|
// Drive the watcher's injectable cadence seam (CRTR_WATCHER_TICK_MS /
|
|
21
22
|
// CRTR_WATCHER_DEBOUNCE_MS) at a fast tempo so the watcher delivers in tens of
|
|
22
23
|
// milliseconds, not seconds — exactly the resolve+seed tick, read tick, and
|
|
@@ -49,6 +50,11 @@ function makeFakePi() {
|
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
53
|
+
/** The text of a batch that must coalesce into exactly one delivery. */
|
|
54
|
+
function onlyDelivery(deliveries) {
|
|
55
|
+
assert.equal(deliveries.length, 1, 'expected a single delivery');
|
|
56
|
+
return deliveries[0].text;
|
|
57
|
+
}
|
|
52
58
|
// Register the watcher, then fire session_start — the real lifecycle gate
|
|
53
59
|
// that arms its poll interval and initial tick (see canvas-inbox-watcher.ts).
|
|
54
60
|
// registerCanvasInboxWatcher() alone only wires up handlers; without this the
|
|
@@ -75,7 +81,7 @@ async function waitFor(predicate, timeoutMs = 3000, stepMs = 10) {
|
|
|
75
81
|
await wait(stepMs);
|
|
76
82
|
}
|
|
77
83
|
}
|
|
78
|
-
test('coalesce
|
|
84
|
+
test('coalesce renders one inbox card whose entries inline short report bodies and carry their refs', () => {
|
|
79
85
|
freshNode('child');
|
|
80
86
|
const child = createNode({
|
|
81
87
|
node_id: 'child',
|
|
@@ -90,38 +96,83 @@ test('coalesce inlines short report bodies with a trailing artifact pointer and
|
|
|
90
96
|
const reportPath = join(reportsDir(child.node_id), 'short-update.md');
|
|
91
97
|
mkdirSync(reportsDir(child.node_id), { recursive: true });
|
|
92
98
|
writeFileSync(reportPath, '---\nnode: child\nkind: update\n---\nshort report body', 'utf8');
|
|
93
|
-
const digest = coalesce([
|
|
99
|
+
const digest = onlyDelivery(coalesce([
|
|
94
100
|
finalizeInboxEntry({ from: 'child', tier: 'normal', kind: 'update', label: 'short report', ref: 'child:reports/short-update.md' }),
|
|
101
|
+
]));
|
|
102
|
+
// The envelope is the whole message: a reader that finds it anywhere else
|
|
103
|
+
// attributes the digest to the person.
|
|
104
|
+
assert.equal(digest.startsWith('<runtime kind="inbox"'), true);
|
|
105
|
+
assert.equal(digest.endsWith('</runtime>'), true);
|
|
106
|
+
const card = parseCard({ role: 'user', content: digest });
|
|
107
|
+
assert.notEqual(card, null);
|
|
108
|
+
assert.equal(card.kind, 'inbox');
|
|
109
|
+
assert.deepEqual({ ...card.facts }, { senders: '1', updates: '1' });
|
|
110
|
+
assert.equal(card.senders.length, 1);
|
|
111
|
+
assert.equal(card.senders[0].id, 'child');
|
|
112
|
+
// The label is NOT reprinted above the body it duplicates.
|
|
113
|
+
assert.deepEqual(card.senders[0].entries, [
|
|
114
|
+
{ kind: 'update', disposition: 'report', body: 'short report body', ref: 'child:reports/short-update.md' },
|
|
95
115
|
]);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
116
|
+
assert.equal(reportPath.endsWith('short-update.md'), true);
|
|
117
|
+
// A report too large to inline (here: absent) keeps its label and its ref.
|
|
118
|
+
const canonical = parseCard({
|
|
119
|
+
role: 'user',
|
|
120
|
+
content: onlyDelivery(coalesce([
|
|
121
|
+
finalizeInboxEntry({ from: 'child', tier: 'normal', kind: 'final', label: 'done', ref: 'child:reports/final.md' }),
|
|
122
|
+
])),
|
|
123
|
+
});
|
|
124
|
+
assert.deepEqual(canonical.senders[0].entries, [
|
|
125
|
+
{ kind: 'final', disposition: 'report', body: 'done', ref: 'child:reports/final.md' },
|
|
104
126
|
]);
|
|
105
|
-
assert.equal(canonical.
|
|
106
|
-
const spilled =
|
|
107
|
-
|
|
127
|
+
assert.equal(canonical.senders[0].finished, true);
|
|
128
|
+
const spilled = parseCard({
|
|
129
|
+
role: 'user',
|
|
130
|
+
content: onlyDelivery(coalesce([
|
|
131
|
+
finalizeInboxEntry({ from: 'child', tier: 'normal', kind: 'message', label: 'long', ref: '/tmp/message.md', data: { body: 'long body' } }),
|
|
132
|
+
])),
|
|
133
|
+
});
|
|
134
|
+
assert.deepEqual(spilled.senders[0].entries, [
|
|
135
|
+
{ kind: 'message', disposition: 'report', body: 'long body', ref: '/tmp/message.md' },
|
|
108
136
|
]);
|
|
109
|
-
assert.doesNotMatch(spilled, /Dereference a ref with/);
|
|
110
137
|
closeDb();
|
|
111
138
|
});
|
|
112
139
|
test('a human message coalesces verbatim — a conversation turn, not a digest', () => {
|
|
113
140
|
// A human typing directly into a node's box is a CONVERSATION TURN, so the
|
|
114
|
-
// dormant wake path must read identically to the live path: no
|
|
115
|
-
//
|
|
141
|
+
// dormant wake path must read identically to the live path: no card, no
|
|
142
|
+
// `<from>` section. Every other sender is digest content.
|
|
116
143
|
const body = 'first line\n\nsecond paragraph';
|
|
117
|
-
const human = coalesce([
|
|
144
|
+
const human = onlyDelivery(coalesce([
|
|
118
145
|
finalizeInboxEntry({ from: 'human', tier: 'normal', kind: 'message', label: 'first line', data: { body } }),
|
|
119
|
-
]);
|
|
146
|
+
]));
|
|
120
147
|
assert.equal(human, body);
|
|
121
|
-
const agent = coalesce([
|
|
148
|
+
const agent = onlyDelivery(coalesce([
|
|
122
149
|
finalizeInboxEntry({ from: 'mqifzplr-0753bde3', tier: 'normal', kind: 'message', label: 'go', data: { body: 'go' } }),
|
|
123
|
-
]);
|
|
124
|
-
assert.
|
|
150
|
+
]));
|
|
151
|
+
assert.equal(agent.startsWith('<runtime kind="inbox"'), true);
|
|
152
|
+
assert.match(agent, /<from id="mqifzplr-0753bde3" updates="1">/);
|
|
153
|
+
});
|
|
154
|
+
test('a mixed batch splits: human words verbatim, node reports in one card, order preserved', () => {
|
|
155
|
+
// Concatenating the two into one message left `parseCard` unable to see the
|
|
156
|
+
// envelope, so the whole delivery read as the person's words.
|
|
157
|
+
const humanEntry = (body) => finalizeInboxEntry({ from: 'human', tier: 'normal', kind: 'message', label: body, data: { body } });
|
|
158
|
+
const nodeEntry = (from, body) => finalizeInboxEntry({ from, tier: 'normal', kind: 'update', label: body, data: { body } });
|
|
159
|
+
for (const [name, entries] of [
|
|
160
|
+
['human first', [humanEntry('do the thing'), nodeEntry('mqifzplr-0753bde3', 'phase one done')]],
|
|
161
|
+
['node first', [nodeEntry('mqifzplr-0753bde3', 'phase one done'), humanEntry('do the thing')]],
|
|
162
|
+
]) {
|
|
163
|
+
const deliveries = coalesce([...entries]);
|
|
164
|
+
assert.equal(deliveries.length, 2, name);
|
|
165
|
+
// The card lands where its first sender fell.
|
|
166
|
+
assert.deepEqual(deliveries.map((d) => d.kind), name === 'human first' ? ['human', 'card'] : ['card', 'human'], name);
|
|
167
|
+
const human = deliveries.find((d) => d.kind === 'human');
|
|
168
|
+
assert.equal(human.text, 'do the thing', name);
|
|
169
|
+
assert.doesNotMatch(human.text, /<runtime|<from /, name);
|
|
170
|
+
const card = deliveries.find((d) => d.kind === 'card');
|
|
171
|
+
const parsed = parseCard({ role: 'user', content: card.text });
|
|
172
|
+
assert.notEqual(parsed, null, name);
|
|
173
|
+
assert.deepEqual(parsed.senders.map((sender) => sender.id), ['mqifzplr-0753bde3'], name);
|
|
174
|
+
assert.deepEqual(card.entries.map((entry) => entry.from), ['mqifzplr-0753bde3'], name);
|
|
175
|
+
}
|
|
125
176
|
});
|
|
126
177
|
let origTick;
|
|
127
178
|
let origDebounce;
|
|
@@ -174,6 +225,22 @@ describe('canvas inbox watcher — finished-node delivery', () => {
|
|
|
174
225
|
assert.equal(pi.injected.length, 1, 'one coalesced injection');
|
|
175
226
|
assert.equal(pi.injected[0].deliverAs, 'steer', 'a finished node steers, not follows up');
|
|
176
227
|
});
|
|
228
|
+
test('a mixed batch reaches pi as separate messages: the person verbatim, the card whole', async () => {
|
|
229
|
+
freshNode('node-mixed');
|
|
230
|
+
const pi = makeFakePi();
|
|
231
|
+
disposers.push(registerWatcher(pi));
|
|
232
|
+
await wait(TICK_MS + 100);
|
|
233
|
+
appendInbox('node-mixed', { from: 'human', tier: 'normal', kind: 'message', label: 'do the thing', data: { body: 'do the thing' } });
|
|
234
|
+
appendInbox('node-mixed', { from: 'child-9', tier: 'normal', kind: 'update', label: 'phase one done' });
|
|
235
|
+
// One visible send per cycle, so the second part lands a tick later.
|
|
236
|
+
await waitFor(() => pi.injected.length >= 2);
|
|
237
|
+
assert.equal(pi.injected.length, 2, 'the batch split into two visible messages');
|
|
238
|
+
assert.equal(pi.injected[0].content, 'do the thing', 'the person\'s words arrive verbatim');
|
|
239
|
+
assert.equal(parseCard({ role: 'user', content: pi.injected[0].content }), null, 'no envelope rides the human message');
|
|
240
|
+
const card = parseCard({ role: 'user', content: pi.injected[1].content });
|
|
241
|
+
assert.notEqual(card, null, 'the card message is a whole envelope');
|
|
242
|
+
assert.deepEqual(card.senders.map((sender) => sender.id), ['child-9']);
|
|
243
|
+
});
|
|
177
244
|
test('mid-stream: a routine update still follows up', async () => {
|
|
178
245
|
freshNode('node-update');
|
|
179
246
|
const pi = makeFakePi();
|
|
@@ -7,15 +7,20 @@ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'no
|
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { createApiServer } from '../../daemon/api/server.js';
|
|
10
|
-
import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket } from '../../daemon/human/finish.js';
|
|
10
|
+
import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket, settleFeedbackCompanion } from '../../daemon/human/finish.js';
|
|
11
11
|
import { humanSend } from '../../commands/human/prompts.js';
|
|
12
12
|
import { getNode, listNodes, closeDb } from '../canvas/index.js';
|
|
13
|
-
import { readInboxSince } from '../feed/inbox.js';
|
|
13
|
+
import { coalesce, readInboxSince } from '../feed/inbox.js';
|
|
14
|
+
import { createPageFeedbackComment } from '../human/feedback.js';
|
|
14
15
|
import { resolveRef } from '../canvas/history.js';
|
|
16
|
+
import { transition } from '../runtime/lifecycle.js';
|
|
17
|
+
import { reviveNode } from '../runtime/revive.js';
|
|
15
18
|
import { apiSocketPath } from '../canvas/paths.js';
|
|
16
19
|
import { spawnNode } from '../runtime/nodes.js';
|
|
17
20
|
import { bindTicketReplyRoute } from '../human/convention.js';
|
|
18
21
|
import { ticketDir } from '../human/root.js';
|
|
22
|
+
import { pageFeedbackPath } from '../human/feedback.js';
|
|
23
|
+
import { parseCard } from '../../shared/generated-context.js';
|
|
19
24
|
import { readTicketResult, submitPage } from '../human/tickets.js';
|
|
20
25
|
let home;
|
|
21
26
|
let priorHome;
|
|
@@ -120,7 +125,7 @@ describe('enqueue is producer-only', () => {
|
|
|
120
125
|
});
|
|
121
126
|
});
|
|
122
127
|
describe('terminal ticket delivery', () => {
|
|
123
|
-
test(
|
|
128
|
+
test("resolvePageTicket pushFinals the person's words once and wakes the subscribed parent", async () => {
|
|
124
129
|
const { parent, bridge } = bridgeUnder();
|
|
125
130
|
const dir = submitBridgePage(bridge);
|
|
126
131
|
const result = await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true } });
|
|
@@ -130,8 +135,34 @@ describe('terminal ticket delivery', () => {
|
|
|
130
135
|
const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
|
|
131
136
|
assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
|
|
132
137
|
const body = reportBody(finals[0].ref);
|
|
133
|
-
assert.
|
|
134
|
-
assert.
|
|
138
|
+
assert.equal(body, 'yes, ship');
|
|
139
|
+
assert.equal(finals[0].disposition, 'human-answer');
|
|
140
|
+
assert.ok(!body.includes(join(dir, 'response.json')));
|
|
141
|
+
});
|
|
142
|
+
test('settlement delivers feedback outside the human-answer entry', async () => {
|
|
143
|
+
const { parent, bridge } = bridgeUnder();
|
|
144
|
+
const dir = submitBridgePage(bridge);
|
|
145
|
+
writeFileSync(pageFeedbackPath(dir), JSON.stringify({
|
|
146
|
+
schema: 'crtr.page-feedback/v1',
|
|
147
|
+
companionNodeId: 'feedback-companion',
|
|
148
|
+
firstCommentedSha256: 'stale-page-hash',
|
|
149
|
+
comments: [],
|
|
150
|
+
}));
|
|
151
|
+
await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true } });
|
|
152
|
+
closeDb();
|
|
153
|
+
const entries = readInboxSince(parent).filter((entry) => entry.from === bridge);
|
|
154
|
+
const final = entries.find((entry) => entry.kind === 'final');
|
|
155
|
+
const feedback = entries.find((entry) => entry.kind === 'update');
|
|
156
|
+
assert.ok(final);
|
|
157
|
+
assert.ok(feedback);
|
|
158
|
+
assert.equal(final.disposition, 'human-answer');
|
|
159
|
+
assert.match(reportBody(feedback.ref), /The page changed during feedback/);
|
|
160
|
+
const deliveries = coalesce(entries);
|
|
161
|
+
assert.equal(deliveries.length, 1);
|
|
162
|
+
const card = parseCard({ role: 'user', content: deliveries[0].text });
|
|
163
|
+
assert.ok(card);
|
|
164
|
+
const answer = card.senders.flatMap((sender) => sender.entries).find((entry) => entry.disposition === 'human-answer');
|
|
165
|
+
assert.deepEqual(answer, { kind: 'final', disposition: 'human-answer', body: 'yes, ship', ref: final.ref });
|
|
135
166
|
});
|
|
136
167
|
test('a replay against a terminal bridge is a no-op', async () => {
|
|
137
168
|
const { parent, bridge } = bridgeUnder();
|
|
@@ -142,6 +173,36 @@ describe('terminal ticket delivery', () => {
|
|
|
142
173
|
const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
|
|
143
174
|
assert.equal(finals.length, 1, 'replay after a terminal bridge pushes nothing further');
|
|
144
175
|
});
|
|
176
|
+
test('approved companions receive one canonical final while dismissed companions remain canceled', async () => {
|
|
177
|
+
const { bridge } = bridgeUnder();
|
|
178
|
+
const dir = submitBridgePage(bridge);
|
|
179
|
+
const companion = spawnNode({ kind: 'general', parent: bridge, cwd: home, name: 'feedback companion', lifecycle: 'resident' }).node_id;
|
|
180
|
+
createPageFeedbackComment(dir, { quote: 'Ship it?', note: 'Confirm the release.', companionNodeId: companion });
|
|
181
|
+
transition(companion, 'finish');
|
|
182
|
+
await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
|
|
183
|
+
const finalized = getNode(companion);
|
|
184
|
+
assert.equal(finalized.status, 'done');
|
|
185
|
+
assert.equal(finalized.intent, 'done');
|
|
186
|
+
assert.ok(finalized.final_report);
|
|
187
|
+
assert.ok(finalized.finalized_at);
|
|
188
|
+
const finalReport = finalized.final_report;
|
|
189
|
+
const finalizedAt = finalized.finalized_at;
|
|
190
|
+
await deliverTerminalResult(bridge);
|
|
191
|
+
assert.equal(getNode(companion)?.final_report, finalReport, 'terminal replay preserves the first canonical final');
|
|
192
|
+
assert.equal(getNode(companion)?.finalized_at, finalizedAt, 'terminal replay preserves the first finalization time');
|
|
193
|
+
assert.throws(() => reviveNode(companion, { resume: true }), /finalization latch/);
|
|
194
|
+
const { bridge: dismissedBridge } = bridgeUnder();
|
|
195
|
+
const dismissedDir = submitBridgePage(dismissedBridge);
|
|
196
|
+
const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
|
|
197
|
+
createPageFeedbackComment(dismissedDir, { quote: 'Ship it?', note: 'Do not ship.', companionNodeId: dismissedCompanion });
|
|
198
|
+
await cancelHumanTicket(dismissedBridge, { reason: 'dismissed', actor: 'human' });
|
|
199
|
+
assert.equal(getNode(dismissedCompanion)?.status, 'canceled');
|
|
200
|
+
assert.equal(getNode(dismissedCompanion)?.final_report, null);
|
|
201
|
+
const dismissedResult = readTicketResult(dismissedDir);
|
|
202
|
+
await settleFeedbackCompanion(dismissedBridge, dismissedResult.kind === 'canceled' ? 'canceled' : 'approved');
|
|
203
|
+
assert.equal(getNode(dismissedCompanion)?.status, 'canceled');
|
|
204
|
+
assert.equal(getNode(dismissedCompanion)?.final_report, null);
|
|
205
|
+
});
|
|
145
206
|
test('cancelHumanTicket finishes the bridge and notifies non-actor subscribers', async () => {
|
|
146
207
|
const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
|
|
147
208
|
const bridge = spawnNode({ kind: 'human', parent: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
|
|
@@ -89,7 +89,8 @@ test('broker welcome snapshot serves the persisted history, not pi\u2019s sliced
|
|
|
89
89
|
const sessionFile = seed.getSessionFile();
|
|
90
90
|
assert.ok(sessionFile, 'seed session persisted a .jsonl');
|
|
91
91
|
// The persisted, ordered history a dormant reader / crouter-web normalizer sees.
|
|
92
|
-
const
|
|
92
|
+
const persistedManager = SessionManager.open(sessionFile);
|
|
93
|
+
const persisted = persistedManager.buildSessionContext().messages;
|
|
93
94
|
assert.equal(persisted.length, 5, 'disk holds all five messages incl. the errored attempt');
|
|
94
95
|
// 2. Revive: the broker resumes the closed session via the SERVICES path.
|
|
95
96
|
const { session } = await buildBrokerSession(realEngine, {
|
|
@@ -113,9 +114,11 @@ test('broker welcome snapshot serves the persisted history, not pi\u2019s sliced
|
|
|
113
114
|
// a turn the .jsonl still holds. The fix serves the persisted history (5),
|
|
114
115
|
// byte-identical to the normalizer.
|
|
115
116
|
const snap = snapshotMessages(session);
|
|
117
|
+
assert.ok(snap);
|
|
116
118
|
assert.equal(snap.length, persisted.length, 'snapshot presents the COMPLETE persisted history (omit-on-revive regression)');
|
|
117
119
|
assert.notEqual(snap.length, session.messages.length, 'snapshot does NOT echo the sliced live array');
|
|
118
|
-
assert.deepEqual(snap, persisted, 'snapshot is byte-identical to the static normalizer\u2019s buildSessionContext (single source of truth)');
|
|
120
|
+
assert.deepEqual(snap.map(({ message }) => message), persisted, 'snapshot is byte-identical to the static normalizer\u2019s buildSessionContext (single source of truth)');
|
|
121
|
+
assert.deepEqual(snap.map(({ id }) => id), persistedManager.getEntries().map(({ id }) => id), 'snapshot ids stay aligned to the persisted entry that projected each message');
|
|
119
122
|
}
|
|
120
123
|
finally {
|
|
121
124
|
session.dispose();
|
|
@@ -486,10 +486,13 @@ test('the real inbox watcher holds a deferred entry alone, then delivers it TOGE
|
|
|
486
486
|
label: 'go now',
|
|
487
487
|
data: { body: 'go now' },
|
|
488
488
|
});
|
|
489
|
-
await wait(SETTLE_MS);
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
assert.
|
|
489
|
+
await wait(SETTLE_MS + TICK_MS * 2);
|
|
490
|
+
// Two direct human entries are two verbatim deliveries — one message per
|
|
491
|
+
// entry, physical order preserved, one visible send per watcher cycle.
|
|
492
|
+
assert.equal(pi.injected.length, 2, 'both entries are delivered once a non-deferred entry joins the batch — each human entry as its own verbatim message');
|
|
493
|
+
assert.match(pi.injected[0].content, /deferred note/, 'the previously-held deferred entry is delivered first — not lost');
|
|
494
|
+
assert.doesNotMatch(pi.injected[0].content, /go now/, 'the deliveries are separate messages, not one concatenation');
|
|
495
|
+
assert.match(pi.injected[1].content, /go now/, 'the new normal entry follows in its own message — not duplicated');
|
|
493
496
|
assert.equal(pi.injected[0].deliverAs, undefined, 'idle delivery triggers a fresh turn, no deliverAs');
|
|
494
497
|
// Idle-path delivery hands off on the void send but only COMMITS the
|
|
495
498
|
// durable cursor at agent_settled (never on the fire-and-forget send
|
|
@@ -59,6 +59,7 @@ import { subscribe } from '../../canvas/canvas.js';
|
|
|
59
59
|
import { transition } from '../../runtime/lifecycle.js';
|
|
60
60
|
import { pushFinal } from '../../feed/feed.js';
|
|
61
61
|
import { evaluateStop, STALL_REPROMPT } from '../../runtime/stop-guard.js';
|
|
62
|
+
import { formatCard } from '../../../shared/generated-context.js';
|
|
62
63
|
import { personaDrift, commitPersonaAck } from '../../runtime/persona.js';
|
|
63
64
|
// A pid reaped (dead) by the time spawnSync returns — a released broker's exited
|
|
64
65
|
// process (the headless analog of a closed pane).
|
|
@@ -228,7 +229,7 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
|
|
|
228
229
|
const stop = evaluateStop(B, { pushedFinal: false, requestedHumanReply: false });
|
|
229
230
|
assert.equal(stop.action, 'reprompt', 'orchestrator B with no live sub, no final → reprompt');
|
|
230
231
|
assert.equal(stop.reason, 'stalled', 'classified stalled');
|
|
231
|
-
assert.equal(stop.action === 'reprompt' ? stop.message : '', STALL_REPROMPT, 'the stall reprompt nudge');
|
|
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
233
|
}
|
|
233
234
|
// ===================================================================
|
|
234
235
|
// S10 — B YIELDS. The real plain `node yield` verb sets intent=refresh
|
|
@@ -121,8 +121,11 @@ test('the ticket resolve route pushFinals the readable answer to the parent', as
|
|
|
121
121
|
assert.equal(getNode(bridge)?.status, 'done', 'bridge finalized by crtrd');
|
|
122
122
|
const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
|
|
123
123
|
assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
|
|
124
|
+
// A typed answer's final is purely the person's words, attributed by the
|
|
125
|
+
// human-answer disposition — no record path, no "Answered …" trailer.
|
|
126
|
+
assert.equal(finals[0].disposition, 'human-answer');
|
|
124
127
|
const body = reportBody(finals[0].ref);
|
|
125
128
|
assert.ok(body.includes('yes, ship'));
|
|
126
|
-
assert.ok(body.includes(join(dir, 'response.json')));
|
|
129
|
+
assert.ok(!body.includes(join(dir, 'response.json')));
|
|
127
130
|
assert.ok(!body.includes('humanloop.response/v2'));
|
|
128
131
|
});
|
|
@@ -39,7 +39,8 @@ import { closeDb } from '../../canvas/db.js';
|
|
|
39
39
|
import { buildPiArgv } from '../../runtime/launch.js';
|
|
40
40
|
import { headlessBrokerHost } from '../../runtime/host.js';
|
|
41
41
|
import { resumeArgs, reviveNode } from '../../runtime/revive.js';
|
|
42
|
-
import {
|
|
42
|
+
import { readGoal, writeGoal } from '../../runtime/kickoff.js';
|
|
43
|
+
import { parseCard } from '../../../shared/generated-context.js';
|
|
43
44
|
import { seedRoadmap } from '../../runtime/roadmap.js';
|
|
44
45
|
import { InputError } from '../../io.js';
|
|
45
46
|
import { createHarness } from '../helpers/harness.js';
|
|
@@ -59,6 +60,10 @@ function node(id, over = {}) {
|
|
|
59
60
|
...over,
|
|
60
61
|
};
|
|
61
62
|
}
|
|
63
|
+
/** A launch prompt is the fresh-revive kickoff when it carries that card kind. */
|
|
64
|
+
function isReviveKickoff(arg) {
|
|
65
|
+
return parseCard({ role: 'user', content: arg })?.kind === 'revive';
|
|
66
|
+
}
|
|
62
67
|
/** A pid that is guaranteed dead — a crashed broker the guard must NOT see as up. */
|
|
63
68
|
function deadPid() {
|
|
64
69
|
const r = spawnSync('true', [], { stdio: 'ignore' });
|
|
@@ -487,7 +492,7 @@ test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', (
|
|
|
487
492
|
createNode(node('bare-unstarted', { cwd: home, pi_session_id: 'uuid-a', pi_session_file: missing }));
|
|
488
493
|
reviveNode('bare-unstarted', { resume: true });
|
|
489
494
|
const bare = launches[0];
|
|
490
|
-
assert.equal(bare.some((a) => a
|
|
495
|
+
assert.equal(bare.some((a) => isReviveKickoff(a)), false, 'an unstarted node is never told it lost context it never had');
|
|
491
496
|
assert.deepEqual(bare, buildPiArgv(getNode('bare-unstarted'), {}).argv, 'a promptless boot — the argv carries no trailing prompt positional at all');
|
|
492
497
|
// (2) Spawned with a mandate whose first turn never persisted: replay it.
|
|
493
498
|
createNode(node('mandate-unstarted', { cwd: home, pi_session_id: 'uuid-b', pi_session_file: missing }));
|
|
@@ -498,7 +503,7 @@ test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', (
|
|
|
498
503
|
createNode(node('progressed', { cwd: home, pi_session_id: 'uuid-c', pi_session_file: missing }));
|
|
499
504
|
seedRoadmap('progressed', { goal: 'ship it' });
|
|
500
505
|
reviveNode('progressed', { resume: true });
|
|
501
|
-
assert.equal(launches[2].at(-1)
|
|
506
|
+
assert.equal(isReviveKickoff(launches[2].at(-1)), true, 'a node with a roadmap still rebuilds from the kickoff');
|
|
502
507
|
}
|
|
503
508
|
finally {
|
|
504
509
|
headlessBrokerHost.launch = originalLaunch;
|
|
@@ -75,15 +75,17 @@ test('cycleAwareMessages: a compacted prior cycle replays via buildSessionContex
|
|
|
75
75
|
// Minimal fake mirroring the real SDK's SessionManager just enough to drive
|
|
76
76
|
// cycleAwareMessages: buildSessionContext() plus the tree accessors it reads.
|
|
77
77
|
const out = cycleAwareMessages(cycleManager(entries, 'm4'));
|
|
78
|
-
|
|
78
|
+
assert.ok(out);
|
|
79
|
+
const summaries = out.filter(({ message }) => message.role === 'compactionSummary');
|
|
79
80
|
assert.equal(summaries.length, 1, 'exactly one compactionSummary message from the replayed prior cycle');
|
|
80
|
-
const texts = out.map((
|
|
81
|
+
const texts = out.map(({ message }) => (typeof message.content === 'string' ? message.content : ''));
|
|
81
82
|
assert.ok(!texts.some((t) => t.includes('pre-compaction message')), 'the pre-compaction raw message (m1) must NOT appear');
|
|
82
83
|
assert.ok(texts.some((t) => t.includes('kept message')), 'the kept message (m2) DOES appear');
|
|
83
84
|
assert.ok(texts.some((t) => t.includes('cycle 0 leaf at yield')), 'the post-compaction message (m3) DOES appear');
|
|
84
85
|
assert.ok(texts.some((t) => t.includes('cycle 1 live message')), 'the live cycle (m4) DOES appear');
|
|
85
|
-
const dividers = out.filter((
|
|
86
|
+
const dividers = out.filter(({ message }) => message.customType === 'crtr-cycle-divider');
|
|
86
87
|
assert.equal(dividers.length, 1, 'one divider between the replayed prior cycle and the live one');
|
|
88
|
+
assert.equal(dividers[0].id, 'cy1~divider', 'the synthesized divider stays tied to its cycle marker');
|
|
87
89
|
});
|
|
88
90
|
test('cycleAwareMessages: a yielded cycle keeps terminal assistant content without an abort notice', () => {
|
|
89
91
|
const m1 = userMsg('m1', null, 'yield after this turn');
|
|
@@ -99,11 +101,12 @@ test('cycleAwareMessages: a yielded cycle keeps terminal assistant content witho
|
|
|
99
101
|
};
|
|
100
102
|
const m3 = userMsg('m3', 'cy1', 'fresh cycle');
|
|
101
103
|
const out = cycleAwareMessages(cycleManager([m1, m2, cy1, m3], 'm3'));
|
|
102
|
-
|
|
104
|
+
assert.ok(out);
|
|
105
|
+
const yieldedAssistant = out.find(({ message }) => message.role === 'assistant').message;
|
|
103
106
|
assert.deepEqual(yieldedAssistant.content, [toolCall], 'the yielded turn remains available to transcript renderers');
|
|
104
107
|
assert.equal(yieldedAssistant.stopReason, undefined, 'yield is not presented as a failed assistant turn');
|
|
105
108
|
assert.equal(yieldedAssistant.errorMessage, undefined, 'the provider abort text is omitted with the yield boundary');
|
|
106
|
-
assert.equal(out.filter((message) => message.customType === 'crtr-cycle-divider').length, 1);
|
|
109
|
+
assert.equal(out.filter(({ message }) => message.customType === 'crtr-cycle-divider').length, 1);
|
|
107
110
|
});
|
|
108
111
|
// Observed shape: the yield's self-abort landed as the NEXT request was
|
|
109
112
|
// starting, so the provider caught a bare AbortError instead of seeing its own
|
|
@@ -123,7 +126,8 @@ test('cycleAwareMessages: a yield abort recorded as a provider error is still no
|
|
|
123
126
|
};
|
|
124
127
|
const m3 = userMsg('m3', 'cy1', 'fresh cycle');
|
|
125
128
|
const out = cycleAwareMessages(cycleManager([m1, m2, cy1, m3], 'm3'));
|
|
126
|
-
|
|
129
|
+
assert.ok(out);
|
|
130
|
+
const yieldedAssistant = out.find(({ message }) => message.role === 'assistant').message;
|
|
127
131
|
assert.equal(yieldedAssistant.stopReason, undefined, 'yield is not presented as a failed assistant turn');
|
|
128
132
|
assert.equal(yieldedAssistant.errorMessage, undefined, 'the aborted-request text is omitted with the yield boundary');
|
|
129
133
|
});
|
|
@@ -20,6 +20,7 @@ import { jobDir, contextDir } from './paths.js';
|
|
|
20
20
|
import { isPidAlive } from './pid.js';
|
|
21
21
|
import { resolveNodeVisual, faultSummary } from './status-glyph.js';
|
|
22
22
|
import { readFault } from '../runtime/fault.js';
|
|
23
|
+
import { isLegacyRuntimeText, parseCard } from '../../shared/generated-context.js';
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
24
25
|
// Active-fault overlay: stale daemon-owned provider faults are hidden once the
|
|
25
26
|
// broker pid is gone; manual/client faults stay visible until cleared.
|
|
@@ -191,6 +192,8 @@ export function readSessionParts(sessionFile) {
|
|
|
191
192
|
const text = extractUserText(rec.message.content);
|
|
192
193
|
if (text === '')
|
|
193
194
|
continue;
|
|
195
|
+
if (parseCard({ role: 'user', content: text }) !== null || isLegacyRuntimeText(text))
|
|
196
|
+
continue;
|
|
194
197
|
const capped = text.length > CONVO_MSG_CAP ? text.slice(0, CONVO_MSG_CAP) : text;
|
|
195
198
|
parts.push(capped);
|
|
196
199
|
total += capped.length + 1;
|
package/dist/core/feed/feed.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import type { InboxDisposition } from './inbox.js';
|
|
1
2
|
export type PushKind = 'update' | 'urgent' | 'final';
|
|
2
3
|
export interface PushOpts {
|
|
3
4
|
kind: PushKind;
|
|
4
5
|
body: string;
|
|
5
6
|
from?: string;
|
|
7
|
+
/** Attribution snapshotted by the human-ticket settlement paths; absent means an ordinary report. */
|
|
8
|
+
disposition?: InboxDisposition;
|
|
6
9
|
}
|
|
7
10
|
export interface PushResult {
|
|
8
11
|
reportPath: string;
|
|
@@ -28,6 +31,9 @@ export declare function pushUrgent(nodeId: string, body: string, opts?: {
|
|
|
28
31
|
}): Promise<PushResult>;
|
|
29
32
|
export declare function pushFinal(nodeId: string, body: string, opts?: {
|
|
30
33
|
from?: string;
|
|
34
|
+
disposition?: InboxDisposition;
|
|
31
35
|
}): Promise<PushResult>;
|
|
32
36
|
/** Re-fan a previously committed final to its ticket origin without finalizing again. */
|
|
33
|
-
export declare function redeliverFinalReport(nodeId: string, originNodeId: string, reportBasename: string, body: string
|
|
37
|
+
export declare function redeliverFinalReport(nodeId: string, originNodeId: string, reportBasename: string, body: string, opts?: {
|
|
38
|
+
disposition?: InboxDisposition;
|
|
39
|
+
}): PushResult;
|