@nanobpm/nano-workforce 0.185.0 → 0.186.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.186.0](https://github.com/nanobpm/nano-workforce/compare/v0.185.1...v0.186.0) (2026-09-08)
2
+
3
+ ### Features
4
+
5
+ * **cockpit:** render coherent streaming message blocks in order ([#766](https://github.com/nanobpm/nano-workforce/issues/766)) ([b977927](https://github.com/nanobpm/nano-workforce/commit/b977927df9b3c264d2a34fe450180e168bcbd4b0)), closes [#566](https://github.com/nanobpm/nano-workforce/issues/566) [#757](https://github.com/nanobpm/nano-workforce/issues/757)
6
+
7
+ ## [0.185.1](https://github.com/nanobpm/nano-workforce/compare/v0.185.0...v0.185.1) (2026-09-08)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **agentic:** capability probe recognises single-package v-prefixed release tags ([#765](https://github.com/nanobpm/nano-workforce/issues/765)) ([79faa67](https://github.com/nanobpm/nano-workforce/commit/79faa676f46882d4e10f3ce0bb70080f40e3e757)), closes [#764](https://github.com/nanobpm/nano-workforce/issues/764)
12
+
1
13
  ## [0.185.0](https://github.com/nanobpm/nano-workforce/compare/v0.184.1...v0.185.0) (2026-09-08)
2
14
 
3
15
  ### Features
@@ -77,14 +77,23 @@ test("regression: a nwfTranscriptEvent chunk is rendered as derived cards, NEVER
77
77
  assert(!host.text().includes(TRANSCRIPT_EVENT_MARKER), "rendered transcript must not contain the raw nwfTranscriptEvent marker");
78
78
  });
79
79
 
80
- test("feature: messages coalesce into one turn, tool/diff card renders, permission prompt renders", () => {
80
+ test("feature: split-fragment messages coalesce into one block, chronological tool/diff + permission order (#757)", () => {
81
81
  const host = new FakeElement("div");
82
82
  renderDerivedTranscript(host as never, doc, report() as never);
83
83
 
84
- // Message coalescing: both the user and assistant messages fold under a SINGLE derived turn section.
85
- const turns = host.byClass("cockpit-transcript-turn");
86
- assertEquals(turns.length, 1);
87
- const roles = turns[0]?.byClass("cockpit-transcript-message").map((n) => n.getAttribute("data-role")) ?? [];
84
+ // NO per-delta cards and NO turn-section wrapper: the ordered display renders one growing text block
85
+ // per logical message, interleaved in chronological (offset) order — the #757 fix.
86
+ assertEquals(host.byClass("cockpit-transcript-turn").length, 0);
87
+ const blocksHost = host.byClass("cockpit-transcript-blocks")[0];
88
+ assert(blocksHost !== undefined, "the ordered blocks container is rendered");
89
+ const order = blocksHost.children.map((n) => n.className);
90
+ assertEquals(order, [
91
+ "cockpit-transcript-message", // user: "please build it"
92
+ "cockpit-transcript-tool", // the edit tool/diff card issued mid-conversation
93
+ "cockpit-transcript-message", // assistant: "built it"
94
+ "cockpit-transcript-permission", // the escalate prompt
95
+ ]);
96
+ const roles = host.byClass("cockpit-transcript-message").map((n) => n.getAttribute("data-role"));
88
97
  assertEquals(roles, ["user", "assistant"]);
89
98
 
90
99
  // Tool card with a synthesized diff (structured edit args → add/del lines).
@@ -0,0 +1,231 @@
1
+ // #757 — the cockpit renders ONE coherent, growing message block per logical message, with tool cards
2
+ // and permission prompts interleaved in chronological order — driven through the REAL published display
3
+ // projection (agentic #566) and the REAL codec (`encodeTranscriptEvent`), never a hand-invented mock.
4
+ //
5
+ // These are the red-first acceptance tests for the streaming-block render: they prove
6
+ // - split-word deltas reconstruct byte-exact into ONE block (never one bordered card per delta),
7
+ // - distinct messages stay distinct when producer metadata (messageId) exists,
8
+ // - text / tool / permission order is chronological (a tool issued mid-conversation renders between),
9
+ // - the legacy fallback (no boundary metadata) coalesces adjacent same-speaker deltas,
10
+ // - a snapshot REPLACES (never doubles) accumulated text,
11
+ // - replay / reconnect / duplicate offsets never double text (idempotent on offset),
12
+ // - a retention gap stays a visible break that prevents false continuity,
13
+ // - live incremental growth patches the SAME node in place (selection/expansion survive), and
14
+ // - historical batch rendering is identical to the final live rendering.
15
+ import { test } from "node:test";
16
+ import { assert, assertEquals } from "#test-assert";
17
+ import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
18
+ import { type TranscriptEvent, encodeTranscriptEvent } from "../transcript-events.ts";
19
+ import { createIncrementalTranscript, renderDerivedTranscript } from "./transcript-derive.ts";
20
+ import type { TranscriptDataReport } from "./transcript-render.ts";
21
+
22
+ const doc = new FakeDocument();
23
+
24
+ /** Encode a typed event through the REAL published codec — the exact wire form a structured producer
25
+ * appends (the inverse of the one parser). Using it here is the integration guarantee: these fixtures
26
+ * ride the real @nanobpm/agentic envelope grammar, not a hand-rolled marker. */
27
+ function enc(event: TranscriptEvent): { offset: number; chunk: string } {
28
+ return { offset: event.offset, chunk: encodeTranscriptEvent(event) };
29
+ }
30
+
31
+ /** A single-page report over the given stored chunks (offsets already assigned). */
32
+ function report(entries: Array<{ offset: number; chunk: string }>): TranscriptDataReport {
33
+ return { stream: "job:757", from: 0, gap: false, nextOffset: entries.length, entries };
34
+ }
35
+
36
+ /** The ordered block class names under the blocks container — the human-facing top-to-bottom sequence. */
37
+ function blockOrder(host: FakeElement): string[] {
38
+ return host.byClass("cockpit-transcript-blocks")[0]?.children.map((n) => n.className) ?? [];
39
+ }
40
+
41
+ /** The text-block nodes (one per logical message) with their reconstructed text + role. */
42
+ function messages(host: FakeElement): Array<{ role: string | undefined; text: string }> {
43
+ return host.byClass("cockpit-transcript-message").map((n) => ({ role: n.getAttribute("data-role"), text: n.textContent ?? "" }));
44
+ }
45
+
46
+ test("#757 split-word deltas reconstruct byte-exact into ONE block — never one card per delta", () => {
47
+ // The screenshot-equivalent failure: a single logical message arrives as fragments that spell words
48
+ // ("No", "tice", " I am ", …). The bug rendered one bordered card per fragment; the fix coalesces.
49
+ const deltas = ["No", "tice", " I am ", "act", "ually", " here."];
50
+ const entries = deltas.map((text, i) => enc({ kind: "message", offset: i, role: "assistant", messageId: "m1", text }));
51
+ const host = new FakeElement("div");
52
+ renderDerivedTranscript(host, doc, report(entries));
53
+
54
+ const msgs = messages(host);
55
+ assertEquals(msgs.length, 1, "the six fragments fold into exactly ONE message block, not six cards");
56
+ assertEquals(msgs[0]?.text, "Notice I am actually here.");
57
+ assertEquals(msgs[0]?.role, "assistant");
58
+ });
59
+
60
+ test("#757 distinct messages with distinct messageId stay separate blocks", () => {
61
+ const entries = [
62
+ enc({ kind: "message", offset: 0, role: "assistant", messageId: "m1", text: "hello " }),
63
+ enc({ kind: "message", offset: 1, role: "assistant", messageId: "m1", text: "world" }),
64
+ enc({ kind: "message", offset: 2, role: "assistant", messageId: "m2", text: "second message" }),
65
+ ];
66
+ const host = new FakeElement("div");
67
+ renderDerivedTranscript(host, doc, report(entries));
68
+ assertEquals(
69
+ messages(host).map((m) => m.text),
70
+ ["hello world", "second message"],
71
+ );
72
+ });
73
+
74
+ test("#757 chronological order: a tool issued mid-conversation renders BETWEEN the text before and after", () => {
75
+ const entries = [
76
+ enc({ kind: "message", offset: 0, role: "assistant", messageId: "m1", text: "let me look" }),
77
+ enc({ kind: "tool-call", offset: 1, name: "grep", callId: "c1", args: { pattern: "x" } }),
78
+ enc({ kind: "tool-result", offset: 2, callId: "c1", ok: true, content: "hit" }),
79
+ enc({ kind: "message", offset: 3, role: "assistant", messageId: "m2", text: "found it" }),
80
+ enc({
81
+ kind: "permission",
82
+ offset: 4,
83
+ phase: "request",
84
+ callId: "p1",
85
+ policy: "escalate",
86
+ options: [{ optionId: "ok", name: "Allow", kind: "allow-once" }],
87
+ }),
88
+ ];
89
+ const host = new FakeElement("div");
90
+ renderDerivedTranscript(host, doc, report(entries));
91
+ assertEquals(blockOrder(host), [
92
+ "cockpit-transcript-message",
93
+ "cockpit-transcript-tool",
94
+ "cockpit-transcript-message",
95
+ "cockpit-transcript-permission",
96
+ ]);
97
+ assertEquals(
98
+ messages(host).map((m) => m.text),
99
+ ["let me look", "found it"],
100
+ );
101
+ });
102
+
103
+ test("#757 legacy fallback: no messageId/mode — adjacent same-speaker deltas coalesce, a tool boundary splits", () => {
104
+ const entries = [
105
+ enc({ kind: "message", offset: 0, role: "assistant", text: "a" }),
106
+ enc({ kind: "message", offset: 1, role: "assistant", text: "b" }),
107
+ enc({ kind: "tool-call", offset: 2, name: "read", callId: "c1" }),
108
+ enc({ kind: "message", offset: 3, role: "assistant", text: "c" }),
109
+ ];
110
+ const host = new FakeElement("div");
111
+ renderDerivedTranscript(host, doc, report(entries));
112
+ assertEquals(
113
+ messages(host).map((m) => m.text),
114
+ ["ab", "c"],
115
+ );
116
+ assertEquals(blockOrder(host), ["cockpit-transcript-message", "cockpit-transcript-tool", "cockpit-transcript-message"]);
117
+ });
118
+
119
+ test("#757 a snapshot REPLACES accumulated text (never doubled as a delta)", () => {
120
+ const entries = [
121
+ enc({ kind: "message", offset: 0, role: "assistant", messageId: "m1", text: "Hel" }),
122
+ enc({ kind: "message", offset: 1, role: "assistant", messageId: "m1", mode: "snapshot", text: "Hello world" }),
123
+ ];
124
+ const host = new FakeElement("div");
125
+ renderDerivedTranscript(host, doc, report(entries));
126
+ assertEquals(messages(host)[0]?.text, "Hello world");
127
+ });
128
+
129
+ test("#757 replay/duplicate: re-feeding already-applied offsets never doubles text (idempotent on offset)", () => {
130
+ const host = new FakeElement("div");
131
+ const t = createIncrementalTranscript(host, doc, "job:757");
132
+ const deltas = [
133
+ enc({ kind: "message", offset: 0, role: "assistant", messageId: "m1", text: "one " }),
134
+ enc({ kind: "message", offset: 1, role: "assistant", messageId: "m1", text: "two" }),
135
+ ];
136
+ for (const d of deltas) t.applyChunk(d);
137
+ // A reconnect re-delivers the same offsets (pagination overlap): applying them again is a no-op.
138
+ for (const d of deltas) t.applyChunk(d);
139
+ assertEquals(messages(host).length, 1);
140
+ assertEquals(messages(host)[0]?.text, "one two");
141
+ });
142
+
143
+ test("#757 a retention gap stays a VISIBLE break and prevents false continuity across the drop", () => {
144
+ const host = new FakeElement("div");
145
+ const t = createIncrementalTranscript(host, doc, "job:757");
146
+ t.applyChunk(enc({ kind: "message", offset: 0, role: "assistant", messageId: "m1", text: "before the gap" }));
147
+ // The consumer resumed from an offset older than the oldest retained chunk — chunks were evicted.
148
+ t.noteGap();
149
+ t.applyChunk(enc({ kind: "message", offset: 10, role: "assistant", messageId: "m2", text: "after the gap" }));
150
+
151
+ assertEquals(blockOrder(host), ["cockpit-transcript-message", "cockpit-transcript-gap", "cockpit-transcript-message"]);
152
+ const gapNode = host.byData("gap", "true")[0];
153
+ assert(gapNode !== undefined, "the retention gap renders a visible break");
154
+ // The gap is anchored to the first post-gap block, so the break is placed, not implied-continuous.
155
+ assertEquals(gapNode?.getAttribute("data-before-offset"), "10");
156
+ // The text on either side did NOT merge into one block.
157
+ assertEquals(
158
+ messages(host).map((m) => m.text),
159
+ ["before the gap", "after the gap"],
160
+ );
161
+ });
162
+
163
+ test("#757 live growth patches the SAME node in place — unaffected sibling nodes are never replaced", () => {
164
+ const host = new FakeElement("div");
165
+ const t = createIncrementalTranscript(host, doc, "job:757");
166
+ t.applyChunk(enc({ kind: "message", offset: 0, role: "assistant", messageId: "m1", text: "Hel" }));
167
+ const blocksHost = host.byClass("cockpit-transcript-blocks")[0];
168
+ const textNode = blocksHost?.children[0];
169
+ assert(textNode !== undefined, "the first delta created the text node");
170
+
171
+ // A further delta of the SAME message grows the SAME node in place (identity preserved) — this is what
172
+ // lets the browser keep the operator's selection/expansion state across streaming.
173
+ t.applyChunk(enc({ kind: "message", offset: 1, role: "assistant", messageId: "m1", text: "lo there" }));
174
+ assert(blocksHost?.children[0] === textNode, "the delta grew the SAME node, it was not replaced");
175
+ assertEquals(textNode?.textContent, "Hello there");
176
+ assertEquals(blocksHost?.children.length, 1, "no second card was appended for the same message");
177
+
178
+ // A later tool call appends a NEW sibling and leaves the original text node object untouched.
179
+ t.applyChunk(enc({ kind: "tool-call", offset: 2, name: "grep", callId: "c1" }));
180
+ assert(blocksHost?.children[0] === textNode, "appending the tool card did not replace the text node");
181
+ assertEquals(textNode?.textContent, "Hello there");
182
+ assertEquals(blocksHost?.children.length, 2);
183
+ });
184
+
185
+ test("#757 a tool result settles the EXISTING tool card in place (no duplicate card)", () => {
186
+ const host = new FakeElement("div");
187
+ const t = createIncrementalTranscript(host, doc, "job:757");
188
+ t.applyChunk(enc({ kind: "tool-call", offset: 0, name: "grep", callId: "c1" }));
189
+ const toolNode = host.byData("tool", "grep")[0];
190
+ assertEquals(toolNode?.getAttribute("data-status"), "pending");
191
+ t.applyChunk(enc({ kind: "tool-result", offset: 1, callId: "c1", ok: true, content: "match" }));
192
+ assertEquals(host.byData("tool", "grep").length, 1, "the result settled the same card — no second card");
193
+ assertEquals(host.byData("tool", "grep")[0]?.getAttribute("data-status"), "ok");
194
+ });
195
+
196
+ test("#757 historical batch render is identical to the final live incremental render", () => {
197
+ const entries = [
198
+ enc({ kind: "message", offset: 0, role: "user", messageId: "u1", text: "please " }),
199
+ enc({ kind: "message", offset: 1, role: "user", messageId: "u1", text: "build it" }),
200
+ enc({ kind: "tool-call", offset: 2, name: "edit", callId: "c1", args: { path: "a.txt", oldText: "one", newText: "two" } }),
201
+ enc({ kind: "tool-result", offset: 3, callId: "c1", ok: true, content: "ok" }),
202
+ enc({ kind: "message", offset: 4, role: "assistant", messageId: "a1", text: "built " }),
203
+ enc({ kind: "message", offset: 5, role: "assistant", messageId: "a1", text: "it" }),
204
+ ];
205
+
206
+ // Historical: one batch render of the whole fetched page.
207
+ const past = new FakeElement("div");
208
+ renderDerivedTranscript(past, doc, report(entries));
209
+
210
+ // Live: the same chunks fed one at a time (as a relay stream) through the incremental renderer.
211
+ const live = new FakeElement("div");
212
+ const t = createIncrementalTranscript(live, doc, "job:757");
213
+ for (const e of entries) t.applyChunk(e);
214
+
215
+ assertEquals(blockOrder(live), blockOrder(past));
216
+ assertEquals(messages(live), messages(past));
217
+ assertEquals(live.text(), past.text(), "final live rendering matches historical rendering exactly");
218
+ });
219
+
220
+ test("#757 an all-raw (unstructured) transcript shows the empty state, and a later block clears it", () => {
221
+ const host = new FakeElement("div");
222
+ const t = createIncrementalTranscript(host, doc, "job:757");
223
+ t.applyChunk({ offset: 0, chunk: "\u001b[2Jraw terminal frame" });
224
+ assertEquals(host.byData("empty", "true").length, 1, "an all-raw page shows the empty note");
225
+ assertEquals(host.byClass("cockpit-transcript-raw")[0]?.getAttribute("data-raw-chunks"), "1");
226
+
227
+ // A structured block arriving later clears the empty note WITHOUT rebuilding (toggled, not removed).
228
+ t.applyChunk(enc({ kind: "message", offset: 1, role: "assistant", text: "now structured" }));
229
+ assertEquals(host.byData("empty", "true").length, 0, "the empty note is cleared once a block renders");
230
+ assertEquals(messages(host)[0]?.text, "now structured");
231
+ });