@genesislcap/ai-assistant 15.2.0 → 15.3.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.
@@ -39,13 +39,21 @@ export interface DebugLog {
39
39
  meta: unknown;
40
40
  }
41
41
  /**
42
- * Stable identity for a message diagnostic entry — `timestamp::role::subAgentOf`.
42
+ * Stable identity for a message diagnostic entry — `timestamp::role::subAgentOf::category`.
43
43
  * Used by the forward-capture delta (skip already-emitted messages) and by the
44
44
  * restore watermark (skip messages already present in a prior load's stream).
45
45
  * `null` when the entry has no timestamp → treat as always-new. Shared so the
46
46
  * element's diagnostics capture and the `SessionPersister`'s restore watermark
47
47
  * compute identity identically (GENC-1351 §5.8/§5.10).
48
48
  *
49
+ * `category` is part of the key because the reasoning/narration/answer split (GENC-1411) emits
50
+ * SEVERAL messages from one model response — the thought summary, any interstitial narration, and
51
+ * the answer/tool-call message — which share `timestamp` + `role` + `subAgentOf`. Keying on those
52
+ * alone collapsed them to one, so the forward-capture dropped all but the first: a server
53
+ * generation's answer message vanished from the persisted debug log while its reasoning survived
54
+ * (GENC-1461). `category` is stable across a message's in-place annotation (only `toolCalls` change,
55
+ * which is deliberately excluded), so a genuine re-append of the same message still dedups.
56
+ *
49
57
  * @internal
50
58
  */
51
59
  export declare function diagnosticsMessageKey(entry: DiagnosticEntry): string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"diagnostics.d.ts","sourceRoot":"","sources":["../../../../src/state/persistence/diagnostics.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,eAAe,CAAC;IACrD,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,mFAAmF;AACnF,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAI3E;AASD;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,eAAe,EAAE,EAAE,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,QAAQ,CA+BhG"}
1
+ {"version":3,"file":"diagnostics.d.ts","sourceRoot":"","sources":["../../../../src/state/persistence/diagnostics.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,eAAe,CAAC;IACrD,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,mFAAmF;AACnF,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAI3E;AASD;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,eAAe,EAAE,EAAE,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,QAAQ,CA+BhG"}
@@ -165,6 +165,18 @@ export declare class SessionPersister {
165
165
  * (§5.8). Skips messages present at restore or already appended; emits each turn
166
166
  * (by turn-index) and meta-event (by index) once; appends a `meta-snapshot` only
167
167
  * when the block changed. Advances the shared per-session cursors as it goes.
168
+ *
169
+ * TODO(GENC-1461, longer-term): this whole delta step exists only because the element *pulls* the
170
+ * ENTIRE debug log on every throttled flush (`getDiagnosticEntries()` returns the full timeline),
171
+ * so we must diff it against per-session cursors to append only the new tail — and messages, unlike
172
+ * events (monotonic `index`) and turns (`turnIndex`), have no stable positional cursor (compaction
173
+ * rewrites ranges, tool-call messages are annotated in place, synthetic messages are spliced in,
174
+ * restore reloads them), forcing a content-derived identity (`diagnosticsMessageKey`). That
175
+ * identity is inherently fragile: it was too coarse for the reasoning/answer/tool-call split and
176
+ * silently dropped co-timestamped messages (GENC-1461; fixed for now by adding `category`). The
177
+ * robust fix is to invert the flow — have the driver *push* each diagnostic entry exactly once at
178
+ * creation (an append-only event stream) instead of the element pulling-and-diffing — which removes
179
+ * the message-identity key and this dedup entirely. Bigger change; deferred.
168
180
  */
169
181
  private collectDiagnosticsDelta;
170
182
  /** Cancel any pending timers. Called on explicit session teardown. */
@@ -1 +1 @@
1
- {"version":3,"file":"session-persister.d.ts","sourceRoot":"","sources":["../../../../src/state/persistence/session-persister.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sCAAsC,CAAC;AAWrE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAE5E,OAAO,KAAK,EAAE,wBAAwB,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAsCnG;;;;;;;GAOG;AACH,MAAM,WAAW,oBAAoB;IACnC,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,oFAAoF;IACpF,cAAc,EAAE,MAAM,wBAAwB,CAAC;IAC/C,+EAA+E;IAC/E,aAAa,EAAE,MAAM,MAAM,EAAE,CAAC;IAC9B,oEAAoE;IACpE,SAAS,EAAE,MAAM,QAAQ,GAAG,SAAS,CAAC;IACtC,mEAAmE;IACnE,QAAQ,EAAE,MAAM,kBAAkB,GAAG,SAAS,CAAC;IAC/C;;;;OAIG;IACH,oBAAoB,EAAE,MAAM,eAAe,EAAE,CAAC;CAC/C;AAED;;;;;;;;;;;;;;GAcG;AACH,qBAAa,gBAAgB;IA0Bf,OAAO,CAAC,IAAI;IAzBxB,OAAO,CAAC,UAAU,CAAC,CAAgC;IACnD,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,iBAAiB,CAAC,CAAgC;IAC1D,OAAO,CAAC,kBAAkB,CAAK;IAC/B;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAA+B;IAC7C;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB,CAAmC;IAC7D;;;;;OAKG;IACH,OAAO,CAAC,uBAAuB,CAAS;gBAEpB,IAAI,EAAE,oBAAoB;IAE9C;;;;;;;;;;OAUG;IACH,UAAU,CAAC,IAAI,EAAE,oBAAoB,GAAG,IAAI;IAI5C,0FAA0F;IAC1F,IAAI,iBAAiB,IAAI,kBAAkB,GAAG,IAAI,CAEjD;IAED,uFAAuF;IACvF,IAAI,sBAAsB,IAAI,OAAO,CAEpC;IAED,OAAO,KAAK,QAAQ,GAEnB;IAED,OAAO,CAAC,OAAO;IAIf;;;;;OAKG;IACH,YAAY,IAAI,IAAI;IAapB;;;;;OAKG;IACH,iBAAiB,IAAI,IAAI;IAOzB;;;;OAIG;YACW,cAAc;IAsJ5B;;;;;;OAMG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAoK9B;;;;;;;OAOG;IACH,OAAO,CAAC,uBAAuB;IAU/B;;;;OAIG;IACG,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAiBvC;;;;OAIG;IACG,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAatC;;;;;OAKG;IACH,eAAe,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI;IAchD;;;;;;OAMG;IACH,mBAAmB,IAAI,IAAI;IAiB3B;;;;;;OAMG;IACG,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAYvC;;;;;OAKG;IACH,OAAO,CAAC,uBAAuB;IAwC/B,sEAAsE;IACtE,OAAO,IAAI,IAAI;CAMhB"}
1
+ {"version":3,"file":"session-persister.d.ts","sourceRoot":"","sources":["../../../../src/state/persistence/session-persister.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sCAAsC,CAAC;AAWrE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAE5E,OAAO,KAAK,EAAE,wBAAwB,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAsCnG;;;;;;;GAOG;AACH,MAAM,WAAW,oBAAoB;IACnC,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,oFAAoF;IACpF,cAAc,EAAE,MAAM,wBAAwB,CAAC;IAC/C,+EAA+E;IAC/E,aAAa,EAAE,MAAM,MAAM,EAAE,CAAC;IAC9B,oEAAoE;IACpE,SAAS,EAAE,MAAM,QAAQ,GAAG,SAAS,CAAC;IACtC,mEAAmE;IACnE,QAAQ,EAAE,MAAM,kBAAkB,GAAG,SAAS,CAAC;IAC/C;;;;OAIG;IACH,oBAAoB,EAAE,MAAM,eAAe,EAAE,CAAC;CAC/C;AAED;;;;;;;;;;;;;;GAcG;AACH,qBAAa,gBAAgB;IA0Bf,OAAO,CAAC,IAAI;IAzBxB,OAAO,CAAC,UAAU,CAAC,CAAgC;IACnD,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,iBAAiB,CAAC,CAAgC;IAC1D,OAAO,CAAC,kBAAkB,CAAK;IAC/B;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAA+B;IAC7C;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB,CAAmC;IAC7D;;;;;OAKG;IACH,OAAO,CAAC,uBAAuB,CAAS;gBAEpB,IAAI,EAAE,oBAAoB;IAE9C;;;;;;;;;;OAUG;IACH,UAAU,CAAC,IAAI,EAAE,oBAAoB,GAAG,IAAI;IAI5C,0FAA0F;IAC1F,IAAI,iBAAiB,IAAI,kBAAkB,GAAG,IAAI,CAEjD;IAED,uFAAuF;IACvF,IAAI,sBAAsB,IAAI,OAAO,CAEpC;IAED,OAAO,KAAK,QAAQ,GAEnB;IAED,OAAO,CAAC,OAAO;IAIf;;;;;OAKG;IACH,YAAY,IAAI,IAAI;IAapB;;;;;OAKG;IACH,iBAAiB,IAAI,IAAI;IAOzB;;;;OAIG;YACW,cAAc;IAsJ5B;;;;;;OAMG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAoK9B;;;;;;;OAOG;IACH,OAAO,CAAC,uBAAuB;IAU/B;;;;OAIG;IACG,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAiBvC;;;;OAIG;IACG,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAatC;;;;;OAKG;IACH,eAAe,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI;IAchD;;;;;;OAMG;IACH,mBAAmB,IAAI,IAAI;IAiB3B;;;;;;OAMG;IACG,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAYvC;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,uBAAuB;IAwC/B,sEAAsE;IACtE,OAAO,IAAI,IAAI;CAMhB"}
@@ -8,19 +8,27 @@
8
8
  * @packageDocumentation
9
9
  */
10
10
  /**
11
- * Stable identity for a message diagnostic entry — `timestamp::role::subAgentOf`.
11
+ * Stable identity for a message diagnostic entry — `timestamp::role::subAgentOf::category`.
12
12
  * Used by the forward-capture delta (skip already-emitted messages) and by the
13
13
  * restore watermark (skip messages already present in a prior load's stream).
14
14
  * `null` when the entry has no timestamp → treat as always-new. Shared so the
15
15
  * element's diagnostics capture and the `SessionPersister`'s restore watermark
16
16
  * compute identity identically (GENC-1351 §5.8/§5.10).
17
17
  *
18
+ * `category` is part of the key because the reasoning/narration/answer split (GENC-1411) emits
19
+ * SEVERAL messages from one model response — the thought summary, any interstitial narration, and
20
+ * the answer/tool-call message — which share `timestamp` + `role` + `subAgentOf`. Keying on those
21
+ * alone collapsed them to one, so the forward-capture dropped all but the first: a server
22
+ * generation's answer message vanished from the persisted debug log while its reasoning survived
23
+ * (GENC-1461). `category` is stable across a message's in-place annotation (only `toolCalls` change,
24
+ * which is deliberately excluded), so a genuine re-append of the same message still dedups.
25
+ *
18
26
  * @internal
19
27
  */
20
28
  export function diagnosticsMessageKey(entry) {
21
- var _a, _b;
29
+ var _a, _b, _c;
22
30
  return typeof entry.timestamp === 'string'
23
- ? `${entry.timestamp}::${(_a = entry.role) !== null && _a !== void 0 ? _a : ''}::${(_b = entry.subAgentOf) !== null && _b !== void 0 ? _b : ''}`
31
+ ? `${entry.timestamp}::${(_a = entry.role) !== null && _a !== void 0 ? _a : ''}::${(_b = entry.subAgentOf) !== null && _b !== void 0 ? _b : ''}::${(_c = entry.category) !== null && _c !== void 0 ? _c : ''}`
24
32
  : null;
25
33
  }
26
34
  // Tie-break co-timestamped entries by cause → call → output (event, turn, message),
@@ -1,5 +1,5 @@
1
1
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
- import { assembleDebugLog } from './diagnostics';
2
+ import { assembleDebugLog, diagnosticsMessageKey } from './diagnostics';
3
3
  // GENC-1351 §5.8: `assembleDebugLog` reassembles the forward-only diagnostics
4
4
  // stream back into the `{ readme, timeline, meta }` debug-log shape — timeline
5
5
  // sorted by timestamp, meta-snapshots pulled out with the latest winning.
@@ -70,3 +70,38 @@ Suite('collapses exact-duplicate entries but keeps genuinely-distinct ones', ()
70
70
  assert.is(timeline.filter((e) => e.type === 'assistant.connected').length, 2, 'two distinct connects (different index) kept; the repeated connA collapsed');
71
71
  });
72
72
  Suite.run();
73
+ // GENC-1461: the reasoning/narration/answer split (GENC-1411) emits several messages from ONE model
74
+ // response, so they share `timestamp` + `role` + `subAgentOf`. The message identity key must still
75
+ // tell them apart — otherwise the forward-capture dedup treats them as one and drops all but the
76
+ // first (a server generation's answer message vanished from the persisted download while its
77
+ // reasoning survived).
78
+ const KeySuite = createLogicSuite('diagnosticsMessageKey');
79
+ KeySuite('gives co-timestamped split messages (reasoning vs answer) distinct keys', () => {
80
+ const base = {
81
+ kind: 'message',
82
+ role: 'assistant',
83
+ timestamp: '2026-01-01T00:00:05.000Z',
84
+ };
85
+ const reasoning = Object.assign(Object.assign({}, base), { category: 'reasoning', content: 'thinking…' });
86
+ const answer = Object.assign(Object.assign({}, base), { content: 'the generated code' }); // no category = the answer
87
+ const kReasoning = diagnosticsMessageKey(reasoning);
88
+ const kAnswer = diagnosticsMessageKey(answer);
89
+ assert.ok(kReasoning, 'reasoning message has a key');
90
+ assert.ok(kAnswer, 'answer message has a key');
91
+ assert.not.equal(kReasoning, kAnswer, 'a reasoning and an answer split from one response must not collide');
92
+ });
93
+ KeySuite('still collapses a genuine re-append of the same message (same key)', () => {
94
+ const m = {
95
+ kind: 'message',
96
+ role: 'assistant',
97
+ timestamp: '2026-01-01T00:00:05.000Z',
98
+ category: 'reasoning',
99
+ content: 'thinking…',
100
+ };
101
+ // Two instances / a reload re-appending the SAME message must still share one key.
102
+ assert.equal(diagnosticsMessageKey(m), diagnosticsMessageKey(Object.assign({}, m)));
103
+ });
104
+ KeySuite('returns null when the entry has no timestamp (treated as always-new)', () => {
105
+ assert.is(diagnosticsMessageKey({ kind: 'message', role: 'assistant' }), null);
106
+ });
107
+ KeySuite.run();
@@ -585,6 +585,18 @@ export class SessionPersister {
585
585
  * (§5.8). Skips messages present at restore or already appended; emits each turn
586
586
  * (by turn-index) and meta-event (by index) once; appends a `meta-snapshot` only
587
587
  * when the block changed. Advances the shared per-session cursors as it goes.
588
+ *
589
+ * TODO(GENC-1461, longer-term): this whole delta step exists only because the element *pulls* the
590
+ * ENTIRE debug log on every throttled flush (`getDiagnosticEntries()` returns the full timeline),
591
+ * so we must diff it against per-session cursors to append only the new tail — and messages, unlike
592
+ * events (monotonic `index`) and turns (`turnIndex`), have no stable positional cursor (compaction
593
+ * rewrites ranges, tool-call messages are annotated in place, synthetic messages are spliced in,
594
+ * restore reloads them), forcing a content-derived identity (`diagnosticsMessageKey`). That
595
+ * identity is inherently fragile: it was too coarse for the reasoning/answer/tool-call split and
596
+ * silently dropped co-timestamped messages (GENC-1461; fixed for now by adding `category`). The
597
+ * robust fix is to invert the flow — have the driver *push* each diagnostic entry exactly once at
598
+ * creation (an append-only event stream) instead of the element pulling-and-diffing — which removes
599
+ * the message-identity key and this dedup entirely. Bigger change; deferred.
588
600
  */
589
601
  collectDiagnosticsDelta() {
590
602
  var _a;
@@ -263,6 +263,9 @@ const metaSnap = (dedupSignature, meta) => ({
263
263
  meta,
264
264
  dedupSignature,
265
265
  });
266
+ // A diagnostic MESSAGE entry as the reasoning/narration/answer split (GENC-1411) produces them —
267
+ // several messages from ONE response, sharing timestamp + role, differing by `category` / content.
268
+ const diagMsg = (over = {}) => (Object.assign({ kind: 'message', role: 'assistant', timestamp: '2026-01-01T00:00:05.000Z', content: '' }, over));
266
269
  Suite('scheduleDiagnostics() appends the collected delta once per window', () => __awaiter(void 0, void 0, void 0, function* () {
267
270
  const { p, f } = makePersister({ diagnosticEntries: [evt(0), evt(1)] });
268
271
  p.scheduleDiagnostics();
@@ -311,4 +314,26 @@ Suite('flushDiagnostics() dedups meta-snapshots by dedupSignature, not the volat
311
314
  yield p.flushDiagnostics();
312
315
  assert.is(f.appended.length, 2, 'changed config → re-appended');
313
316
  }));
317
+ Suite('flushDiagnostics() keeps co-timestamped split messages (reasoning + answer), not deduped as one', () => __awaiter(void 0, void 0, void 0, function* () {
318
+ // GENC-1461: the reasoning/narration/answer split emits several messages from ONE response, so
319
+ // they share timestamp + role + subAgentOf. The forward-capture keyed only on those, so it
320
+ // emitted the reasoning message and dropped the answer as a "duplicate" — a server generation's
321
+ // answer message vanished from the persisted download while its reasoning survived. Both must be
322
+ // captured.
323
+ const reasoning = diagMsg({ category: 'reasoning', content: 'thinking…' });
324
+ const answer = diagMsg({ content: 'the generated code' }); // no category = the answer/tool-call slot
325
+ const { p, f } = makePersister({ diagnosticEntries: [reasoning, answer] });
326
+ yield p.flushDiagnostics();
327
+ assert.is(f.appended.length, 1, 'one flush');
328
+ assert.is(f.appended[0].length, 2, 'both the reasoning and the answer message are captured, not collapsed to one');
329
+ }));
330
+ Suite('flushDiagnostics() still dedups a genuine re-append of the same message across flushes', () => __awaiter(void 0, void 0, void 0, function* () {
331
+ // The dedup must still stop the SAME message being re-appended on every throttled flush.
332
+ const m = diagMsg({ category: 'reasoning', content: 'thinking…' });
333
+ const { p, f } = makePersister({ diagnosticEntries: [m] });
334
+ yield p.flushDiagnostics();
335
+ assert.is(f.appended.length, 1, 'first flush appends the message');
336
+ yield p.flushDiagnostics();
337
+ assert.is(f.appended.length, 1, 'unchanged message → no re-append');
338
+ }));
314
339
  Suite.run();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "15.2.0",
4
+ "version": "15.3.0",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -73,26 +73,26 @@
73
73
  }
74
74
  },
75
75
  "devDependencies": {
76
- "@genesislcap/foundation-testing": "15.2.0",
77
- "@genesislcap/genx": "15.2.0",
78
- "@genesislcap/rollup-builder": "15.2.0",
79
- "@genesislcap/ts-builder": "15.2.0",
80
- "@genesislcap/uvu-playwright-builder": "15.2.0",
81
- "@genesislcap/vite-builder": "15.2.0",
82
- "@genesislcap/webpack-builder": "15.2.0",
76
+ "@genesislcap/foundation-testing": "15.3.0",
77
+ "@genesislcap/genx": "15.3.0",
78
+ "@genesislcap/rollup-builder": "15.3.0",
79
+ "@genesislcap/ts-builder": "15.3.0",
80
+ "@genesislcap/uvu-playwright-builder": "15.3.0",
81
+ "@genesislcap/vite-builder": "15.3.0",
82
+ "@genesislcap/webpack-builder": "15.3.0",
83
83
  "@types/dompurify": "^3.0.5",
84
84
  "@types/marked": "^5.0.2",
85
85
  "esbuild": "0.25.12"
86
86
  },
87
87
  "dependencies": {
88
- "@genesislcap/foundation-ai": "15.2.0",
89
- "@genesislcap/foundation-logger": "15.2.0",
90
- "@genesislcap/foundation-notifications": "15.2.0",
91
- "@genesislcap/foundation-redux": "15.2.0",
92
- "@genesislcap/foundation-ui": "15.2.0",
93
- "@genesislcap/foundation-utils": "15.2.0",
94
- "@genesislcap/rapid-design-system": "15.2.0",
95
- "@genesislcap/web-core": "15.2.0",
88
+ "@genesislcap/foundation-ai": "15.3.0",
89
+ "@genesislcap/foundation-logger": "15.3.0",
90
+ "@genesislcap/foundation-notifications": "15.3.0",
91
+ "@genesislcap/foundation-redux": "15.3.0",
92
+ "@genesislcap/foundation-ui": "15.3.0",
93
+ "@genesislcap/foundation-utils": "15.3.0",
94
+ "@genesislcap/rapid-design-system": "15.3.0",
95
+ "@genesislcap/web-core": "15.3.0",
96
96
  "dompurify": "^3.3.1",
97
97
  "marked": "^17.0.3"
98
98
  },
@@ -105,5 +105,5 @@
105
105
  "access": "public"
106
106
  },
107
107
  "customElements": "dist/custom-elements.json",
108
- "gitHead": "c39af72807245ad2240adf75233c2769213d9aba"
108
+ "gitHead": "a8d2fa3336de2307fb9b82d4be1d4939ac847d14"
109
109
  }
@@ -1,5 +1,5 @@
1
1
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
- import { assembleDebugLog, type DiagnosticEntry } from './diagnostics';
2
+ import { assembleDebugLog, diagnosticsMessageKey, type DiagnosticEntry } from './diagnostics';
3
3
 
4
4
  // GENC-1351 §5.8: `assembleDebugLog` reassembles the forward-only diagnostics
5
5
  // stream back into the `{ readme, timeline, meta }` debug-log shape — timeline
@@ -93,3 +93,47 @@ Suite('collapses exact-duplicate entries but keeps genuinely-distinct ones', ()
93
93
  });
94
94
 
95
95
  Suite.run();
96
+
97
+ // GENC-1461: the reasoning/narration/answer split (GENC-1411) emits several messages from ONE model
98
+ // response, so they share `timestamp` + `role` + `subAgentOf`. The message identity key must still
99
+ // tell them apart — otherwise the forward-capture dedup treats them as one and drops all but the
100
+ // first (a server generation's answer message vanished from the persisted download while its
101
+ // reasoning survived).
102
+ const KeySuite = createLogicSuite('diagnosticsMessageKey');
103
+
104
+ KeySuite('gives co-timestamped split messages (reasoning vs answer) distinct keys', () => {
105
+ const base = {
106
+ kind: 'message' as const,
107
+ role: 'assistant',
108
+ timestamp: '2026-01-01T00:00:05.000Z',
109
+ };
110
+ const reasoning: DiagnosticEntry = { ...base, category: 'reasoning', content: 'thinking…' };
111
+ const answer: DiagnosticEntry = { ...base, content: 'the generated code' }; // no category = the answer
112
+ const kReasoning = diagnosticsMessageKey(reasoning);
113
+ const kAnswer = diagnosticsMessageKey(answer);
114
+ assert.ok(kReasoning, 'reasoning message has a key');
115
+ assert.ok(kAnswer, 'answer message has a key');
116
+ assert.not.equal(
117
+ kReasoning,
118
+ kAnswer,
119
+ 'a reasoning and an answer split from one response must not collide',
120
+ );
121
+ });
122
+
123
+ KeySuite('still collapses a genuine re-append of the same message (same key)', () => {
124
+ const m: DiagnosticEntry = {
125
+ kind: 'message',
126
+ role: 'assistant',
127
+ timestamp: '2026-01-01T00:00:05.000Z',
128
+ category: 'reasoning',
129
+ content: 'thinking…',
130
+ };
131
+ // Two instances / a reload re-appending the SAME message must still share one key.
132
+ assert.equal(diagnosticsMessageKey(m), diagnosticsMessageKey({ ...m }));
133
+ });
134
+
135
+ KeySuite('returns null when the entry has no timestamp (treated as always-new)', () => {
136
+ assert.is(diagnosticsMessageKey({ kind: 'message', role: 'assistant' }), null);
137
+ });
138
+
139
+ KeySuite.run();
@@ -42,18 +42,26 @@ export interface DebugLog {
42
42
  }
43
43
 
44
44
  /**
45
- * Stable identity for a message diagnostic entry — `timestamp::role::subAgentOf`.
45
+ * Stable identity for a message diagnostic entry — `timestamp::role::subAgentOf::category`.
46
46
  * Used by the forward-capture delta (skip already-emitted messages) and by the
47
47
  * restore watermark (skip messages already present in a prior load's stream).
48
48
  * `null` when the entry has no timestamp → treat as always-new. Shared so the
49
49
  * element's diagnostics capture and the `SessionPersister`'s restore watermark
50
50
  * compute identity identically (GENC-1351 §5.8/§5.10).
51
51
  *
52
+ * `category` is part of the key because the reasoning/narration/answer split (GENC-1411) emits
53
+ * SEVERAL messages from one model response — the thought summary, any interstitial narration, and
54
+ * the answer/tool-call message — which share `timestamp` + `role` + `subAgentOf`. Keying on those
55
+ * alone collapsed them to one, so the forward-capture dropped all but the first: a server
56
+ * generation's answer message vanished from the persisted debug log while its reasoning survived
57
+ * (GENC-1461). `category` is stable across a message's in-place annotation (only `toolCalls` change,
58
+ * which is deliberately excluded), so a genuine re-append of the same message still dedups.
59
+ *
52
60
  * @internal
53
61
  */
54
62
  export function diagnosticsMessageKey(entry: DiagnosticEntry): string | null {
55
63
  return typeof entry.timestamp === 'string'
56
- ? `${entry.timestamp}::${(entry.role as string) ?? ''}::${(entry.subAgentOf as string) ?? ''}`
64
+ ? `${entry.timestamp}::${(entry.role as string) ?? ''}::${(entry.subAgentOf as string) ?? ''}::${(entry.category as string) ?? ''}`
57
65
  : null;
58
66
  }
59
67
 
@@ -367,6 +367,16 @@ const metaSnap = (dedupSignature: string, meta: unknown): DiagnosticEntry => ({
367
367
  dedupSignature,
368
368
  });
369
369
 
370
+ // A diagnostic MESSAGE entry as the reasoning/narration/answer split (GENC-1411) produces them —
371
+ // several messages from ONE response, sharing timestamp + role, differing by `category` / content.
372
+ const diagMsg = (over: Partial<DiagnosticEntry> = {}): DiagnosticEntry => ({
373
+ kind: 'message',
374
+ role: 'assistant',
375
+ timestamp: '2026-01-01T00:00:05.000Z',
376
+ content: '',
377
+ ...over,
378
+ });
379
+
370
380
  Suite('scheduleDiagnostics() appends the collected delta once per window', async () => {
371
381
  const { p, f } = makePersister({ diagnosticEntries: [evt(0), evt(1)] });
372
382
  p.scheduleDiagnostics();
@@ -425,4 +435,38 @@ Suite(
425
435
  },
426
436
  );
427
437
 
438
+ Suite(
439
+ 'flushDiagnostics() keeps co-timestamped split messages (reasoning + answer), not deduped as one',
440
+ async () => {
441
+ // GENC-1461: the reasoning/narration/answer split emits several messages from ONE response, so
442
+ // they share timestamp + role + subAgentOf. The forward-capture keyed only on those, so it
443
+ // emitted the reasoning message and dropped the answer as a "duplicate" — a server generation's
444
+ // answer message vanished from the persisted download while its reasoning survived. Both must be
445
+ // captured.
446
+ const reasoning = diagMsg({ category: 'reasoning', content: 'thinking…' });
447
+ const answer = diagMsg({ content: 'the generated code' }); // no category = the answer/tool-call slot
448
+ const { p, f } = makePersister({ diagnosticEntries: [reasoning, answer] });
449
+ await p.flushDiagnostics();
450
+ assert.is(f.appended.length, 1, 'one flush');
451
+ assert.is(
452
+ f.appended[0].length,
453
+ 2,
454
+ 'both the reasoning and the answer message are captured, not collapsed to one',
455
+ );
456
+ },
457
+ );
458
+
459
+ Suite(
460
+ 'flushDiagnostics() still dedups a genuine re-append of the same message across flushes',
461
+ async () => {
462
+ // The dedup must still stop the SAME message being re-appended on every throttled flush.
463
+ const m = diagMsg({ category: 'reasoning', content: 'thinking…' });
464
+ const { p, f } = makePersister({ diagnosticEntries: [m] });
465
+ await p.flushDiagnostics();
466
+ assert.is(f.appended.length, 1, 'first flush appends the message');
467
+ await p.flushDiagnostics();
468
+ assert.is(f.appended.length, 1, 'unchanged message → no re-append');
469
+ },
470
+ );
471
+
428
472
  Suite.run();
@@ -638,6 +638,18 @@ export class SessionPersister {
638
638
  * (§5.8). Skips messages present at restore or already appended; emits each turn
639
639
  * (by turn-index) and meta-event (by index) once; appends a `meta-snapshot` only
640
640
  * when the block changed. Advances the shared per-session cursors as it goes.
641
+ *
642
+ * TODO(GENC-1461, longer-term): this whole delta step exists only because the element *pulls* the
643
+ * ENTIRE debug log on every throttled flush (`getDiagnosticEntries()` returns the full timeline),
644
+ * so we must diff it against per-session cursors to append only the new tail — and messages, unlike
645
+ * events (monotonic `index`) and turns (`turnIndex`), have no stable positional cursor (compaction
646
+ * rewrites ranges, tool-call messages are annotated in place, synthetic messages are spliced in,
647
+ * restore reloads them), forcing a content-derived identity (`diagnosticsMessageKey`). That
648
+ * identity is inherently fragile: it was too coarse for the reasoning/answer/tool-call split and
649
+ * silently dropped co-timestamped messages (GENC-1461; fixed for now by adding `category`). The
650
+ * robust fix is to invert the flow — have the driver *push* each diagnostic entry exactly once at
651
+ * creation (an append-only event stream) instead of the element pulling-and-diffing — which removes
652
+ * the message-identity key and this dedup entirely. Bigger change; deferred.
641
653
  */
642
654
  private collectDiagnosticsDelta(): DiagnosticEntry[] {
643
655
  const cursors = getDiagnosticsCursors(this.stateKey);