From 5198b6fe56e7f2413edb9a69c7647c89cdecdeb7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 12:29:56 -0700 Subject: [PATCH 01/12] docs: design deterministic streaming markdown lifecycle --- ...-11-streaming-markdown-lifecycle-design.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-streaming-markdown-lifecycle-design.md diff --git a/docs/superpowers/specs/2026-07-11-streaming-markdown-lifecycle-design.md b/docs/superpowers/specs/2026-07-11-streaming-markdown-lifecycle-design.md new file mode 100644 index 000000000..0880878ce --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-streaming-markdown-lifecycle-design.md @@ -0,0 +1,148 @@ +# Streaming Markdown Lifecycle Design + +## Objective + +Make streaming Markdown deterministic from token ingestion through Angular DOM rendering. The upstream parser must expose one canonical visible graph, and the Angular renderer must finalize that graph from an authoritative lifecycle transition rather than elapsed silence. + +This intentionally breaks the existing `ChatStreamingMdComponent` input contract. Backwards compatibility is not required. + +## Problems + +The current parser has two representations of an open line: + +- `parser.root` projects the open line through the Markdown block and inline grammar. +- `push()` also synthesizes a root text node with `id: -1` for selected buffered lines. + +The synthetic path duplicates structural classification. It has already leaked code-fence delimiters and still reports thematic breaks such as `---` as text while `parser.root` reports a thematic-break node. + +The Angular component separately accepts `content` and `streaming`. It does not receive an authoritative terminal event, so it waits 600 ms after content becomes quiet before calling `finish()`. This creates timing-dependent behavior and permits phase/content input-order races. + +## Upstream Parser Architecture + +### Canonical visible graph + +`PartialMarkdownParser.root` is the only representation of visible Markdown. Both committed nodes and the projected open line live in this graph. No synthetic node or reserved `id: -1` path exists. + +The parser maintains a stable projected mirror between pushes. A push applies the chunk to the internal state machine, builds the next projected graph, and reconciles it against the prior projected graph. + +Nodes match when parser ID, node type, parent ID, and sibling index match. A projected node that later commits with the same match retains its public object identity and ID. A grammar reinterpretation that changes any matching field replaces the subtree: removed nodes complete in post-order, then replacement nodes are created in pre-order. Ordinary scalar updates and status transitions are emitted in pre-order after creations. These ordering rules apply within one `push()` or `finish()` result. + +`materialize(root)` continues to provide structurally shared immutable snapshots for signal-based consumers. + +### Event contract + +`push()` and `finish()` return events derived from the same reconciliation that updates `root`: + +- `node-created`: a node became visible in `root`. +- `value-updated`: a visible node retained identity and a scalar value grew or changed. +- `node-completed`: a visible node transitioned to `complete`, or left the visible graph because its subtree was reinterpreted. + +Every event node must be reachable from the current root, except a `node-completed` event for a node removed during that operation. No negative or reserved public node IDs are used. + +Event and root behavior must be partition-invariant. Feeding a prefix as one chunk or character by character must produce normalized trees with the same IDs, types, values, status, and hierarchy. Event batching and `delta` segmentation may differ by chunk partition, but replaying each event stream must end with that same graph; each ID may be created once, updated only while live, and completed at most once unless a grammar reinterpretation explicitly replaces it with a different type or position. + +### Structural coverage + +The invariant suite covers plain paragraphs, ATX headings, thematic breaks, indented and fenced code, blockquotes, nested lists, GFM tables, display math, HTML blocks, and mixed documents. Delimiters that belong to projected structural nodes must never appear as sibling text events. + +## Angular Renderer Contract + +### Atomic input + +`ChatStreamingMdComponent` accepts one required input: + +```ts +export interface StreamingMarkdownDocument { + readonly generation: string; + readonly phase: 'streaming' | 'complete'; + readonly content: string; +} +``` + +The atomic object prevents a transient combination of a new phase with stale content. + +`generation` identifies one assistant response attempt. Regeneration or replacement uses a new generation even when it occupies the same message position. + +### State transitions + +For a generation in `streaming` phase, content is append-only. The component pushes only the appended delta. + +On the first `complete` snapshot, the component synchronously pushes any final delta and calls `finish()` exactly once. There is no timer, debounce, or quiet-period inference. + +A new generation resets the parser and accepts any content. A shrinking or divergent content value within a streaming generation is a contract violation. A content change after completion within the same generation is also a contract violation. Development builds throw descriptive errors; production rebuilds from the provided atomic snapshot so rendering remains recoverable. + +Allowed transitions are explicit: + +| Prior state | Next state | Behavior | +| --- | --- | --- | +| none | streaming | Create parser and push content. | +| none | complete | Create parser, push content, and finish once. | +| streaming | streaming, same generation and append-only content | Push only the delta. | +| streaming | complete, same generation and append-only content | Push final delta, then finish once synchronously. | +| complete | complete, identical generation and content | Idempotent no-op. | +| any | streaming or complete, new generation | Replace parser and process the new snapshot. | +| complete | streaming, same generation | Contract violation. | +| complete | complete, changed content | Contract violation. | +| streaming | any, shrinking or divergent content | Contract violation. | + +All terminal outcomes use `phase: 'complete'`; the outcome describes why the generation ended but does not change parser finalization. + +Static Markdown uses a complete document value. Reasoning and subagent surfaces receive generation and phase from their owning lifecycle rather than supplying defaults that imply timing. + +### Lifecycle ownership + +The runtime-neutral message contract gains required delivery metadata: + +```ts +export type MessageDelivery = + | { + readonly generation: string; + readonly phase: 'streaming'; + } + | { + readonly generation: string; + readonly phase: 'complete'; + readonly outcome: 'success' | 'error' | 'aborted' | 'interrupted' | 'paused'; + }; + +export interface Message { + // Existing fields omitted. + delivery: MessageDelivery; +} +``` + +Runtime adapters own this state because they observe stream start and termination. Static, restored, user, system, and tool messages use `{ generation: message.id, phase: 'complete', outcome: 'success' }`. Each assistant response attempt receives a new opaque generation at submission, retry, regeneration, resume, or subagent invocation. + +Adapter mappings are: + +| Runtime event | Delivery state | +| --- | --- | +| First and subsequent chunks for the active attempt | `streaming` | +| Normal run completion | `complete/success` | +| Backend rejection or explicit runtime error event | `complete/error` | +| Transport closes after at least one chunk without a normal terminal event | `complete/interrupted` | +| User stop/cancellation | `complete/aborted` | +| Human-in-the-loop pause | `complete/paused`; resume starts a new generation | +| Retry or regenerate | New generation, initially `streaming` | +| Earlier assistant step when a tool loop advances | `complete/success` before the next step begins | +| Subagent message | Governed by the subagent adapter's own generation and terminal status | + +The chat composition derives `StreamingMarkdownDocument` only from message content and `message.delivery`. Reasoning content uses the same generation with a `:reasoning` suffix so it has an independent parser session but the same terminal transition. The renderer never reads agent-global loading state, message position, timers, or wall-clock time. + +## Delivery Sequence + +1. Implement canonical projected event reconciliation and invariant tests upstream. +2. Build and pack an unreleased `0.5.8` candidate tarball. +3. Install the tarball in Angular, introduce message delivery metadata and the atomic renderer contract, migrate all call sites, and remove the timing heuristic. +4. Extend canonical aimock browser coverage to sample transient blockquote/table states. +5. Run upstream and Angular unit, type, build, Playwright, and Chrome MCP verification against the tarball. +6. Release `@cacheplane/partial-markdown@0.5.8` through OIDC trusted publishing. +7. Replace the tarball dependency with registry `0.5.8`, refresh the lockfile, and rerun focused verification and Chrome MCP smoke tests, including a real-LLM pass when credentials are available locally. + +## Verification + +Upstream verification includes the complete parser suite, typecheck, build, event/root invariant matrix, chunk-partition properties, and fuzz tests. + +Angular verification includes chat unit tests, type tests, package build, canonical example build, focused aimock Playwright tests, and Chrome MCP inspection of tables, blockquote-to-table boundaries, code fences, thematic breaks, completion, and regeneration. Unit tests must prove final-delta-before-finish ordering, exactly-once finish, idempotent repeated completion, generation reset, every invalid transition in development and production behavior, and every terminal outcome mapping. Static checks must prove that Markdown finalization uses no timer API, agent-global loading state, or latest-message inference. + +No release is cut while event nodes disagree with the canonical root or while lifecycle behavior depends on wall-clock timing. From 327d778f0632d858b7ed5e24a392210806891efe Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 15:54:36 -0700 Subject: [PATCH 02/12] docs: plan deterministic streaming markdown lifecycle --- ...2026-07-11-streaming-markdown-lifecycle.md | 640 ++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-streaming-markdown-lifecycle.md diff --git a/docs/superpowers/plans/2026-07-11-streaming-markdown-lifecycle.md b/docs/superpowers/plans/2026-07-11-streaming-markdown-lifecycle.md new file mode 100644 index 000000000..0fc699106 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-streaming-markdown-lifecycle.md @@ -0,0 +1,640 @@ +# Streaming Markdown Lifecycle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make partial-Markdown events and visible roots share one canonical projected graph, and make Angular finalize Markdown synchronously from authoritative per-message lifecycle metadata instead of a 600 ms silence timer. + +**Architecture:** Cacheplane will reconcile committed and projected AST nodes into one stable public mirror and derive all events from that mirror. Angular adapters will attach required delivery metadata to every neutral message; chat will convert message content and delivery atomically into a `StreamingMarkdownDocument`, whose generation and phase drive one parser session without timers or global-loading inference. + +**Tech Stack:** TypeScript, Vitest, fast-check, Angular signals, Nx, LangGraph SDK, AG-UI events, Playwright, aimock, npm OIDC trusted publishing, Chrome MCP. + +--- + +## Repository Workspaces + +- Angular implementation worktree: `/Users/blove/repos/angular-agent-framework/.worktrees/streaming-markdown-lifecycle` +- Cacheplane implementation worktree: `/Users/blove/.config/superpowers/worktrees/cacheplane/partial-markdown-canonical-events` +- Angular branch: `blove/streaming-markdown-lifecycle` +- Cacheplane branch: `blove/partial-markdown-canonical-events` + +## File Structure + +### Cacheplane + +- Modify `packages/partial-markdown/src/parser.ts`: replace the synthetic pending-text path and disposable open-line projection with stable projected-mirror reconciliation. +- Modify `packages/partial-markdown/src/types.ts`: clarify public event lifecycle guarantees if comments/types require it. +- Modify `packages/partial-markdown/src/parser.test.ts`: pin event reachability, ordering, and projection-to-commit identity. +- Modify `packages/partial-markdown/src/__tests__/streaming-invariants.test.ts`: add block-type and event/root partition invariants. +- Modify `packages/partial-markdown/src/materialize.test.ts`: pin structural sharing across projected updates if reconciliation changes cache behavior. +- Modify `packages/partial-markdown/README.md`: document canonical root/event semantics. +- Modify `packages/partial-markdown/package.json`: set release candidate/final version `0.5.8`. +- Modify `packages/partial-markdown/CHANGELOG.md`: describe canonical projected events and removed synthetic IDs. + +### Angular Agent Framework + +- Modify `libs/chat/src/lib/agent/message.ts`: add discriminated `MessageDelivery` and require it on neutral messages. +- Create `libs/chat/src/lib/agent/message-delivery.ts`: central constructors and transition helpers for static, streaming, and terminal delivery states. +- Modify `libs/chat/src/lib/agent/index.ts` and `libs/chat/src/public-api.ts`: export the new public types and renderer document type. +- Modify `libs/chat/src/lib/streaming/streaming-markdown.component.ts`: accept one atomic document input and implement the explicit generation/phase state machine. +- Modify `libs/chat/src/lib/streaming/streaming-markdown.*.spec.ts`: migrate fixtures and test lifecycle ordering, identity, invalid transitions, and removed timers. +- Modify `libs/chat/src/lib/compositions/chat/chat.component.ts`: derive renderer documents only from message delivery metadata. +- Modify `libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts`: pass an atomic reasoning document with an independent generation suffix. +- Modify `libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts`: pass each subagent message's delivery metadata. +- Modify `libs/langgraph/src/lib/agent.fn.ts`: assign and finalize attempt generations in the runtime-neutral projection. +- Modify `libs/langgraph/src/lib/internals/stream-manager.bridge.ts`: expose deterministic run/attempt terminal state and mark restored messages complete. +- Modify `libs/langgraph/src/lib/internals/subagent-tracker.ts`: assign subagent delivery state. +- Modify `libs/ag-ui/src/lib/reducer.ts`: attach and transition message delivery metadata from run/message events and snapshots. +- Modify `libs/ag-ui/src/lib/to-agent.ts`: allocate generations at run start and finalize success/error/abort outcomes, including subagents. +- Modify `libs/chat/src/lib/testing/mock-agent.ts`, `libs/langgraph/src/lib/testing/mock-langgraph-agent.ts`, and `libs/ag-ui/src/lib/testing/fake-agent.ts`: produce valid lifecycle metadata in test utilities. +- Modify affected adapter and chat specs discovered by `rg "Message\s*[=>:]|role: ['\"](user|assistant|system|tool)" libs examples`: add explicit delivery metadata rather than compatibility defaults. +- Modify `examples/chat/angular/e2e/markdown-surfaces.spec.ts`: sample transient blockquote/table DOM and add thematic-break coverage. +- Modify `examples/chat/angular/e2e/fixtures/streaming-markdown.json`: provide deterministic slow fixtures for those assertions. +- Modify root `package.json`, `libs/chat/package.json`, and `package-lock.json`: consume the packed candidate, then registry `0.5.8`. + +## Task 1: Create the Cacheplane Worktree and Confirm Baseline + +- [ ] **Step 1: Create the isolated upstream branch** + +Run from `/Users/blove/repos/cacheplane`: + +```bash +mkdir -p /Users/blove/.config/superpowers/worktrees/cacheplane +git worktree add /Users/blove/.config/superpowers/worktrees/cacheplane/partial-markdown-canonical-events -b blove/partial-markdown-canonical-events origin/main +``` + +Expected: worktree at `origin/main`, branch tracks no unrelated changes. + +- [ ] **Step 2: Install and verify the baseline** + +```bash +pnpm install --frozen-lockfile +pnpm --filter @cacheplane/partial-markdown test +pnpm --filter @cacheplane/partial-markdown typecheck +``` + +Expected: 381 existing parser tests pass and typecheck exits 0. + +## Task 2: Pin Canonical Event/Root Invariants Upstream + +**Files:** +- Modify: `packages/partial-markdown/src/parser.test.ts` +- Modify: `packages/partial-markdown/src/__tests__/streaming-invariants.test.ts` + +- [ ] **Step 1: Write the failing structural event test** + +Add a table-driven test that pushes open prefixes for paragraph, ATX heading, thematic breaks (`---`, `***`, `___`), indented code, fenced code, blockquote, nested list, table, display math, and HTML. Walk `parser.root` to collect reachable IDs and assert: + +```ts +for (const event of events) { + const isRemovedCompletion = event.type === 'node-completed' + && !reachableNodes(parser.root).has(event.node); + if (!isRemovedCompletion) { + expect(reachableIds(parser.root)).toContain(event.node.id); + } + expect(event.node.id).toBeGreaterThanOrEqual(0); +} +``` + +For an unreachable completion, assert the exact node object was reachable from the root immediately before the operation and was removed by that operation. For a retained streaming-to-complete transition, assert the exact node object remains reachable and receives exactly one completion event. Also assert thematic-break pushes never emit a text event containing the delimiter. + +- [ ] **Step 2: Run the focused test and verify RED** + +```bash +pnpm --filter @cacheplane/partial-markdown exec vitest run src/parser.test.ts src/__tests__/streaming-invariants.test.ts +``` + +Expected: FAIL because thematic breaks emit synthetic `id: -1` text nodes and projected nodes are not event-reconciled. + +- [ ] **Step 3: Add failing identity and ordering assertions** + +Pin these behaviors: + +```ts +const projected = parser.root!.children[0]; +const commitEvents = parser.push('\n'); +expect(parser.root!.children[0]).toBe(projected); +expect(commitEvents.filter(e => e.type === 'node-created')).toEqual([]); +expect(projected.status).toBe('complete'); +``` + +For replacement, assert old descendants complete post-order before new nodes are created pre-order. For representative chunk partitions, replay events into an ID-indexed model and compare it with normalized `parser.root`. + +Assert the complete per-operation order explicitly: removed descendants complete post-order; replacement nodes are created pre-order; scalar updates and status transitions follow in pre-order. Model lifecycle by node incarnation, where an incarnation is the public object identity plus `(id, type, parent ID, sibling index)`. Reject duplicate creation, updates while non-live, or duplicate completion for the same incarnation. Permit a reused numeric ID only for an explicit grammar-reinterpretation replacement within the same reconciliation operation: the old incarnation must be removed and completed before the replacement is created at a different type or position. Track that replacement as a distinct incarnation; reject numeric-ID reuse in every other circumstance. + +Add a fast-check property using the existing adversarial Markdown corpus plus generated text. For every input, compare a one-chunk push with character-by-character pushes. Normalized roots must have identical IDs, types, values, statuses, and hierarchy. Normalized lifecycle summaries must have one creation and at most one completion per incarnation, updates only while that incarnation is live, and exactly one completion whenever a retained node transitions from streaming to complete. `delta` segmentation and event batch boundaries are intentionally ignored. + +- [ ] **Step 4: Re-run and confirm the new assertions fail for the intended reasons** + +Expected: projection identity changes between pushes and event replay cannot reconstruct the projected root. + +## Task 3: Implement the Canonical Projected Mirror + +**Files:** +- Modify: `packages/partial-markdown/src/parser.ts` +- Modify: `packages/partial-markdown/src/types.ts` +- Modify: `packages/partial-markdown/src/materialize.test.ts` + +- [ ] **Step 1: Remove the synthetic pending-text implementation** + +Delete `PENDING_TEXT_ID`, `pendingTextNode`, `pendingTextLen`, `syncPendingText`, `retirePendingText`, `shouldEmitSyntheticPendingText`, `isInsideBlockquote`, and `isProjectedStructuralOpenLine`. + +- [ ] **Step 2: Add projected reconciliation state** + +Maintain a public-node map for the currently visible projected AST and reconcile using `(id, kind, parentId, siblingIndex)`. Implement focused helpers in `parser.ts`: + +```ts +type VisibleKey = `${number}:${AstNodeKind}:${number | 'root'}:${number}`; + +function reconcileVisibleRoot( + previous: MarkdownDocumentNode | null, + preview: InternalState, +): { root: MarkdownDocumentNode | null; events: ParseEvent[] }; +``` + +Reuse matched objects, mutate scalar/status fields, rebuild child arrays with reused objects, complete removed subtrees post-order, and create replacements pre-order. Preserve the committed root object across open-line projections. + +- [ ] **Step 3: Route `push`, `finish`, `root`, and `getByPath` through the reconciled root** + +Each mutation builds one preview state, reconciles once, stores the resulting root, and returns those events. `root` becomes a stable read with no lazy disposable projection. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +```bash +pnpm --filter @cacheplane/partial-markdown exec vitest run src/parser.test.ts src/__tests__/streaming-invariants.test.ts src/materialize.test.ts +``` + +Expected: all focused tests pass; no public event has a negative ID. + +- [ ] **Step 5: Run full upstream verification** + +```bash +pnpm --filter @cacheplane/partial-markdown test +pnpm --filter @cacheplane/partial-markdown typecheck +pnpm --filter @cacheplane/partial-markdown build +pnpm --filter @cacheplane/partial-markdown publint +pnpm --filter @cacheplane/partial-markdown attw +``` + +- [ ] **Step 6: Commit the parser architecture** + +```bash +git add packages/partial-markdown/src +git commit -m "fix: unify projected markdown events" +``` + +## Task 4: Document and Pack the 0.5.8 Candidate + +**Files:** +- Modify: `packages/partial-markdown/package.json` +- Modify: `packages/partial-markdown/CHANGELOG.md` +- Modify: `packages/partial-markdown/README.md` + +- [ ] **Step 1: Set version and document behavior** + +Set `version` to `0.5.8`. Add changelog and README text stating that `root` and events share one projected graph, projection-to-commit preserves identity, and negative synthetic IDs are removed. + +- [ ] **Step 2: Verify packaging** + +```bash +pnpm --filter @cacheplane/partial-markdown build +pnpm --filter @cacheplane/partial-markdown publint +pnpm --filter @cacheplane/partial-markdown attw +npm pack --workspace packages/partial-markdown --pack-destination /tmp +``` + +Expected: `/tmp/cacheplane-partial-markdown-0.5.8.tgz` exists and contains `dist`, README, LICENSE, and package metadata. + +- [ ] **Step 3: Commit release preparation without tagging** + +```bash +git add packages/partial-markdown/package.json packages/partial-markdown/CHANGELOG.md packages/partial-markdown/README.md +git commit -m "chore(release): prepare partial-markdown 0.5.8" +``` + +## Task 4A: Establish Browser-Level RED Before Angular Implementation + +**Files:** +- Modify: `examples/chat/angular/e2e/markdown-surfaces.spec.ts` +- Modify: `examples/chat/angular/e2e/fixtures/streaming-markdown.json` + +- [ ] **Step 1: Add transient boundary tests before changing Angular production code** + +For blockquote followed by table, collect samples throughout the stream and assert every table-visible sample has exactly one table, no rows/cells outside it, no raw pipe paragraphs, and the table is a sibling after the blockquote. Add a long inter-chunk-pause fixture whose header arrives before its delimiter row, with latency greater than the existing 600 ms heuristic. Add a streamed thematic-break fixture and assert delimiter text never coexists with the projected `
`. + +- [ ] **Step 2: Run the focused tests and verify RED against the current implementation** + +```bash +AIMOCK_FIXTURE=fixtures/streaming-markdown.json npx nx e2e examples-chat-angular --grep "long pause|blockquote followed by table|thematic break" +``` + +Expected: at least the long-pause lifecycle test fails because elapsed silence can finalize an open table before the authoritative stream ends. Confirm the failure is an observed raw-pipe/detached-table state, not fixture or server startup failure. + +- [ ] **Step 3: Commit only the failing browser regression test and fixture** + +```bash +git add examples/chat/angular/e2e/markdown-surfaces.spec.ts examples/chat/angular/e2e/fixtures/streaming-markdown.json +git commit -m "test(chat): reproduce timer-driven markdown finalization" +``` + +Do not make this commit the PR head until Tasks 8-10 turn it green. + +## Task 5: Add the Required Neutral Message Delivery Contract + +**Files:** +- Create: `libs/chat/src/lib/agent/message-delivery.ts` +- Modify: `libs/chat/src/lib/agent/message.ts` +- Modify: `libs/chat/src/lib/agent/index.ts` +- Modify: `libs/chat/src/public-api.ts` +- Test: `libs/chat/src/lib/agent/message.spec.ts` + +- [ ] **Step 1: Write failing type/runtime tests** + +Test the discriminated union and constructors: + +```ts +expect(streamingDelivery('attempt-1')).toEqual({ generation: 'attempt-1', phase: 'streaming' }); +expect(completeDelivery('attempt-1', 'aborted')).toEqual({ + generation: 'attempt-1', phase: 'complete', outcome: 'aborted', +}); +expect(staticDelivery('m1')).toEqual({ + generation: 'm1', phase: 'complete', outcome: 'success', +}); +``` + +Add `expectTypeOf` coverage proving `outcome` is required for complete and unavailable for streaming. + +- [ ] **Step 2: Run RED** + +```bash +npx nx test chat --testFile=libs/chat/src/lib/agent/message.spec.ts +``` + +Expected: FAIL because delivery types/helpers do not exist. + +- [ ] **Step 3: Implement and export the delivery contract** + +Implement the exact discriminated type from the approved spec and pure constructors. Add required `delivery: MessageDelivery` to `Message`. + +- [ ] **Step 4: Run the focused test and typecheck** + +```bash +npx nx test chat --testFile=libs/chat/src/lib/agent/message.spec.ts +npx nx type-tests chat +``` + +Expected: focused behavior passes; typecheck reports every remaining message producer that must migrate. + +- [ ] **Step 5: Commit the contract** + +```bash +git add libs/chat/src/lib/agent libs/chat/src/public-api.ts +git commit -m "feat(chat): add authoritative message delivery state" +``` + +## Task 6: Populate Delivery State in LangGraph + +**Files:** +- Modify: `libs/langgraph/src/lib/agent.fn.ts` +- Modify: `libs/langgraph/src/lib/internals/stream-manager.bridge.ts` +- Modify: `libs/langgraph/src/lib/internals/subagent-tracker.ts` +- Modify: `libs/langgraph/src/lib/agent.fn.spec.ts` +- Modify: `libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts` +- Modify: `libs/langgraph/src/lib/testing/mock-langgraph-agent.ts` + +- [ ] **Step 1: Write failing lifecycle projection tests** + +Cover restored/static messages, first chunk, normal completion, explicit error, transport interruption after a chunk, abort, pause, resume, retry, regeneration, tool-loop step completion, and subagent completion. Assert every new attempt gets a distinct generation and every terminal message has a mandatory outcome. + +- [ ] **Step 2: Run RED** + +```bash +npx nx test langgraph --testFile=libs/langgraph/src/lib/agent.fn.spec.ts +npx nx test langgraph --testFile=libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts +``` + +- [ ] **Step 3: Implement attempt ownership at the stream manager boundary** + +Allocate an opaque attempt generation when `submit`, `retry`, `regenerate`, or resume starts. Track whether any chunk was seen. Expose per-message delivery metadata alongside reasoning timing. Mark prior tool-loop steps complete before projecting the next assistant step. Restored checkpoint messages are complete/success. + +- [ ] **Step 4: Project delivery in `messagesNeutral` and subagents** + +Extend `toMessage`/`toSubagent` projection so every neutral message receives the manager-owned delivery state. Never derive it from `isLoading` plus tail position. + +- [ ] **Step 5: Verify LangGraph** + +```bash +npx nx test langgraph +npx nx build langgraph +``` + +- [ ] **Step 6: Commit** + +```bash +git add libs/langgraph +git commit -m "feat(langgraph): project message delivery lifecycle" +``` + +## Task 7: Populate Delivery State in AG-UI and Test Utilities + +**Files:** +- Modify: `libs/ag-ui/src/lib/reducer.ts` +- Modify: `libs/ag-ui/src/lib/to-agent.ts` +- Modify: `libs/ag-ui/src/lib/reducer.spec.ts` +- Modify: `libs/ag-ui/src/lib/to-agent.spec.ts` +- Modify: `libs/ag-ui/src/lib/to-agent.conformance.spec.ts` +- Modify: `libs/ag-ui/src/lib/testing/fake-agent.ts` +- Modify: `libs/chat/src/lib/testing/mock-agent.ts` +- Modify: affected `Message` fixtures under `libs/chat`, `libs/ag-ui`, `libs/langgraph`, and `examples` + +- [ ] **Step 1: Write failing AG-UI outcome tests** + +Pin RUN_STARTED generation allocation, text chunks as streaming, RUN_FINISHED success, RUN_ERROR error, user abort aborted, snapshot restoration complete/success, and AG-UI subagent delivery. + +- [ ] **Step 2: Run RED** + +```bash +npx nx test ag-ui --testFile=libs/ag-ui/src/lib/reducer.spec.ts +npx nx test ag-ui --testFile=libs/ag-ui/src/lib/to-agent.spec.ts +``` + +- [ ] **Step 3: Implement reducer and adapter lifecycle transitions** + +Keep generation allocation in `to-agent.ts`, pass the active generation into reducer state, and finalize all messages belonging to the active run on terminal callbacks. Deduplicate duplicate RUN_ERROR/onRunFailed delivery without changing the terminal outcome. + +- [ ] **Step 4: Migrate static and test message producers** + +Use `staticDelivery(id)` for restored/static fixtures and explicit streaming/complete helpers for lifecycle tests. Do not add an optional fallback to `Message.delivery`. + +- [ ] **Step 5: Verify affected libraries** + +```bash +npx nx test ag-ui +npx nx build ag-ui +npx nx test chat +npx nx type-tests chat +``` + +- [ ] **Step 6: Commit** + +```bash +git add libs/ag-ui libs/chat/src/lib/testing libs/chat/src/lib/agent/message.spec.ts +git commit -m "feat(ag-ui): project message delivery lifecycle" +``` + +## Task 8: Replace the Angular Timer with an Atomic Renderer State Machine + +**Files:** +- Modify: `libs/chat/src/lib/streaming/streaming-markdown.component.ts` +- Modify: `libs/chat/src/public-api.ts` +- Modify: `libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts` +- Modify: `libs/chat/src/lib/streaming/streaming-markdown.table-stream.spec.ts` +- Modify: `libs/chat/src/lib/streaming/streaming-markdown.integration.spec.ts` +- Modify: `libs/chat/src/lib/streaming/streaming-markdown.identity.spec.ts` +- Modify: `libs/chat/src/lib/streaming/streaming-markdown.ng0956.spec.ts` +- Modify: `libs/chat/src/lib/streaming/streaming-markdown.torture.spec.ts` +- Modify: `libs/chat/src/lib/streaming/streaming-markdown.variants.spec.ts` + +- [ ] **Step 1: Install the upstream candidate tarball** + +```bash +npm install /tmp/cacheplane-partial-markdown-0.5.8.tgz --save-exact +``` + +Apply the same exact dependency to root and `libs/chat/package.json`; confirm the lockfile resolves the local tarball during candidate testing. + +- [ ] **Step 2: Rewrite component tests against `[document]` and verify RED** + +Use a host with one signal: + +```ts +document = signal({ + generation: 'g1', phase: 'streaming', content: '', +}); +``` + +Add assertions for final-delta-before-finish, exactly-once finish, repeated complete no-op, generation reset, same-generation complete-to-streaming rejection, changed completed content rejection, and divergent streaming content rejection. Test development throws and production recovery via an injected/testable contract-violation policy rather than global environment mutation. + +Cover every transition row independently in both development and production policy modes: `none -> streaming`, `none -> complete`, append-only `streaming -> streaming`, `streaming -> complete`, identical `complete -> complete`, new generation into streaming, new generation into complete, invalid `complete -> streaming`, invalid changed completed content, invalid shrinking content, and invalid divergent content. For each production recovery case, assert the rebuilt parser exactly matches the supplied atomic snapshot. + +- [ ] **Step 3: Implement the state machine** + +Replace `content`, `streaming`, `FINALIZE_DEBOUNCE_MS`, timer effects, `finished`, and `finalizeTick` with `document = input.required()` and synchronous transition processing. Keep only one parser, prior snapshot, and materialized root per generation. + +- [ ] **Step 4: Prove timers and inference are absent** + +```bash +rg -n "FINALIZE_DEBOUNCE|setTimeout|isLoading|messages\(\)\.length - 1" libs/chat/src/lib/streaming/streaming-markdown.component.ts +``` + +Expected: no matches. + +- [ ] **Step 5: Run all streaming renderer tests** + +```bash +npx nx test chat --testFile=libs/chat/src/lib/streaming +``` + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json libs/chat/package.json libs/chat/src/lib/streaming libs/chat/src/public-api.ts +git commit -m "refactor(chat): finalize markdown from explicit lifecycle" +``` + +## Task 9: Migrate Chat, Reasoning, and Subagent Call Sites + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.spec.ts` +- Modify: `libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts` +- Modify: `libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts` +- Modify: `libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts` + +- [ ] **Step 1: Add failing derivation tests** + +Assert chat creates `{ generation, phase, content }` from `message.delivery`; reasoning uses `${generation}:reasoning`; subagents use each nested message's delivery. Assert global `agent.isLoading` and message index do not affect Markdown documents. + +- [ ] **Step 2: Run RED** + +```bash +npx nx test chat --testFile=libs/chat/src/lib/compositions/chat/chat.component.spec.ts +npx nx test chat --testFile=libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts +npx nx test chat --testFile=libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts +``` + +- [ ] **Step 3: Implement pure document derivation** + +Add a small exported/internal pure helper near the streaming component: + +```ts +export function markdownDocument( + content: string, + delivery: MessageDelivery, + suffix = '', +): StreamingMarkdownDocument; +``` + +Bind `[document]` at every call site and remove Markdown-specific loading/tail inference. + +- [ ] **Step 4: Verify chat** + +```bash +npx nx test chat +npx nx type-tests chat +npx nx build chat +``` + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat +git commit -m "refactor(chat): bind markdown to message delivery" +``` + +## Task 10: Turn Canonical Browser Regressions Green + +**Files:** +- Modify: `examples/chat/angular/e2e/markdown-surfaces.spec.ts` +- Modify: `examples/chat/angular/e2e/fixtures/streaming-markdown.json` + +- [ ] **Step 1: Review the preimplementation tests from Task 4A** + +Confirm no assertions or fixtures were weakened while implementing the explicit lifecycle contract. + +- [ ] **Step 2: Run focused Playwright against the candidate and verify GREEN** + +```bash +AIMOCK_FIXTURE=fixtures/streaming-markdown.json npx nx e2e examples-chat-angular --grep "long pause|blockquote followed by table|thematic break" +``` + +Expected: the exact tests observed failing in Task 4A now pass against the packed parser and explicit lifecycle implementation. + +- [ ] **Step 3: Verify canonical example build and E2E** + +```bash +npx nx build examples-chat-angular +AIMOCK_FIXTURE=fixtures/streaming-markdown.json npx nx e2e examples-chat-angular --grep "streaming" +``` + +- [ ] **Step 4: Commit** + +```bash +git add examples/chat/angular/e2e +git commit -m "test(chat): cover transient markdown boundaries" +``` + +## Task 11: Candidate Integration and Chrome MCP Gate + +- [ ] **Step 1: Run complete local verification against the tarball** + +Cacheplane: + +```bash +pnpm --filter @cacheplane/partial-markdown test +pnpm --filter @cacheplane/partial-markdown typecheck +pnpm --filter @cacheplane/partial-markdown build +``` + +Angular: + +```bash +npx nx test chat +npx nx type-tests chat +npx nx build chat +npx nx test langgraph +npx nx build langgraph +npx nx test ag-ui +npx nx build ag-ui +npx nx build examples-chat-angular +``` + +- [ ] **Step 2: Start aimock-backed canonical servers on free ports** + +Use the existing E2E harness or explicit free ports if `4200`/`2024` are occupied. Do not expose secrets in command output. + +- [ ] **Step 3: Verify with Chrome MCP** + +Inspect these prompts during and after streaming: + +```text +Show me a markdown table comparing Angular signals, RxJS, and zone.js — three columns: name, mental model, when to use. Keep it concise. +Give me a blockquote with two lines, then a markdown table with columns issue, expected behavior, verification. +Give me a TypeScript code block, then a thematic break, then one concise sentence. +``` + +Confirm one table, attached rows, correct blockquote/table sibling boundary, no raw closing fence, no visible thematic delimiter, no console errors, immediate terminal finalization, and correct regeneration. + +- [ ] **Step 4: Stop all local servers** + +Confirm no listeners remain on the selected Angular, LangGraph, or aimock ports. + +## Task 12: Publish Upstream, Consume Registry Artifact, and Reverify + +- [ ] **Step 1: Push Cacheplane and open a ready PR** + +```bash +git push -u origin blove/partial-markdown-canonical-events +gh pr create --repo cacheplane/cacheplane --title "fix: unify projected markdown events" --body-file +``` + +- [ ] **Step 2: Wait for green and merge** + +Require workspace and partial-markdown package jobs green. Merge without bypassing failures. + +- [ ] **Step 3: Tag the merged release commit** + +```bash +git tag partial-markdown-v0.5.8 +git push origin partial-markdown-v0.5.8 +``` + +Expected: OIDC publish workflow succeeds and `npm view @cacheplane/partial-markdown version` returns `0.5.8`. + +- [ ] **Step 4: Replace the tarball with registry `0.5.8`** + +```bash +npm install @cacheplane/partial-markdown@0.5.8 --save-exact +``` + +Update both root and chat package manifests; assert no `file:` tarball reference remains in `package-lock.json`. + +- [ ] **Step 5: Repeat focused Angular verification and Chrome MCP smoke** + +Run chat tests/build, adapter tests, focused Playwright, and the three Chrome prompts against the registry artifact. + +- [ ] **Step 6: Run the credential-conditional real-LLM Chrome smoke** + +Without printing or shell-expanding the key, check whether root `.env` defines a non-empty `OPENAI_API_KEY`. When available, start the canonical LangGraph server without aimock using that environment, start Angular on free ports, and run the table, blockquote/table, and code-fence/thematic-break prompts through Chrome MCP. Record that the responses came from the real LLM path and verify the same transient/final DOM invariants. When the key is absent, report this gate as skipped with the exact reason; do not substitute a mock and call it real-LLM verification. + +- [ ] **Step 7: Commit the registry resolution** + +```bash +git add package.json libs/chat/package.json package-lock.json +git commit -m "chore: consume partial-markdown 0.5.8" +``` + +## Task 13: Publish Angular Changes + +- [ ] **Step 1: Review final diff and status** + +```bash +git diff origin/main...HEAD --check +git status --short +git log --oneline origin/main..HEAD +``` + +- [ ] **Step 2: Push and create the Angular PR** + +```bash +git push -u origin blove/streaming-markdown-lifecycle +gh pr create --repo cacheplane/angular-agent-framework --title "refactor(chat): make markdown streaming lifecycle explicit" --body-file +``` + +- [ ] **Step 3: Monitor checks, address actionable review, and merge on green** + +Require all mandatory checks green. After merge, verify `origin/main` contains the registry dependency and no debounce heuristic. + +- [ ] **Step 4: Clean up worktrees and local branches** + +Use `git worktree remove` only after both PRs are merged and each worktree is clean. Delete local feature branches only after confirming their commits are reachable from `origin/main`. From 6619042450603b3a02013ce26ec234c58cc5439d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 20:23:11 -0700 Subject: [PATCH 03/12] docs: clarify parser session identity invariant --- .../plans/2026-07-11-streaming-markdown-lifecycle.md | 6 +++--- .../specs/2026-07-11-streaming-markdown-lifecycle-design.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-07-11-streaming-markdown-lifecycle.md b/docs/superpowers/plans/2026-07-11-streaming-markdown-lifecycle.md index 0fc699106..b25092495 100644 --- a/docs/superpowers/plans/2026-07-11-streaming-markdown-lifecycle.md +++ b/docs/superpowers/plans/2026-07-11-streaming-markdown-lifecycle.md @@ -117,11 +117,11 @@ expect(commitEvents.filter(e => e.type === 'node-created')).toEqual([]); expect(projected.status).toBe('complete'); ``` -For replacement, assert old descendants complete post-order before new nodes are created pre-order. For representative chunk partitions, replay events into an ID-indexed model and compare it with normalized `parser.root`. +For replacement, assert old descendants complete post-order before new nodes are created pre-order. For representative chunk partitions, replay each event stream into its own ID-indexed model and compare it with that parser's normalized `root`. Assert the complete per-operation order explicitly: removed descendants complete post-order; replacement nodes are created pre-order; scalar updates and status transitions follow in pre-order. Model lifecycle by node incarnation, where an incarnation is the public object identity plus `(id, type, parent ID, sibling index)`. Reject duplicate creation, updates while non-live, or duplicate completion for the same incarnation. Permit a reused numeric ID only for an explicit grammar-reinterpretation replacement within the same reconciliation operation: the old incarnation must be removed and completed before the replacement is created at a different type or position. Track that replacement as a distinct incarnation; reject numeric-ID reuse in every other circumstance. -Add a fast-check property using the existing adversarial Markdown corpus plus generated text. For every input, compare a one-chunk push with character-by-character pushes. Normalized roots must have identical IDs, types, values, statuses, and hierarchy. Normalized lifecycle summaries must have one creation and at most one completion per incarnation, updates only while that incarnation is live, and exactly one completion whenever a retained node transitions from streaming to complete. `delta` segmentation and event batch boundaries are intentionally ignored. +Add a fast-check property using the existing adversarial Markdown corpus plus generated text. For every input, compare a one-chunk push with character-by-character pushes. Normalize away parser-session-local numeric IDs and compare types, values, statuses, and hierarchy. Independently validate each parser's lifecycle summary: one creation and at most one completion per incarnation, updates only while that incarnation is live, and exactly one completion whenever a retained node transitions from streaming to complete. `delta` segmentation and event batch boundaries are intentionally ignored. Do not replay a multi-character push one character at a time merely to manufacture cross-parser ID equality. - [ ] **Step 4: Re-run and confirm the new assertions fail for the intended reasons** @@ -140,7 +140,7 @@ Delete `PENDING_TEXT_ID`, `pendingTextNode`, `pendingTextLen`, `syncPendingText` - [ ] **Step 2: Add projected reconciliation state** -Maintain a public-node map for the currently visible projected AST and reconcile using `(id, kind, parentId, siblingIndex)`. Implement focused helpers in `parser.ts`: +Maintain a public-node map for the currently visible projected AST and reconcile using internal `(id, kind, parentId, siblingIndex)` within that parser session. Public numeric IDs are nonnegative session-local labels and are not compared across independent parser instances. Implement focused helpers in `parser.ts`: ```ts type VisibleKey = `${number}:${AstNodeKind}:${number | 'root'}:${number}`; diff --git a/docs/superpowers/specs/2026-07-11-streaming-markdown-lifecycle-design.md b/docs/superpowers/specs/2026-07-11-streaming-markdown-lifecycle-design.md index 0880878ce..e6ce45e9b 100644 --- a/docs/superpowers/specs/2026-07-11-streaming-markdown-lifecycle-design.md +++ b/docs/superpowers/specs/2026-07-11-streaming-markdown-lifecycle-design.md @@ -39,7 +39,7 @@ Nodes match when parser ID, node type, parent ID, and sibling index match. A pro Every event node must be reachable from the current root, except a `node-completed` event for a node removed during that operation. No negative or reserved public node IDs are used. -Event and root behavior must be partition-invariant. Feeding a prefix as one chunk or character by character must produce normalized trees with the same IDs, types, values, status, and hierarchy. Event batching and `delta` segmentation may differ by chunk partition, but replaying each event stream must end with that same graph; each ID may be created once, updated only while live, and completed at most once unless a grammar reinterpretation explicitly replaces it with a different type or position. +Event and root behavior must be semantically partition-invariant. Feeding a prefix as one chunk or character by character must produce normalized trees with the same types, values, status, and hierarchy. Numeric IDs are parser-session-local and are intentionally excluded when comparing independent parser instances; requiring cross-instance ID equality would couple correctness to chunk boundaries and force replay work. Within one parser session, every ID must be nonnegative and stable for a continuing logical node. Event batching and `delta` segmentation may differ by chunk partition, but replaying each event stream must end with its parser's root; each node incarnation may be created once, updated only while live, and completed at most once unless a grammar reinterpretation explicitly replaces it. ### Structural coverage From 5d3ecabd0169eaa8b8aebaa6ebfe76bae81b541b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 21:32:56 -0700 Subject: [PATCH 04/12] test(chat): reproduce timer-driven markdown finalization --- .../e2e/fixtures/streaming-markdown.json | 16 +++ .../angular/e2e/markdown-surfaces.spec.ts | 128 ++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/examples/chat/angular/e2e/fixtures/streaming-markdown.json b/examples/chat/angular/e2e/fixtures/streaming-markdown.json index aa63f2104..5f6e37502 100644 --- a/examples/chat/angular/e2e/fixtures/streaming-markdown.json +++ b/examples/chat/angular/e2e/fixtures/streaming-markdown.json @@ -23,6 +23,22 @@ }, "chunkSize": 3, "latency": 35 + }, + { + "match": { "userMessage": "stream a markdown table with a long header pause regression" }, + "response": { + "content": "Long pause table:\n\n| Name | Value |\n| --- | --- |\n| alpha | one |\n| beta | two |\n" + }, + "chunkSize": 36, + "latency": 750 + }, + { + "match": { "userMessage": "stream a thematic break regression" }, + "response": { + "content": "Before the break.\n\n---\n\nAfter the break." + }, + "chunkSize": 23, + "latency": 200 } ] } diff --git a/examples/chat/angular/e2e/markdown-surfaces.spec.ts b/examples/chat/angular/e2e/markdown-surfaces.spec.ts index d2bb6fc8d..0f0be3328 100644 --- a/examples/chat/angular/e2e/markdown-surfaces.spec.ts +++ b/examples/chat/angular/e2e/markdown-surfaces.spec.ts @@ -114,7 +114,20 @@ test('streaming markdown table: blockquote followed by table does not throw', as const hygiene = attachBrowserHygiene(page); await sendPrompt(page, 'stream a blockquote then a markdown table regression'); + const streamingAssistant = latestAssistant(page); + await expect(streamingAssistant).toBeAttached({ timeout: 15_000 }); + await waitForStreamingContent(streamingAssistant); + + const samples = await collectStreamingSamples(page, streamingAssistant, 2_000); const bubble = await waitForFinalAssistant(page); + + expect(contentChangedAcross(samples)).toBe(true); + const firstTableSample = samples.findIndex((sample) => sample.tableCount > 0); + expect(firstTableSample).toBeGreaterThanOrEqual(0); + const samplesAfterTableAppears = samples.slice(firstTableSample); + expect(samplesAfterTableAppears.length).toBeGreaterThan(2); + expect(tableBoundaryViolations(samplesAfterTableAppears)).toEqual([]); + await expect(bubble.locator('blockquote')).toBeVisible(); await expect(bubble.locator('blockquote')).toContainText('First line of the quote.'); await expect(bubble.locator('blockquote')).toContainText('Second line of the quote.'); @@ -134,6 +147,60 @@ test('streaming markdown table: blockquote followed by table does not throw', as expect(hygiene.failedRequests).toEqual([]); }); +test('streaming markdown table: long pause after header has no detached table state', async ({ + page, +}) => { + const hygiene = attachBrowserHygiene(page); + await sendPrompt(page, 'stream a markdown table with a long header pause regression'); + + const streamingAssistant = latestAssistant(page); + await expect(streamingAssistant).toBeAttached({ timeout: 15_000 }); + await waitForStreamingContent(streamingAssistant); + + const samples = await collectStreamingSamples(page, streamingAssistant, 2_000); + const bubble = await waitForFinalAssistant(page); + + expect(samples.length).toBeGreaterThan(20); + expect(samples[0]?.contentTextLength).toBeGreaterThan(0); + expect(samples.every((sample) => sample.tableCount <= 1)).toBe(true); + expect(samples.every((sample) => sample.tableElementsOutsideTable === 0)).toBe(true); + expect(samples.every((sample) => sample.detachedTableCellText.length === 0)).toBe(true); + expect(samples.every((sample) => sample.rawPipeTextOutsideTable.length === 0)).toBe(true); + + await expect(bubble.locator('table')).toHaveCount(1); + await expect(bubble.locator('thead th')).toHaveText(['Name', 'Value']); + await expect(bubble.locator('tbody tr')).toHaveCount(2); + await expect(bubble.locator('tbody tr')).toContainText(['alpha', 'beta']); + expect(hygiene.consoleErrors).toEqual([]); + expect(hygiene.failedRequests).toEqual([]); +}); + +test('streaming thematic break suppresses the live markdown delimiter', async ({ page }) => { + const hygiene = attachBrowserHygiene(page); + await sendPrompt(page, 'stream a thematic break regression'); + + const streamingAssistant = latestAssistant(page); + await expect(streamingAssistant).toBeAttached({ timeout: 15_000 }); + await waitForStreamingContent(streamingAssistant); + + const samples = await collectStreamingSamples(page, streamingAssistant, 1_200); + const bubble = await waitForFinalAssistant(page); + + expect(samples.length).toBeGreaterThan(2); + expect(samples.some((sample) => sample.thematicBreakCount > 0)).toBe(true); + expect( + samples.every( + (sample) => sample.rawThematicBreakTextOutsideCode.length === 0, + ), + ).toBe(true); + + await expect(bubble.locator('hr')).toHaveCount(1); + await expect(bubble).toContainText('Before the break.'); + await expect(bubble).toContainText('After the break.'); + expect(hygiene.consoleErrors).toEqual([]); + expect(hygiene.failedRequests).toEqual([]); +}); + test('streaming code fence: suppresses closing fence marker while streaming', async ({ page }) => { const hygiene = attachBrowserHygiene(page); await sendPrompt(page, 'stream a TypeScript code fence regression'); @@ -188,8 +255,14 @@ interface StreamingMarkdownSample { readonly tableCount: number; readonly rowsOutsideTable: number; readonly detachedTableCellText: string[]; + readonly tableElementsOutsideTable: number; + readonly rawPipeTextOutsideTable: string[]; + readonly tableFollowsBlockquote: boolean; + readonly tableNestedInBlockquote: boolean; readonly codeBlockCount: number; readonly hasRawFenceMarker: boolean; + readonly thematicBreakCount: number; + readonly rawThematicBreakTextOutsideCode: string[]; } async function collectStreamingSamples( @@ -208,16 +281,32 @@ async function collectStreamingSamples( return samples; } +async function waitForStreamingContent(bubble: Locator): Promise { + await expect.poll(async () => ( + await sampleStreamingMarkdown(bubble) + ).contentTextLength, { + intervals: [25], + timeout: 15_000, + }).toBeGreaterThan(0); +} + async function sampleStreamingMarkdown(bubble: Locator): Promise { return bubble.evaluate((el) => { const looksLikeTableRowFragment = (text: string): boolean => ( /\|/.test(text) || /Angular Signals|RxJS|zone\.js/.test(text) ); const tables = Array.from(el.querySelectorAll('table')); + const blockquote = el.querySelector('blockquote'); + const table = tables[0]; const rowsOutsideTable = Array.from(el.querySelectorAll('tr')).filter( (row) => !row.closest('table'), ).length; + const tableElementsOutsideTable = Array.from(el.querySelectorAll('tr, th, td')).filter( + (tableElement) => !tableElement.closest('table'), + ).length; const detachedTableCellText: string[] = []; + const rawPipeTextOutsideTable: string[] = []; + const rawThematicBreakTextOutsideCode: string[] = []; const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { @@ -226,6 +315,15 @@ async function sampleStreamingMarkdown(bubble: Locator): Promise { function contentChangedAcross(samples: StreamingMarkdownSample[]): boolean { return new Set(samples.map((sample) => sample.contentTextLength)).size > 2; } + +function tableBoundaryViolations(samples: StreamingMarkdownSample[]): object[] { + return samples.flatMap((sample, index) => { + const valid = sample.tableCount === 1 + && sample.tableElementsOutsideTable === 0 + && sample.rawPipeTextOutsideTable.length === 0 + && sample.tableFollowsBlockquote + && !sample.tableNestedInBlockquote; + if (valid) return []; + + return [{ + sampleIndex: index, + tableCount: sample.tableCount, + tableElementsOutsideTable: sample.tableElementsOutsideTable, + rawPipeTextOutsideTable: sample.rawPipeTextOutsideTable, + tableFollowsBlockquote: sample.tableFollowsBlockquote, + tableNestedInBlockquote: sample.tableNestedInBlockquote, + }]; + }); +} From 84a33bd05ff08aafdafe1466726c28590b6c01d2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 21:54:55 -0700 Subject: [PATCH 05/12] feat(chat): add authoritative message delivery state --- libs/chat/src/lib/agent/index.ts | 2 + libs/chat/src/lib/agent/message-delivery.ts | 39 ++++++++++++++ .../lib/agent/message-delivery.type-spec.ts | 51 ++++++++++++++++++ libs/chat/src/lib/agent/message.spec.ts | 53 +++++++++++++++++-- libs/chat/src/lib/agent/message.ts | 3 ++ libs/chat/src/public-api.ts | 5 ++ 6 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 libs/chat/src/lib/agent/message-delivery.ts create mode 100644 libs/chat/src/lib/agent/message-delivery.type-spec.ts diff --git a/libs/chat/src/lib/agent/index.ts b/libs/chat/src/lib/agent/index.ts index 627abd694..961390eb7 100644 --- a/libs/chat/src/lib/agent/index.ts +++ b/libs/chat/src/lib/agent/index.ts @@ -6,6 +6,8 @@ export { toAgentError, isAbortError } from './to-agent-error'; export type { Citation } from './citation'; export type { Message, Role } from './message'; export { isUserMessage, isAssistantMessage, isToolMessage, isSystemMessage } from './message'; +export type { CompleteOutcome, MessageDelivery } from './message-delivery'; +export { streamingDelivery, completeDelivery, staticDelivery } from './message-delivery'; export type { ContentBlock } from './content-block'; export type { ToolCall, ToolCallStatus } from './tool-call'; export type { AgentStatus } from './agent-status'; diff --git a/libs/chat/src/lib/agent/message-delivery.ts b/libs/chat/src/lib/agent/message-delivery.ts new file mode 100644 index 000000000..6f6194f9e --- /dev/null +++ b/libs/chat/src/lib/agent/message-delivery.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT + +/** + * Terminal result of one response attempt. + * + * `paused` is an intentional stop awaiting resumable input; `interrupted` means + * the response stream ended unexpectedly. The other outcomes indicate normal + * completion, failure, or caller cancellation. + */ +export type CompleteOutcome = 'success' | 'error' | 'aborted' | 'interrupted' | 'paused'; + +/** + * Delivery lifecycle for one response attempt. `generation` identifies that + * attempt and is stable only for its lifetime. `streaming` means chunks may + * still arrive; `complete` means the attempt has stopped with a terminal outcome. + */ +export type MessageDelivery = + | { readonly generation: string; readonly phase: 'streaming' } + | { + readonly generation: string; + readonly phase: 'complete'; + readonly outcome: CompleteOutcome; + }; + +export function streamingDelivery(generation: string) { + return { generation, phase: 'streaming' } as const satisfies MessageDelivery; +} + +export function completeDelivery( + generation: string, + outcome: TOutcome, +) { + const delivery = { generation, phase: 'complete', outcome } as const; + return delivery satisfies MessageDelivery; +} + +export function staticDelivery(messageId: string) { + return completeDelivery(messageId, 'success'); +} diff --git a/libs/chat/src/lib/agent/message-delivery.type-spec.ts b/libs/chat/src/lib/agent/message-delivery.type-spec.ts new file mode 100644 index 000000000..b562d1386 --- /dev/null +++ b/libs/chat/src/lib/agent/message-delivery.type-spec.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +import type { Equal, Expect } from '../../testing/type-assert'; +import { + completeDelivery, + staticDelivery, + streamingDelivery, + type MessageDelivery, +} from './message-delivery'; + +type TerminalOutcome = 'success' | 'error' | 'aborted' | 'interrupted' | 'paused'; + +// @ts-expect-error A complete delivery requires a terminal outcome. +const _missingOutcome: MessageDelivery = { generation: 'generation-1', phase: 'complete' }; + +const streamingState: MessageDelivery = { + generation: 'generation-2', + phase: 'streaming', +}; +// @ts-expect-error Streaming delivery does not expose an outcome. +streamingState.outcome; + +type _completeParameters = Expect< + Equal, [generation: string, outcome: TerminalOutcome]> +>; + +const streaming = streamingDelivery('generation-3'); +const _streamingDelivery: MessageDelivery = streaming; +type _streamingShape = Expect< + Equal< + typeof streaming, + { readonly generation: string; readonly phase: 'streaming' } + > +>; + +const complete = completeDelivery('generation-4', 'paused'); +const _completeDelivery: MessageDelivery = complete; + +const staticMessage = staticDelivery('message-1'); +const _staticDelivery: MessageDelivery = staticMessage; +type _staticShape = Expect< + Equal< + typeof staticMessage, + { + readonly generation: string; + readonly phase: 'complete'; + readonly outcome: 'success'; + } + > +>; + +export { _completeDelivery, _staticDelivery, _streamingDelivery }; diff --git a/libs/chat/src/lib/agent/message.spec.ts b/libs/chat/src/lib/agent/message.spec.ts index df66a77f2..a233b841f 100644 --- a/libs/chat/src/lib/agent/message.spec.ts +++ b/libs/chat/src/lib/agent/message.spec.ts @@ -1,15 +1,55 @@ // SPDX-License-Identifier: MIT import { isUserMessage, isAssistantMessage, type Message } from './message'; +import { + completeDelivery, + staticDelivery, + streamingDelivery, +} from './message-delivery'; + +describe('MessageDelivery', () => { + it('creates streaming delivery state', () => { + expect(streamingDelivery('generation-1')).toEqual({ + generation: 'generation-1', + phase: 'streaming', + }); + }); + + it('creates complete delivery state with its outcome', () => { + expect(completeDelivery('generation-2', 'interrupted')).toEqual({ + generation: 'generation-2', + phase: 'complete', + outcome: 'interrupted', + }); + }); + + it('creates successful complete delivery state for static messages', () => { + expect(staticDelivery('message-1')).toEqual({ + generation: 'message-1', + phase: 'complete', + outcome: 'success', + }); + }); +}); describe('Message', () => { it('isUserMessage narrows role', () => { - const msg: Message = { id: '1', role: 'user', content: 'hi' }; + const msg: Message = { + id: '1', + delivery: staticDelivery('1'), + role: 'user', + content: 'hi', + }; expect(isUserMessage(msg)).toBe(true); expect(isAssistantMessage(msg)).toBe(false); }); it('isAssistantMessage narrows role', () => { - const msg: Message = { id: '2', role: 'assistant', content: 'hello' }; + const msg: Message = { + id: '2', + delivery: staticDelivery('2'), + role: 'assistant', + content: 'hello', + }; expect(isAssistantMessage(msg)).toBe(true); expect(isUserMessage(msg)).toBe(false); }); @@ -19,6 +59,7 @@ describe('Message — reasoning fields', () => { it('accepts an optional reasoning string', () => { const m: Message = { id: 'a', + delivery: staticDelivery('a'), role: 'assistant', content: 'hello', reasoning: 'first I thought about it', @@ -29,6 +70,7 @@ describe('Message — reasoning fields', () => { it('accepts an optional reasoningDurationMs number', () => { const m: Message = { id: 'a', + delivery: staticDelivery('a'), role: 'assistant', content: 'hello', reasoning: 'first I thought about it', @@ -38,7 +80,12 @@ describe('Message — reasoning fields', () => { }); it('treats both reasoning fields as optional', () => { - const m: Message = { id: 'a', role: 'assistant', content: 'hello' }; + const m: Message = { + id: 'a', + delivery: staticDelivery('a'), + role: 'assistant', + content: 'hello', + }; expect(m.reasoning).toBeUndefined(); expect(m.reasoningDurationMs).toBeUndefined(); }); diff --git a/libs/chat/src/lib/agent/message.ts b/libs/chat/src/lib/agent/message.ts index 78542e0ae..2ba75f269 100644 --- a/libs/chat/src/lib/agent/message.ts +++ b/libs/chat/src/lib/agent/message.ts @@ -1,11 +1,14 @@ // SPDX-License-Identifier: MIT import type { ContentBlock } from './content-block'; import type { Citation } from './citation'; +import type { MessageDelivery } from './message-delivery'; export type Role = 'user' | 'assistant' | 'system' | 'tool'; export interface Message { id: string; + /** Adapter-owned authoritative delivery lifecycle state for this message. */ + delivery: MessageDelivery; role: Role; /** Plain text, or a list of structured content blocks. */ content: string | ContentBlock[]; diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index a565f61a4..a2032352d 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -9,7 +9,9 @@ export type { Agent, AgentWithHistory, Citation, + CompleteOutcome, Message, + MessageDelivery, Role, ContentBlock, ToolCall, @@ -34,6 +36,9 @@ export { isAssistantMessage, isToolMessage, isSystemMessage, + streamingDelivery, + completeDelivery, + staticDelivery, createAgentRef, AgentError, AGENT_ERROR_MESSAGES, From c0d7fb450f9f9f782dda3d922d28ae9e9ed97bf3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 22:20:34 -0700 Subject: [PATCH 06/12] feat(langgraph): project message delivery lifecycle --- libs/langgraph/src/lib/agent.fn.spec.ts | 153 +- libs/langgraph/src/lib/agent.fn.ts | 31 +- .../internals/stream-manager.bridge.spec.ts | 1262 ++++++++++++++++- .../lib/internals/stream-manager.bridge.ts | 502 ++++++- .../src/lib/internals/subagent-tracker.ts | 29 + 5 files changed, 1898 insertions(+), 79 deletions(-) diff --git a/libs/langgraph/src/lib/agent.fn.spec.ts b/libs/langgraph/src/lib/agent.fn.spec.ts index 4802dafe0..22ec4da2e 100644 --- a/libs/langgraph/src/lib/agent.fn.spec.ts +++ b/libs/langgraph/src/lib/agent.fn.spec.ts @@ -4,7 +4,7 @@ import { signal } from '@angular/core'; import type { AIMessage as CoreAIMessage } from '@langchain/core/messages'; import { agent } from './agent.fn'; import { MockAgentTransport } from './transport/mock-stream.transport'; -import type { StreamEvent } from './agent.types'; +import type { AgentTransport, StreamEvent } from './agent.types'; import type { ThreadState } from '@langchain/langgraph-sdk'; import { createLangGraphClient } from './client/create-langgraph-client'; import { LANGGRAPH_CLIENT_OPTIONS } from './client/client-options'; @@ -76,6 +76,157 @@ describe('agent', () => { expect((ref.value() as any).count).toBe(99); }); + describe('neutral message delivery', () => { + it('projects restored assistant, user, tool, and system messages as static success', async () => { + const transport = new MockAgentTransport(); + transport.history = [{ + values: { + messages: [ + { id: 'system-1', type: 'system', content: 'rules' }, + { id: 'user-1', type: 'human', content: 'question' }, + { id: 'assistant-1', type: 'ai', content: 'answer' }, + { id: 'tool-1', type: 'tool', tool_call_id: 'call-1', content: 'result' }, + ], + }, + next: [], + checkpoint: { thread_id: 'thread-1', checkpoint_ns: '', checkpoint_id: 'cp-1', checkpoint_map: null }, + metadata: null, + created_at: '2026-07-11T00:00:00.000Z', + parent_checkpoint: null, + tasks: [], + } as never]; + const ref = withInjectionContext(() => + agent({ apiUrl: '', assistantId: 'a', transport, threadId: 'thread-1', throttle: false }) + ); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(ref.messages().map(message => message.delivery)).toEqual([ + { generation: 'system-1', phase: 'complete', outcome: 'success' }, + { generation: 'user-1', phase: 'complete', outcome: 'success' }, + { generation: 'assistant-1', phase: 'complete', outcome: 'success' }, + { generation: 'tool-1', phase: 'complete', outcome: 'success' }, + ]); + }); + + it('reactively projects assistant streaming and terminal success without content changing', async () => { + const transport = new MockAgentTransport(); + const ref = withInjectionContext(() => + agent({ apiUrl: '', assistantId: 'a', transport, throttle: false }) + ); + + const submitted = ref.submit({ message: 'hello' }); + transport.emit([{ + type: 'messages', + messages: [{ id: 'ai-live', type: 'ai', content: 'answer' }], + messageMetadata: { langgraph_node: 'model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + + const streaming = ref.messages().find(message => message.id === 'ai-live')?.delivery; + expect(streaming).toEqual({ generation: expect.any(String), phase: 'streaming' }); + + transport.emit([{ type: 'values', values: { done: true } }]); + transport.close(); + await submitted; + + expect(ref.messages().find(message => message.id === 'ai-live')?.delivery).toEqual({ + generation: streaming?.generation, + phase: 'complete', + outcome: 'success', + }); + }); + + it('allocates a new generation for a regenerated assistant response', async () => { + let run = 0; + const transport: AgentTransport = { + async *stream() { + run += 1; + yield { + type: 'messages', + messages: run === 1 + ? [{ id: 'user-regen', type: 'human', content: 'question' }, { id: 'ai-old', type: 'ai', content: 'old' }] + : [{ id: 'ai-new', type: 'ai', content: 'new' }], + }; + yield { type: 'values', values: { done: true } }; + }, + async updateState() {}, + }; + const ref = withInjectionContext(() => + agent({ apiUrl: '', assistantId: 'a', transport, threadId: 'thread-1', throttle: false }) + ); + + await ref.submit({ message: 'question' }); + const oldGeneration = ref.messages().find(message => message.id === 'ai-old')?.delivery.generation; + expect(oldGeneration).not.toBe('ai-old'); + await ref.regenerate(ref.messages().findIndex(message => message.id === 'ai-old')); + const regenerated = ref.messages().find(message => message.id === 'ai-new')?.delivery; + + expect(regenerated).toMatchObject({ phase: 'complete', outcome: 'success' }); + expect(regenerated?.generation).not.toBe('ai-new'); + expect(regenerated?.generation).not.toBe(oldGeneration); + }); + + it('uses each subagent invocation generation and terminalizes success and error', async () => { + const transport = new MockAgentTransport(); + const ref = withInjectionContext(() => + agent({ + apiUrl: '', assistantId: 'a', transport, throttle: false, + subagentToolNames: ['task'], filterSubagentMessages: true, + }) + ); + + void ref.submit({ message: 'delegate' }); + transport.emit([{ + type: 'messages', + messages: [{ + id: 'ai-parent', type: 'ai', content: '', + tool_calls: [ + { id: 'call-success', name: 'task', args: { subagent_type: 'researcher', description: 'research' } }, + { id: 'call-error', name: 'task', args: { subagent_type: 'reviewer', description: 'review' } }, + ], + }], + }]); + transport.emit([{ + type: 'messages|tools:call-success', namespace: ['tools:call-success'], + messages: [{ id: 'sub-success', type: 'ai', content: 'result' }], + messageMetadata: { checkpoint_ns: 'tools:call-success|model' }, + }, { + type: 'messages|tools:call-error', namespace: ['tools:call-error'], + messages: [{ id: 'sub-error', type: 'ai', content: 'partial' }], + messageMetadata: { checkpoint_ns: 'tools:call-error|model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + + const successStreaming = ref.subagents().get('call-success')?.messages()[0].delivery; + const errorStreaming = ref.subagents().get('call-error')?.messages()[0].delivery; + expect(successStreaming).toMatchObject({ phase: 'streaming' }); + expect(errorStreaming).toMatchObject({ phase: 'streaming' }); + expect(successStreaming?.generation).not.toBe(errorStreaming?.generation); + + transport.emit([{ + type: 'messages', + messages: [ + { id: 'tool-success', type: 'tool', tool_call_id: 'call-success', content: 'done', status: 'success' }, + { id: 'tool-error', type: 'tool', tool_call_id: 'call-error', content: 'failed', status: 'error' }, + ], + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(ref.subagents().get('call-success')?.messages()[0].delivery).toEqual({ + generation: successStreaming?.generation, + phase: 'complete', + outcome: 'success', + }); + expect(ref.subagents().get('call-error')?.messages()[0].delivery).toEqual({ + generation: errorStreaming?.generation, + phase: 'complete', + outcome: 'error', + }); + await ref.stop(); + }); + }); + it('status transitions to running (isLoading) on submit()', async () => { const transport = new MockAgentTransport(); const ref = withInjectionContext(() => diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 4d59b7b5d..fb5b67087 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -47,7 +47,9 @@ import type { ContentBlock, AgentSubmitInput, AgentSubmitOptions, + MessageDelivery, } from '@threadplane/chat'; +import { staticDelivery } from '@threadplane/chat'; import { AgentOptions, @@ -355,9 +357,12 @@ export function agent< // `@let content = messageContent(message)` short-circuits — DOM never // updates per token. DOM stability is provided by `track message.id` // in chat-message-list, not by Message identity. - const messagesNeutral = computed(() => - rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs)), - ); + const messagesNeutral = computed(() => { + manager.deliveryRevision(); + return rawMessages().map((m) => + toMessage(m, manager.getReasoningDurationMs, manager.getMessageDelivery) + ); + }); // Client-tool resolutions written client-side. The raw `toolCalls$` stream // (and thus `rawToolCalls`) only ever carries backend results — a resolved @@ -390,7 +395,7 @@ export function agent< const subagentsNeutral = computed>(() => { const out = new Map(); - subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa))); + subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa, manager))); return out; }); @@ -622,6 +627,7 @@ function mapStatus(s: ResourceStatus): AgentStatus { function toMessage( m: BaseMessage, getReasoningDurationMs?: (id: string) => number | undefined, + getDelivery?: (id: string) => MessageDelivery, ): Message { const raw = m as unknown as Record; const typeVal = typeof m._getType === 'function' @@ -642,6 +648,7 @@ function toMessage( const result: Message = { id, role, + delivery: getDelivery?.(id) ?? staticDelivery(id), content: extractTextContent(m.content), toolCallId: raw['tool_call_id'] as string | undefined, name: raw['name'] as string | undefined, @@ -716,12 +723,24 @@ function toInterrupt(ix: Interrupt): AgentInterrupt { }; } -function toSubagent(sa: SubagentStreamRef): Subagent { +function toSubagent( + sa: SubagentStreamRef, + manager: ReturnType, +): Subagent { return { toolCallId: sa.toolCallId, name: sa.name, status: sa.status, - messages: computed(() => sa.messages().map((m) => toMessage(m))) as Signal, + messages: computed(() => { + manager.deliveryRevision(); + return sa.messages().map((m) => + toMessage( + m, + undefined, + () => manager.getSubagentMessageDelivery(sa.toolCallId, m), + ) + ); + }) as Signal, state: sa.values as Signal>, }; } diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts index 7e6fe7978..07a960304 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts @@ -90,6 +90,994 @@ describe('createStreamManagerBridge', () => { expect(typeof bridge.submit).toBe('function'); expect(typeof bridge.stop).toBe('function'); expect(typeof bridge.resubmitLast).toBe('function'); + expect(typeof bridge.getMessageDelivery).toBe('function'); + }); + + describe('message delivery lifecycle', () => { + it('projects the first assistant chunk as streaming and normal completion as success', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + const submitted = bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ id: 'ai-1', type: 'ai', content: 'hel' }], + messageMetadata: { langgraph_node: 'model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + + const streaming = bridge.getMessageDelivery('ai-1'); + expect(streaming).toEqual({ + generation: expect.any(String), + phase: 'streaming', + }); + + transport.emit([{ type: 'values', values: { answer: 'hello' } }]); + transport.close(); + await submitted; + + expect(bridge.getMessageDelivery('ai-1')).toEqual({ + generation: streaming.generation, + phase: 'complete', + outcome: 'success', + }); + destroy$.next(); + }); + + it('does not advance delivery revision for ordinary same-message token publication', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + void bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ id: 'revision-ai', type: 'ai', content: 'a' }], + messageMetadata: { langgraph_node: 'model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + const afterFirstChunk = bridge.deliveryRevision(); + + transport.emit([{ + type: 'messages', + messages: [{ id: 'revision-ai', type: 'ai', content: 'b' }], + messageMetadata: { langgraph_node: 'model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(bridge.deliveryRevision()).toBe(afterFirstChunk); + await bridge.stop(); + destroy$.next(); + }); + + it('preserves streamed identity and generation across a canonical history id swap', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + await new Promise(resolve => setTimeout(resolve, 0)); + + const submitted = bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ id: 'streamed-id', type: 'ai', content: 'final answer' }], + messageMetadata: { langgraph_node: 'model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + const streaming = bridge.getMessageDelivery('streamed-id'); + + transport.history = [{ + values: { + messages: [{ id: 'canonical-id', type: 'ai', content: 'final answer' }], + }, + next: [], + checkpoint: { + thread_id: 'thread-1', checkpoint_ns: '', checkpoint_id: 'cp-final', checkpoint_map: null, + }, + metadata: null, + created_at: '2026-07-11T00:00:00.000Z', + parent_checkpoint: null, + tasks: [], + } as never]; + transport.emit([{ type: 'values', values: { done: true } }]); + transport.close(); + await submitted; + + expect(subjects.messages$.value).toEqual([ + expect.objectContaining({ id: 'streamed-id', content: 'final answer' }), + ]); + expect(bridge.getMessageDelivery('streamed-id')).toEqual({ + generation: streaming.generation, + phase: 'complete', + outcome: 'success', + }); + destroy$.next(); + }); + + it('accepts an empty messages/complete event as normal terminal evidence', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + const submitted = bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ id: 'ai-complete-marker', type: 'ai', content: 'answer' }], + messageMetadata: { langgraph_node: 'model' }, + }, { + type: 'messages/complete', + messages: [], + }]); + transport.close(); + await submitted; + + expect(bridge.getMessageDelivery('ai-complete-marker')).toMatchObject({ + phase: 'complete', + outcome: 'success', + }); + destroy$.next(); + }); + + it.each([ + { type: 'values|child:node' as StreamEvent['type'], data: { done: true } }, + { type: 'messages/complete|child:node' as StreamEvent['type'], messages: [] }, + { type: 'checkpoints|child:node' as StreamEvent['type'], data: { checkpoint: 'cp-1' } }, + ])('does not accept namespaced $type as top-level terminal evidence', async (terminalEvent) => { + const transport: AgentTransport = { + async *stream() { + yield { + type: 'messages', + messages: [{ id: 'ai-namespaced-marker', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }; + yield terminalEvent; + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + await bridge.submit({}); + + expect(bridge.getMessageDelivery('ai-namespaced-marker')).toMatchObject({ + phase: 'complete', + outcome: 'interrupted', + }); + destroy$.next(); + }); + + it('accepts a null-payload top-level values event as terminal evidence', async () => { + const transport: AgentTransport = { + async *stream() { + yield { + type: 'messages', + messages: [{ id: 'ai-null-values', type: 'ai', content: 'answer' }], + messageMetadata: { langgraph_node: 'model' }, + }; + yield { type: 'values', data: null }; + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + await bridge.submit({}); + + expect(bridge.getMessageDelivery('ai-null-values')).toMatchObject({ + phase: 'complete', + outcome: 'success', + }); + destroy$.next(); + }); + + it('keeps interrupt-bearing top-level values paused', async () => { + const transport: AgentTransport = { + async *stream() { + yield { + type: 'messages', + messages: [{ id: 'ai-values-interrupt', type: 'ai', content: 'waiting' }], + messageMetadata: { langgraph_node: 'model' }, + }; + yield { + type: 'values', + values: { __interrupt__: [{ id: 'approval', value: 'approve?' }] }, + }; + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + await bridge.submit({}); + + expect(bridge.getMessageDelivery('ai-values-interrupt')).toMatchObject({ + phase: 'complete', + outcome: 'paused', + }); + destroy$.next(); + }); + + it('marks an explicit runtime error event as complete/error', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + const submitted = bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ id: 'ai-error', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }, { type: 'error', error: new Error('rejected') }]); + transport.close(); + await submitted; + + expect(bridge.getMessageDelivery('ai-error')).toEqual({ + generation: expect.any(String), + phase: 'complete', + outcome: 'error', + }); + destroy$.next(); + }); + + it('marks a transport close after a chunk without a terminal event as interrupted', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + const submitted = bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ id: 'ai-interrupted', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }]); + transport.close(); + await submitted; + + expect(bridge.getMessageDelivery('ai-interrupted')).toEqual({ + generation: expect.any(String), + phase: 'complete', + outcome: 'interrupted', + }); + destroy$.next(); + }); + + it('does not treat a values snapshot before the first chunk as terminal evidence', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + const submitted = bridge.submit({}); + transport.emit([ + { type: 'values', values: { initialized: true } }, + { + type: 'messages', + messages: [{ id: 'ai-after-initial-values', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }, + ]); + transport.close(); + await submitted; + + expect(bridge.getMessageDelivery('ai-after-initial-values')).toMatchObject({ + phase: 'complete', + outcome: 'interrupted', + }); + destroy$.next(); + }); + + it('requires new terminal evidence after a new assistant step starts', async () => { + const transport: AgentTransport = { + async *stream() { + yield { + type: 'messages', + messages: [{ id: 'ai-step-a', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }; + yield { type: 'values', values: { step: 'a-complete' } }; + yield { + type: 'messages', + messages: [{ id: 'ai-step-b', type: 'ai', content: 'partial answer' }], + messageMetadata: { langgraph_node: 'model' }, + }; + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + await bridge.submit({}); + + expect(bridge.getMessageDelivery('ai-step-b')).toMatchObject({ + phase: 'complete', + outcome: 'interrupted', + }); + destroy$.next(); + }); + + it('marks a transport error after a chunk as interrupted without changing AgentError classification', async () => { + const transport: AgentTransport = { + async *stream() { + yield { + type: 'messages', + messages: [{ id: 'ai-transport-error', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }; + throw new Error('HTTP 500: failed'); + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + await bridge.submit({}); + + expect(bridge.getMessageDelivery('ai-transport-error')).toMatchObject({ + phase: 'complete', + outcome: 'interrupted', + }); + expect(subjects.error$.value).toBeInstanceOf(AgentError); + expect((subjects.error$.value as AgentError).kind).toBe('server'); + destroy$.next(); + }); + + it('marks user stop as complete/aborted', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + void bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ id: 'ai-aborted', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + await bridge.stop(); + + expect(bridge.getMessageDelivery('ai-aborted')).toEqual({ + generation: expect.any(String), + phase: 'complete', + outcome: 'aborted', + }); + destroy$.next(); + }); + + it('marks HITL interruption as paused and allocates a new generation on resume', async () => { + let run = 0; + const transport: AgentTransport = { + async *stream() { + run += 1; + yield { + type: 'messages', + messages: [{ id: 'ai-resume', type: 'ai', content: `partial-${run}` }], + messageMetadata: { langgraph_node: 'model' }, + }; + if (run === 1) { + yield { type: 'interrupt', interrupt: { id: 'approval', value: 'approve?' } }; + } else { + yield { type: 'values', values: { done: true } }; + } + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + await bridge.submit({}); + const paused = bridge.getMessageDelivery('ai-resume'); + expect(paused).toEqual({ + generation: expect.any(String), + phase: 'complete', + outcome: 'paused', + }); + + await bridge.submit(null, { command: { resume: true } }); + const resumed = bridge.getMessageDelivery('ai-resume'); + expect(resumed).toEqual({ + generation: expect.any(String), + phase: 'complete', + outcome: 'success', + }); + expect(resumed.generation).not.toBe(paused.generation); + destroy$.next(); + }); + + it('allocates a new generation when retrying the same message id', async () => { + let run = 0; + const transport: AgentTransport = { + async *stream() { + run += 1; + yield { + type: 'messages', + messages: [{ id: 'ai-retry', type: 'ai', content: `attempt-${run}` }], + messageMetadata: { langgraph_node: 'model' }, + }; + if (run === 1) { + yield { type: 'error', error: new Error('retry me') }; + } else { + yield { type: 'values', values: { done: true } }; + } + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + await bridge.submit({ retry: true }); + const first = bridge.getMessageDelivery('ai-retry'); + await bridge.resubmitLast(); + const second = bridge.getMessageDelivery('ai-retry'); + + expect(first).toMatchObject({ phase: 'complete', outcome: 'error' }); + expect(second).toMatchObject({ phase: 'complete', outcome: 'success' }); + expect(second.generation).not.toBe(first.generation); + destroy$.next(); + }); + + it('allocates fresh generations for direct and queued joined runs', async () => { + const transport = new MockAgentTransport(); + transport.joinStream = async function* (threadId, runId) { + this.joinedRuns.push({ threadId, runId }); + yield { + type: 'messages', + messages: [{ id: 'ai-joined-tail', type: 'ai', content: runId }], + messageMetadata: { langgraph_node: 'model' }, + }; + yield { type: 'values', values: { runId } }; + }; + const subjects = makeSubjects(); + subjects.messages$.next([ + { id: 'ai-joined-tail', type: 'ai', content: 'prior partial' } as never, + ]); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + await bridge.joinStream('direct-run'); + const direct = bridge.getMessageDelivery('ai-joined-tail'); + + const active = bridge.submit({ active: true }); + await bridge.submit( + { queued: true }, + { multitaskStrategy: 'enqueue' }, + ); + transport.emit([{ type: 'values', values: { active: true } }]); + transport.close(); + await active; + + const queued = bridge.getMessageDelivery('ai-joined-tail'); + expect(direct).toMatchObject({ phase: 'complete', outcome: 'success' }); + expect(queued).toMatchObject({ phase: 'complete', outcome: 'success' }); + expect(direct.generation).not.toBe('ai-joined-tail'); + expect(queued.generation).not.toBe(direct.generation); + destroy$.next(); + }); + + it('keeps direct join explicit errors terminal', async () => { + const transport: AgentTransport = { + async *stream() { yield* []; }, + async *joinStream() { + yield { + type: 'messages', + messages: [{ id: 'direct-error-ai', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }; + yield { type: 'error', error: new Error('rejected') }; + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + await bridge.joinStream('direct-error-run'); + + expect(bridge.getMessageDelivery('direct-error-ai')).toMatchObject({ + phase: 'complete', outcome: 'error', + }); + expect(subjects.status$.value).toBe(ResourceStatus.Error); + destroy$.next(); + }); + + it('keeps queued join explicit errors terminal', async () => { + const transport = new MockAgentTransport(); + transport.joinStream = async function* (threadId, runId) { + this.joinedRuns.push({ threadId, runId }); + yield { + type: 'messages', + messages: [{ id: 'queued-error-ai', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }; + yield { type: 'error', error: new Error('queued rejected') }; + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + const active = bridge.submit({ messages: [{ type: 'human', content: 'active' }] }); + await bridge.submit( + { messages: [{ type: 'human', content: 'queued' }] }, + { multitaskStrategy: 'enqueue' }, + ); + transport.emit([{ type: 'values', values: { active: true } }]); + transport.close(); + await active; + + expect(bridge.getMessageDelivery('queued-error-ai')).toMatchObject({ + phase: 'complete', outcome: 'error', + }); + expect(subjects.status$.value).toBe(ResourceStatus.Error); + destroy$.next(); + }); + + it('preserves submit error delivery and status when stopped before iterator close', async () => { + let markErrorProcessed = () => undefined; + const errorProcessed = new Promise(resolve => { markErrorProcessed = resolve; }); + const transport: AgentTransport = { + async *stream(_assistantId, _threadId, _payload, signal) { + yield { + type: 'messages', + messages: [{ id: 'submit-error-stop-ai', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }; + yield { type: 'error', error: new Error('submit rejected') }; + markErrorProcessed(); + await new Promise(resolve => { + if (signal.aborted) resolve(); + else signal.addEventListener('abort', () => resolve(), { once: true }); + }); + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + const submitted = bridge.submit({}); + await errorProcessed; + await new Promise(resolve => setTimeout(resolve, 0)); + const errored = bridge.getMessageDelivery('submit-error-stop-ai'); + await bridge.stop(); + await submitted; + + expect(errored).toMatchObject({ phase: 'complete', outcome: 'error' }); + expect(bridge.getMessageDelivery('submit-error-stop-ai')).toEqual(errored); + expect(subjects.status$.value).toBe(ResourceStatus.Error); + destroy$.next(); + }); + + it('preserves direct join error delivery and status when stopped before iterator close', async () => { + let markErrorProcessed = () => undefined; + const errorProcessed = new Promise(resolve => { markErrorProcessed = resolve; }); + const transport: AgentTransport = { + async *stream() { yield* []; }, + async *joinStream(_threadId, _runId, _lastEventId, signal) { + yield { + type: 'messages', + messages: [{ id: 'direct-error-stop-ai', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }; + yield { type: 'error', error: new Error('direct join rejected') }; + markErrorProcessed(); + await new Promise(resolve => { + if (signal.aborted) resolve(); + else signal.addEventListener('abort', () => resolve(), { once: true }); + }); + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + const joined = bridge.joinStream('direct-error-stop-run'); + await errorProcessed; + await new Promise(resolve => setTimeout(resolve, 0)); + const errored = bridge.getMessageDelivery('direct-error-stop-ai'); + await bridge.stop(); + await joined; + + expect(errored).toMatchObject({ phase: 'complete', outcome: 'error' }); + expect(bridge.getMessageDelivery('direct-error-stop-ai')).toEqual(errored); + expect(subjects.status$.value).toBe(ResourceStatus.Error); + destroy$.next(); + }); + + it('preserves queued join error delivery and status when stopped before iterator close', async () => { + let releaseInitial = () => undefined; + let markErrorProcessed = () => undefined; + const initialGate = new Promise(resolve => { releaseInitial = resolve; }); + const errorProcessed = new Promise(resolve => { markErrorProcessed = resolve; }); + const transport: AgentTransport = { + async *stream() { + await initialGate; + yield { type: 'values', values: { initial: true } }; + }, + async createQueuedRun(_assistantId, threadId, values, _signal, options) { + return { id: 'queued-error-stop-run', threadId, values, options, createdAt: new Date() }; + }, + async *joinStream(_threadId, _runId, _lastEventId, signal) { + yield { + type: 'messages', + messages: [{ id: 'queued-error-stop-ai', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }; + yield { type: 'error', error: new Error('queued join rejected') }; + markErrorProcessed(); + await new Promise(resolve => { + if (signal.aborted) resolve(); + else signal.addEventListener('abort', () => resolve(), { once: true }); + }); + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + const initial = bridge.submit({ run: 'initial' }); + await bridge.submit({ run: 'queued' }, { multitaskStrategy: 'enqueue' }); + releaseInitial(); + await errorProcessed; + await new Promise(resolve => setTimeout(resolve, 0)); + const errored = bridge.getMessageDelivery('queued-error-stop-ai'); + await bridge.stop(); + await initial; + + expect(errored).toMatchObject({ phase: 'complete', outcome: 'error' }); + expect(bridge.getMessageDelivery('queued-error-stop-ai')).toEqual(errored); + expect(subjects.status$.value).toBe(ResourceStatus.Error); + destroy$.next(); + }); + + it.each([false, true])( + 'treats direct join user stop as aborted when transport throws=%s', + async (throwOnAbort) => { + const transport: AgentTransport = { + async *stream() { yield* []; }, + async *joinStream(_threadId, _runId, _lastEventId, signal) { + yield { + type: 'messages', + messages: [{ id: 'direct-abort-ai', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }; + await new Promise(resolve => { + if (signal.aborted) resolve(); + else signal.addEventListener('abort', () => resolve(), { once: true }); + }); + if (throwOnAbort) { + const error = new Error('aborted'); + error.name = 'AbortError'; + throw error; + } + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + const joined = bridge.joinStream('direct-abort-run'); + await new Promise(resolve => setTimeout(resolve, 0)); + await bridge.stop(); + await joined; + + expect(bridge.getMessageDelivery('direct-abort-ai')).toMatchObject({ + phase: 'complete', outcome: 'aborted', + }); + expect(subjects.status$.value).toBe(ResourceStatus.Idle); + expect(subjects.error$.value).toBeUndefined(); + destroy$.next(); + }, + ); + + it('finalizes the earlier tool-loop assistant step before exposing the next step', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + void bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ id: 'ai-tool-call', type: 'ai', content: 'search', tool_calls: [{ id: 'call-1', name: 'search', args: {} }] }], + messageMetadata: { langgraph_node: 'model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(bridge.getMessageDelivery('ai-tool-call').phase).toBe('streaming'); + + transport.emit([{ type: 'values', values: { toolStepComplete: true } }]); + transport.emit([{ + type: 'messages', + messages: [{ id: 'ai-final', type: 'ai', content: 'search complete' }], + messageMetadata: { langgraph_node: 'model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(bridge.getMessageDelivery('ai-tool-call')).toMatchObject({ + phase: 'complete', + outcome: 'success', + }); + expect(bridge.getMessageDelivery('ai-final')).toMatchObject({ phase: 'streaming' }); + await bridge.stop(); + destroy$.next(); + }); + + it('merges cross-id chunks within the same tool-calling assistant step', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + void bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ + id: 'tool-chunk-a', type: 'ai', content: 'hel', + tool_calls: [{ id: 'call-1', name: 'search', args: { q: 'ang' } }], + }], + messageMetadata: { langgraph_node: 'model' }, + }, { + type: 'messages', + messages: [{ + id: 'tool-chunk-b', type: 'ai', content: 'lo', + tool_calls: [{ id: 'call-1', name: 'search', args: { q: 'angular' } }], + }], + messageMetadata: { langgraph_node: 'model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(subjects.messages$.value).toEqual([ + expect.objectContaining({ + id: 'tool-chunk-a', + content: 'hello', + tool_calls: [{ id: 'call-1', name: 'search', args: { q: 'angular' } }], + }), + ]); + expect(bridge.getMessageDelivery('tool-chunk-a')).toEqual({ + generation: expect.any(String), + phase: 'streaming', + }); + expect(bridge.getMessageDelivery('tool-chunk-b')).toEqual({ + generation: 'tool-chunk-b', + phase: 'complete', + outcome: 'success', + }); + + await bridge.stop(); + destroy$.next(); + }); + + it('tracks delivery on the displayed id when per-chunk event ids are merged', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + const submitted = bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ id: 'chunk-event-1', type: 'ai', content: 'hel' }], + messageMetadata: { langgraph_node: 'model' }, + }, { + type: 'messages', + messages: [{ id: 'chunk-event-2', type: 'ai', content: 'lo' }], + messageMetadata: { langgraph_node: 'model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(subjects.messages$.value).toEqual([ + expect.objectContaining({ id: 'chunk-event-1', content: 'hello' }), + ]); + const streaming = bridge.getMessageDelivery('chunk-event-1'); + expect(streaming).toEqual({ + generation: expect.any(String), + phase: 'streaming', + }); + expect(bridge.getMessageDelivery('chunk-event-2')).toEqual({ + generation: 'chunk-event-2', + phase: 'complete', + outcome: 'success', + }); + + transport.emit([{ type: 'values', values: { done: true } }]); + transport.close(); + await submitted; + + expect(bridge.getMessageDelivery('chunk-event-1')).toEqual({ + generation: streaming.generation, + phase: 'complete', + outcome: 'success', + }); + destroy$.next(); + }); + + it.each(['error', 'paused', 'aborted', 'interrupted'] as const)( + 'preserves an earlier tool-loop step success when the active step ends %s', + async (outcome) => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + const submitted = bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ + id: 'ai-earlier', type: 'ai', content: '', + tool_calls: [{ id: 'call-boundary', name: 'search', args: {} }], + }], + messageMetadata: { langgraph_node: 'model' }, + }, { + type: 'messages', + messages: [{ + id: 'tool-boundary', type: 'tool', tool_call_id: 'call-boundary', content: 'result', + }], + messageMetadata: { langgraph_node: 'tools' }, + }, { + type: 'messages', + messages: [{ id: 'ai-active', type: 'ai', content: 'partial' }], + messageMetadata: { langgraph_node: 'model' }, + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + + if (outcome === 'error') { + transport.emit([{ type: 'error', error: new Error('failed') }]); + transport.close(); + } else if (outcome === 'paused') { + transport.emit([{ type: 'interrupt', interrupt: { id: 'approval', value: 'approve?' } }]); + transport.close(); + } else if (outcome === 'aborted') { + await bridge.stop(); + transport.close(); + } else { + transport.close(); + } + await submitted; + + expect(bridge.getMessageDelivery('ai-earlier')).toMatchObject({ + phase: 'complete', + outcome: 'success', + }); + expect(bridge.getMessageDelivery('ai-active')).toMatchObject({ + phase: 'complete', + outcome, + }); + destroy$.next(); + }, + ); }); it('sets status to Loading when submit is called', async () => { @@ -535,6 +1523,90 @@ describe('createStreamManagerBridge', () => { destroy$.next(); }); + it('does not let a stale queue drain interrupt a replacement submit', async () => { + let releaseInitial = () => undefined; + let releaseFirstJoin = () => undefined; + let releaseReplacement = () => undefined; + let markFirstJoinStarted = () => undefined; + const initialGate = new Promise(resolve => { releaseInitial = resolve; }); + const firstJoinGate = new Promise(resolve => { releaseFirstJoin = resolve; }); + const replacementGate = new Promise(resolve => { releaseReplacement = resolve; }); + const firstJoinStarted = new Promise(resolve => { markFirstJoinStarted = resolve; }); + let queuedRun = 0; + const joinedRuns: string[] = []; + const transport: AgentTransport = { + async *stream(_assistantId, _threadId, payload, signal) { + if ((payload as { run: string }).run === 'initial') { + await initialGate; + yield { type: 'values', values: { initial: true } }; + return; + } + yield { + type: 'messages', + messages: [{ id: 'replacement-ai', type: 'ai', content: 'replacement' }], + messageMetadata: { langgraph_node: 'model' }, + }; + await replacementGate; + if (!signal.aborted) yield { type: 'values', values: { replacement: true } }; + }, + async createQueuedRun(_assistantId, threadId, values, _signal, options) { + queuedRun += 1; + return { + id: `queued-${queuedRun}`, + threadId, + values, + options, + createdAt: new Date(), + }; + }, + async *joinStream(_threadId, runId) { + joinedRuns.push(runId); + if (runId === 'queued-1') { + yield { + type: 'messages', + messages: [{ id: 'queued-one-ai', type: 'ai', content: 'queued one' }], + messageMetadata: { langgraph_node: 'model' }, + }; + markFirstJoinStarted(); + await firstJoinGate; + return; + } + yield { type: 'values', values: { queuedTwo: true } }; + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + const initial = bridge.submit({ run: 'initial' }); + await bridge.submit({ run: 'queued-1' }, { multitaskStrategy: 'enqueue' }); + await bridge.submit({ run: 'queued-2' }, { multitaskStrategy: 'enqueue' }); + releaseInitial(); + await firstJoinStarted; + + const replacement = bridge.submit({ run: 'replacement' }); + await new Promise(resolve => setTimeout(resolve, 0)); + const replacementStreaming = bridge.getMessageDelivery('replacement-ai'); + releaseFirstJoin(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(joinedRuns).toEqual(['queued-1']); + expect(bridge.getMessageDelivery('replacement-ai')).toEqual(replacementStreaming); + expect(replacementStreaming).toMatchObject({ phase: 'streaming' }); + expect(subjects.status$.value).toBe(ResourceStatus.Loading); + + releaseReplacement(); + await replacement; + await initial; + expect(joinedRuns).toEqual(['queued-1', 'queued-2']); + destroy$.next(); + }); + it('sets status to Resolved when stream completes', async () => { const transport = new MockAgentTransport([ [{ type: 'values', values: { count: 1 } }], @@ -659,7 +1731,7 @@ describe('createStreamManagerBridge', () => { } ); - it('does not accumulate metadata across multiple messages/partial events', async () => { + it('does not accumulate metadata across multiple messages/partial events', async () => { const transport = new MockAgentTransport(); const subjects = makeSubjects(); const destroy$ = new Subject(); @@ -700,10 +1772,104 @@ describe('createStreamManagerBridge', () => { expect(subjects.messages$.value).toHaveLength(2); expect(subjects.messages$.value[0]).toMatchObject({ id: 'h-1', content: 'hi' }); expect(subjects.messages$.value[1]).toMatchObject({ id: 'ai-1', content: 'Hello' }); - destroy$.next(); - }); + destroy$.next(); + }); - it('ignores late events from the previous stream after threadId changes', async () => { + it.each(['messages/partial', 'messages/complete'] as const)( + 'does not retag historical assistants from a full %s snapshot', + async (type) => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + subjects.messages$.next([ + { id: 'historical-ai', type: 'ai', content: 'old answer' } as never, + { id: 'historical-user', type: 'human', content: 'new question' } as never, + ]); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + void bridge.submit({}); + transport.emit([{ + type, + messages: [ + { id: 'historical-ai', type: 'ai', content: 'old answer' }, + { id: 'historical-user', type: 'human', content: 'new question' }, + { id: 'active-ai', type: 'ai', content: 'new answer' }, + ], + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(bridge.getMessageDelivery('historical-ai')).toEqual({ + generation: 'historical-ai', + phase: 'complete', + outcome: 'success', + }); + expect(bridge.getMessageDelivery('active-ai')).toEqual({ + generation: expect.any(String), + phase: 'streaming', + }); + await bridge.stop(); + destroy$.next(); + }, + ); + + it.each(['messages/partial', 'messages/complete'] as const)( + 'does not retag an enriched historical assistant from a full %s snapshot', + async (type) => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + subjects.messages$.next([ + { id: 'historical-enriched-ai', type: 'ai', content: 'old answer' } as never, + { id: 'historical-enriched-user', type: 'human', content: 'new question' } as never, + ]); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + const historicalDelivery = bridge.getMessageDelivery('historical-enriched-ai'); + + void bridge.submit({}); + transport.emit([{ + type, + messages: [ + { + id: 'historical-enriched-ai', + type: 'ai', + content: 'old answer enriched', + reasoning: 'retrospective reasoning', + tool_calls: [{ id: 'historical-call', name: 'lookup', args: { query: 'old' } }], + }, + { id: 'historical-enriched-user', type: 'human', content: 'new question' }, + { id: 'active-enriched-ai', type: 'ai', content: 'new answer' }, + ], + }]); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(subjects.messages$.value.find(message => + (message as unknown as { id?: string }).id === 'historical-enriched-ai' + )).toMatchObject({ + content: 'old answer enriched', + reasoning: 'retrospective reasoning', + tool_calls: [{ id: 'historical-call', name: 'lookup', args: { query: 'old' } }], + }); + expect(bridge.getMessageDelivery('historical-enriched-ai')).toEqual(historicalDelivery); + expect(bridge.getMessageDelivery('active-enriched-ai')).toEqual({ + generation: expect.any(String), + phase: 'streaming', + }); + await bridge.stop(); + destroy$.next(); + }, + ); + + it('ignores late events from the previous stream after threadId changes', async () => { const transport = new MockAgentTransport(); const subjects = makeSubjects(); const destroy$ = new Subject(); @@ -783,7 +1949,7 @@ describe('createStreamManagerBridge', () => { destroy$.next(); }); - it('classifies a non-user AbortError thrown BEFORE streaming as connection (kind:connection, retryable:true)', async () => { + it('classifies a non-user AbortError thrown BEFORE streaming as connection (kind:connection, retryable:true)', async () => { // Simulate an SDK that surfaces a connect-phase failure as an AbortError-like // error (name === 'AbortError') even though the user never called stop(). // The bridge must NOT classify this as 'aborted' (user stop) — it must @@ -813,10 +1979,90 @@ describe('createStreamManagerBridge', () => { expect(err).toBeInstanceOf(AgentError); expect((err as AgentError).kind).toBe('connection'); expect((err as AgentError).retryable).toBe(true); - destroy$.next(); - }); + destroy$.next(); + }); + + it('isolates a replacement execution from buffered events emitted by the old stream', async () => { + let releaseOld = () => undefined; + let releaseNew = () => undefined; + const oldGate = new Promise(resolve => { releaseOld = resolve; }); + const newGate = new Promise(resolve => { releaseNew = resolve; }); + const transport: AgentTransport = { + async *stream(_assistantId, _threadId, payload) { + const run = (payload as { run: number }).run; + if (run === 1) { + await oldGate; + yield { + type: 'messages', + messages: [{ + id: 'old-ai', type: 'ai', content: 'old', + tool_calls: [{ + id: 'old-call', name: 'task', + args: { subagent_type: 'researcher', description: 'old work' }, + }], + }], + messageMetadata: { langgraph_node: 'model' }, + }; + yield { + type: 'values|tools:old-call', namespace: ['tools:old-call'], + data: { messages: [{ type: 'human', content: 'old work' }] }, + }; + yield { type: 'error', error: new Error('old failure') }; + return; + } + + yield { + type: 'messages', + messages: [{ id: 'new-ai', type: 'ai', content: 'new' }], + messageMetadata: { langgraph_node: 'model' }, + }; + await newGate; + yield { type: 'values', values: { done: true } }; + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { + apiUrl: '', assistantId: 'test', transport, + subagentToolNames: ['task'], filterSubagentMessages: true, + }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + const first = bridge.submit({ run: 1 }); + await new Promise(resolve => setTimeout(resolve, 0)); + const second = bridge.submit({ run: 2 }); + await new Promise(resolve => setTimeout(resolve, 0)); + const newStreaming = bridge.getMessageDelivery('new-ai'); + + releaseOld(); + await first; + + expect(subjects.messages$.value).toEqual([ + expect.objectContaining({ id: 'new-ai', content: 'new' }), + ]); + expect(bridge.getMessageDelivery('new-ai')).toEqual(newStreaming); + expect(bridge.getMessageDelivery('old-ai')).toEqual({ + generation: 'old-ai', phase: 'complete', outcome: 'success', + }); + expect(subjects.subagents$.value.size).toBe(0); + expect(subjects.status$.value).toBe(ResourceStatus.Loading); + expect(subjects.error$.value).toBeUndefined(); + + releaseNew(); + await second; + expect(bridge.getMessageDelivery('new-ai')).toEqual({ + generation: newStreaming.generation, + phase: 'complete', + outcome: 'success', + }); + destroy$.next(); + }); - it('routes custom events to custom$ subject', async () => { + it('routes custom events to custom$ subject', async () => { const transport = new MockAgentTransport(); const subjects = makeSubjects(); const destroy$ = new Subject(); diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index ef181c585..8cc7a357d 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import { signal } from '@angular/core'; +import { signal, type Signal } from '@angular/core'; import { Observable, takeUntil } from 'rxjs'; import { ResourceStatus, @@ -20,7 +20,17 @@ import type { AgentRuntimeTelemetryProperties, AgentRuntimeTelemetrySink, } from '@threadplane/chat'; -import { AgentError, AGENT_ERROR_MESSAGES, toAgentError, isAbortError } from '@threadplane/chat'; +import { + AgentError, + AGENT_ERROR_MESSAGES, + completeDelivery, + isAbortError, + staticDelivery, + streamingDelivery, + toAgentError, + type CompleteOutcome, + type MessageDelivery, +} from '@threadplane/chat'; import { SubagentTracker, TrackedSubagent, @@ -87,6 +97,9 @@ export interface StreamManagerBridge { joinStream: (runId: string, lastEventId?: string) => Promise; resubmitLast: () => Promise; getReasoningDurationMs:(id: string) => number | undefined; + getMessageDelivery: (id: string) => MessageDelivery; + getSubagentMessageDelivery: (toolCallId: string, message: BaseMessage) => MessageDelivery; + deliveryRevision: Signal; /** Update server-side thread state (e.g. RemoveMessage for regenerate rollback). */ updateState: (values: Record, opts?: { asNode?: string }) => Promise; /** The current thread ID tracked by the bridge (null if not yet known). */ @@ -113,15 +126,30 @@ export function createStreamManagerBridge(); const toolProgressMap = new Map(); // Message ids whose content is known-final (installed by a canonical // replacement). Late streamed deltas for these ids are stale stragglers and // are ignored — decided by identity, never by comparing text to text. const canonicalMessageIds = new Set(); const queuedRuns: AgentQueueEntry[] = []; - let drainingQueue = false; + let queueDrainEpoch = 0; + let activeQueueDrainEpoch: number | null = null; + let attemptSequence = 0; + const messageDeliveries = new Map(); + const deliveryRevision = signal(0); + type DeliveryAttempt = { + generation: string; + messageIds: Set; + finalizedMessageIds: Set; + baselineMessageIds: Set; + eligibleBaselineAssistantId?: string; + currentAssistantMessageId?: string; + sawAssistantChunk: boolean; + currentStepHasTerminalEvidence: boolean; + terminalOutcome?: CompleteOutcome; + }; + let activeAttempt: DeliveryAttempt | null = null; const subagentManager = new SubagentTracker({ subagentToolNames: options.subagentToolNames, onSubagentChange: publishSubagents, @@ -148,6 +176,133 @@ export function createStreamManagerBridge(); + function notifyDeliveryChange(): void { + deliveryRevision.update(revision => revision + 1); + } + + function beginAttempt(allowBaselineTail = false): DeliveryAttempt { + if (activeAttempt && !activeAttempt.terminalOutcome) { + finalizeAttempt(activeAttempt, 'interrupted'); + } + const baselineMessageIds = new Set(); + for (const message of subjects.messages$.value) { + const id = (message as unknown as Record)['id']; + if (typeof id === 'string') baselineMessageIds.add(id); + } + attemptSequence += 1; + const attempt: DeliveryAttempt = { + generation: `attempt-${attemptSequence}-${Math.random().toString(36).slice(2, 10)}`, + messageIds: new Set(), + finalizedMessageIds: new Set(), + baselineMessageIds, + eligibleBaselineAssistantId: allowBaselineTail + ? getTailAssistantMessageId(subjects.messages$.value) + : undefined, + sawAssistantChunk: false, + currentStepHasTerminalEvidence: false, + }; + activeAttempt = attempt; + return attempt; + } + + function isCurrentExecution(controller: AbortController, attempt: DeliveryAttempt): boolean { + return abortController === controller && activeAttempt === attempt; + } + + function setDelivery(id: string, delivery: MessageDelivery): void { + const previous = messageDeliveries.get(id); + if ( + previous?.generation === delivery.generation + && previous.phase === delivery.phase + && (previous.phase !== 'complete' || delivery.phase !== 'complete' || previous.outcome === delivery.outcome) + ) { + return; + } + messageDeliveries.set(id, delivery); + notifyDeliveryChange(); + } + + function finalizeMessage(attempt: DeliveryAttempt, id: string, outcome: CompleteOutcome): void { + setDelivery(id, completeDelivery(attempt.generation, outcome)); + attempt.finalizedMessageIds.add(id); + } + + function finalizeAttempt(attempt: DeliveryAttempt, outcome: CompleteOutcome): void { + if (attempt.terminalOutcome) return; + attempt.terminalOutcome = outcome; + for (const id of attempt.messageIds) { + if (attempt.finalizedMessageIds.has(id)) continue; + finalizeMessage(attempt, id, outcome); + } + } + + function finishAttempt(attempt: DeliveryAttempt): void { + if (attempt.terminalOutcome) return; + finalizeAttempt( + attempt, + attempt.currentStepHasTerminalEvidence || !attempt.sawAssistantChunk ? 'success' : 'interrupted', + ); + } + + function trackAssistantMessages(messages: BaseMessage[]): void { + const attempt = activeAttempt; + if (!attempt || attempt.terminalOutcome) return; + + const assistantMessages = messages.filter(message => { + const raw = message as unknown as Record; + const type = normalizeMessageType( + typeof message._getType === 'function' ? message._getType() : raw['type'] as string | undefined, + ); + const id = typeof raw['id'] === 'string' ? raw['id'] : undefined; + return type === 'ai' && id && !attempt.finalizedMessageIds.has(id); + }); + const newAssistantMessages = assistantMessages.filter(message => { + const id = (message as unknown as Record)['id']; + return typeof id === 'string' && !attempt.baselineMessageIds.has(id); + }); + const currentStepMessages = newAssistantMessages.length > 0 + ? newAssistantMessages + : assistantMessages.filter(message => + (message as unknown as Record)['id'] === attempt.eligibleBaselineAssistantId + ); + + for (const message of currentStepMessages) { + const id = (message as unknown as Record)['id'] as string; + + if (attempt.currentAssistantMessageId && attempt.currentAssistantMessageId !== id) { + finalizeMessage(attempt, attempt.currentAssistantMessageId, 'success'); + attempt.currentStepHasTerminalEvidence = false; + } + attempt.currentAssistantMessageId = id; + attempt.messageIds.add(id); + attempt.sawAssistantChunk = true; + setDelivery(id, streamingDelivery(attempt.generation)); + } + } + + function invalidateQueueDrain(): void { + queueDrainEpoch += 1; + activeQueueDrainEpoch = null; + } + + function markNormalTerminal(event: StreamEvent): void { + const attempt = activeAttempt; + if ( + !attempt + || attempt.terminalOutcome + || !attempt.sawAssistantChunk + || (getEventNamespace(event)?.length ?? 0) > 0 + ) return; + const baseType = getBaseEventType(event.type); + // These are the canonical state/snapshot signals available in the current + // transport contract. Iterator close alone is deliberately not terminal + // evidence: after assistant chunks, a close without one of these markers + // is classified as interrupted. + if (baseType === 'values' || baseType === 'messages/complete' || baseType === 'checkpoints') { + attempt.currentStepHasTerminalEvidence = true; + } + } + function resetThreadState(): void { historyAbortController?.abort(); subjects.values$.next({} as T); @@ -167,10 +322,17 @@ export function createStreamManagerBridge { + invalidateQueueDrain(); abortController?.abort(); historyAbortController?.abort(); reasoningTimingMap.clear(); + if (activeAttempt && !activeAttempt.terminalOutcome) { + finalizeAttempt(activeAttempt, 'interrupted'); + } + messageDeliveries.clear(); + activeAttempt = null; + notifyDeliveryChange(); }); async function refreshHistory(force = false): Promise { @@ -227,7 +396,7 @@ export function createStreamManagerBridge { - if (drainingQueue || queuedRuns.length === 0) return; - drainingQueue = true; + if (activeQueueDrainEpoch !== null || queuedRuns.length === 0) return; + queueDrainEpoch += 1; + const drainEpoch = queueDrainEpoch; + activeQueueDrainEpoch = drainEpoch; try { while (queuedRuns.length > 0) { + if (activeQueueDrainEpoch !== drainEpoch) return; const entry = queuedRuns.shift(); publishQueue(); if (!entry || !transport.joinStream) continue; - await joinQueuedRun(entry); + await joinQueuedRun(entry, drainEpoch); } } finally { - drainingQueue = false; + if (activeQueueDrainEpoch === drainEpoch) activeQueueDrainEpoch = null; } } - async function joinQueuedRun(entry: AgentQueueEntry): Promise { - abortController = new AbortController(); + async function joinQueuedRun(entry: AgentQueueEntry, drainEpoch: number): Promise { + if (activeQueueDrainEpoch !== drainEpoch) return; + const controller = new AbortController(); + abortController = controller; + const attempt = beginAttempt(true); const startedAt = Date.now(); captureRuntimeRequestTelemetry('join_queued'); captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties); @@ -350,14 +525,18 @@ export function createStreamManagerBridge { + invalidateQueueDrain(); abortController?.abort(); - abortController = new AbortController(); - userAbortRequested = false; + const controller = new AbortController(); + abortController = controller; + const attempt = beginAttempt( + requestType === 'resubmit' || (isRecord(opts?.command) && 'resume' in opts.command), + ); const startedAt = Date.now(); captureRuntimeRequestTelemetry(requestType); captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties); @@ -427,18 +621,24 @@ export function createStreamManagerBridge(); + const merged = mergeMessages( + subjects.messages$.value, + normalized, + reasoningTimingMap, + mode, + canonicalMessageIds, + affectedMessageIds, + activeAttempt?.currentAssistantMessageId !== undefined + && activeAttempt.currentStepHasTerminalEvidence !== true, + ); + subjects.messages$.next(merged); + if (!isSubagentNamespace(namespace)) { + trackAssistantMessages(merged.filter(message => { + const id = (message as unknown as Record)['id']; + return typeof id === 'string' && affectedMessageIds.has(id); + })); + } if (isLgTraceEnabled()) { const msgs = subjects.messages$.value; const last = msgs[msgs.length - 1]; @@ -525,8 +754,17 @@ export function createStreamManagerBridge(); + const preserved = preserveIds(subjects.messages$.value, normalized, affectedMessageIds); + subjects.messages$.next(preserved); + if (!isSubagentNamespace(namespace)) { + trackAssistantMessages(preserved.filter(message => { + const id = (message as unknown as Record)['id']; + return typeof id === 'string' && affectedMessageIds.has(id); + })); + } } + markNormalTerminal(event); storeMessageMetadata(normalized, event); syncSubagentsFromMessages(normalized); syncToolCallsFromMessages(); @@ -543,6 +781,13 @@ export function createStreamManagerBridge { - userAbortRequested = true; + invalidateQueueDrain(); + const shouldAbortAttempt = Boolean( + abortController && activeAttempt && !activeAttempt.terminalOutcome + ); + if (shouldAbortAttempt && abortController) userAbortedControllers.add(abortController); + if (shouldAbortAttempt && activeAttempt) finalizeAttempt(activeAttempt, 'aborted'); abortController?.abort(); await clearQueue(); - // Note: status is set to Idle by the runStream() catch when it sees - // isAbortError && userAbortRequested. The explicit set here handles - // the case where stop() is called when no stream is active (so the - // catch never fires) or when clearQueue() raised an error. - if (subjects.status$.value !== ResourceStatus.Idle) { + // Set Idle synchronously for an active user cancellation. Attempts that + // already reached a terminal outcome retain their existing status. + if (shouldAbortAttempt && subjects.status$.value !== ResourceStatus.Idle) { subjects.status$.next(ResourceStatus.Idle); } }, @@ -790,8 +1041,12 @@ export function createStreamManagerBridge { if (!currentThreadId) return; + invalidateQueueDrain(); abortController?.abort(); - abortController = new AbortController(); + const controller = new AbortController(); + abortController = controller; + const attempt = beginAttempt(true); + const threadId = currentThreadId; const startedAt = Date.now(); captureRuntimeRequestTelemetry('join'); captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties); @@ -799,27 +1054,46 @@ export function createStreamManagerBridge + messageDeliveries.get(id) ?? staticDelivery(id), + + getSubagentMessageDelivery: (toolCallId: string, message: BaseMessage): MessageDelivery => + subagentManager.getMessageDelivery(toolCallId, message), + + deliveryRevision, + updateState: async ( values: Record, opts?: { asNode?: string }, @@ -906,6 +1188,12 @@ function extractInterrupts( } } +function hasInterrupts(payload: unknown): boolean { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false; + const raw = (payload as Record)['__interrupt__']; + return Array.isArray(raw) && raw.length > 0; +} + /** * Projects pending interrupts from the latest history checkpoint onto the * interrupt$ / interrupts$ subjects. ThreadState exposes interrupts under @@ -1020,7 +1308,11 @@ function normalizeMessages(event: StreamEvent): unknown[] | null { * carry the full text we collapse them, keeping the older slot's id so * track-by-id stays stable in the chat list. */ -function collapseAdjacentAi(messages: BaseMessage[]): BaseMessage[] { +function collapseAdjacentAi( + messages: BaseMessage[], + affectedMessageIds?: Set, + allowCrossIdAiMerge = true, +): BaseMessage[] { if (messages.length < 2) return messages; const out: BaseMessage[] = []; for (const msg of messages) { @@ -1035,13 +1327,21 @@ function collapseAdjacentAi(messages: BaseMessage[]): BaseMessage[] { if (lastType === 'ai' && msgType === 'ai') { const lastText = extractText(last.content); const msgText = extractText(msg.content); - if (lastText.length === 0 + const lastRaw = last as unknown as Record; + const msgRaw = msg as unknown as Record; + const differentIds = lastRaw['id'] !== msgRaw['id']; + if (!(differentIds && !allowCrossIdAiMerge) && (lastText.length === 0 || msgText.length === 0 || lastText === msgText || lastText.startsWith(msgText) - || msgText.startsWith(lastText)) { + || msgText.startsWith(lastText))) { // Keep the longer content; preserve last (older) id and metadata. const longerText = msgText.length >= lastText.length ? msgText : lastText; + const lastId = (last as unknown as Record)['id']; + const msgId = (msg as unknown as Record)['id']; + if (typeof msgId === 'string' && affectedMessageIds?.delete(msgId) && typeof lastId === 'string') { + affectedMessageIds.add(lastId); + } out[out.length - 1] = { ...(last as object), content: longerText } as BaseMessage; continue; } @@ -1059,6 +1359,8 @@ function mergeMessages( reasoningTimingMap?: Map, mode: MergeMode = 'snapshot', canonicalMessageIds?: Set, + affectedMessageIds?: Set, + allowCrossIdAiMerge = true, ): BaseMessage[] { const merged = [...existing]; for (const msg of incoming) { @@ -1073,6 +1375,9 @@ function mergeMessages( // prevents DOM teardown + animation restarts mid-stream. if (idx < 0) { idx = findContentMatch(merged, msg); + if (idx >= 0 && !canMergeCrossIdAi(merged[idx], msg, allowCrossIdAiMerge)) { + idx = -1; + } } // When an AIMessageChunk arrives without an id-match or content-prefix // match, treat the trailing AI message as its accumulator. The @@ -1088,7 +1393,11 @@ function mergeMessages( ? (merged[i] as BaseMessage)._getType() : (merged[i] as unknown as Record)['type'] as string | undefined, ); - if (t === 'ai') { idx = i; break; } + if (t === 'ai') { + if (!canMergeCrossIdAi(merged[i], msg, allowCrossIdAiMerge)) break; + idx = i; + break; + } if (t === 'human' || t === 'tool' || t === 'system') break; } } @@ -1146,7 +1455,9 @@ function mergeMessages( if (existingId) { (next as unknown as Record)['id'] = existingId; } + const changed = mode === 'delta' || messageChangedForDelivery(existing, next); merged[idx] = next; + if (targetId && changed) affectedMessageIds?.add(targetId); } else { const incomingRaw = msg as unknown as Record; const initialReasoningSource = 'reasoning' in incomingRaw @@ -1162,9 +1473,22 @@ function mergeMessages( const next = { ...(msg as object) } as BaseMessage; (next as unknown as Record)['reasoning'] = initialReasoning; merged.push(next); + const nextId = (next as unknown as Record)['id']; + if (typeof nextId === 'string') affectedMessageIds?.add(nextId); } } - return collapseAdjacentAi(merged); + return collapseAdjacentAi(merged, affectedMessageIds, allowCrossIdAiMerge); +} + +function canMergeCrossIdAi( + candidate: BaseMessage, + incoming: BaseMessage, + allowCrossIdAiMerge: boolean, +): boolean { + const candidateRaw = candidate as unknown as Record; + const incomingRaw = incoming as unknown as Record; + if (candidateRaw['id'] === incomingRaw['id']) return true; + return allowCrossIdAiMerge; } /** @@ -1303,8 +1627,18 @@ function accumulateReasoning(existing: unknown, incoming: unknown): string { * (role, content) matches positionally and the existing id differs. Keeps * track-by-id stable across server echoes and final-id swaps. */ -function preserveIds(existing: BaseMessage[], incoming: BaseMessage[]): BaseMessage[] { - if (existing.length === 0) return collapseAdjacentAi(incoming); +function preserveIds( + existing: BaseMessage[], + incoming: BaseMessage[], + affectedMessageIds?: Set, +): BaseMessage[] { + if (existing.length === 0) { + for (const message of incoming) { + const id = (message as unknown as Record)['id']; + if (typeof id === 'string') affectedMessageIds?.add(id); + } + return collapseAdjacentAi(incoming, affectedMessageIds); + } const usedExisting = new Set(); const remapped = incoming.map((msg, i) => { const inRaw = msg as unknown as Record; @@ -1317,13 +1651,43 @@ function preserveIds(existing: BaseMessage[], incoming: BaseMessage[]): BaseMess // Fallback: any unused existing message with matching role+content. matchIdx = existing.findIndex((m, j) => !usedExisting.has(j) && sameRoleAndContent(m, msg)); } - if (matchIdx < 0) return msg; + if (matchIdx < 0) { + if (typeof inId === 'string') affectedMessageIds?.add(inId); + return msg; + } usedExisting.add(matchIdx); const existingId = (existing[matchIdx] as unknown as Record)['id']; - if (!existingId || existingId === inId) return msg; - return { ...(msg as object), id: existingId } as BaseMessage; + const remappedMessage = !existingId || existingId === inId + ? msg + : { ...(msg as object), id: existingId } as BaseMessage; + if (typeof existingId === 'string' && messageChangedForDelivery(existing[matchIdx], remappedMessage)) { + affectedMessageIds?.add(existingId); + } + return remappedMessage; }); - return collapseAdjacentAi(remapped); + return collapseAdjacentAi(remapped, affectedMessageIds); +} + +function messageChangedForDelivery(existing: BaseMessage, incoming: BaseMessage): boolean { + const existingRaw = existing as unknown as Record; + const incomingRaw = incoming as unknown as Record; + const existingType = normalizeMessageType( + typeof existing._getType === 'function' ? existing._getType() : existingRaw['type'] as string | undefined, + ); + const incomingType = normalizeMessageType( + typeof incoming._getType === 'function' ? incoming._getType() : incomingRaw['type'] as string | undefined, + ); + if (existingType !== incomingType || extractText(existing.content) !== extractText(incoming.content)) { + return true; + } + const existingReasoning = typeof existingRaw['reasoning'] === 'string' + ? existingRaw['reasoning'] + : extractReasoning(existingRaw['reasoning']); + const incomingReasoning = typeof incomingRaw['reasoning'] === 'string' + ? incomingRaw['reasoning'] + : extractReasoning(incomingRaw['reasoning']); + if (existingReasoning !== incomingReasoning) return true; + return JSON.stringify(existingRaw['tool_calls'] ?? null) !== JSON.stringify(incomingRaw['tool_calls'] ?? null); } function sameRoleAndContent(a: BaseMessage, b: BaseMessage): boolean { @@ -1396,6 +1760,16 @@ function normalizeMessageType(t: string | undefined): string | undefined { return t; } +function getTailAssistantMessageId(messages: BaseMessage[]): string | undefined { + const tail = messages[messages.length - 1]; + if (!tail) return undefined; + const raw = tail as unknown as Record; + const type = normalizeMessageType( + typeof tail._getType === 'function' ? tail._getType() : raw['type'] as string | undefined, + ); + return type === 'ai' && typeof raw['id'] === 'string' ? raw['id'] : undefined; +} + function toSubagentRefs( subagents: Map, ): Map { diff --git a/libs/langgraph/src/lib/internals/subagent-tracker.ts b/libs/langgraph/src/lib/internals/subagent-tracker.ts index d71295d76..69781ac8b 100644 --- a/libs/langgraph/src/lib/internals/subagent-tracker.ts +++ b/libs/langgraph/src/lib/internals/subagent-tracker.ts @@ -1,5 +1,11 @@ // SPDX-License-Identifier: MIT import type { BaseMessage } from '@langchain/core/messages'; +import { + completeDelivery, + staticDelivery, + streamingDelivery, + type MessageDelivery, +} from '@threadplane/chat'; export interface TrackedToolCall { id?: string; @@ -9,6 +15,7 @@ export interface TrackedToolCall { export interface TrackedSubagent { id: string; + generation: string; status: 'pending' | 'running' | 'complete' | 'error'; toolCall: { id: string; @@ -25,6 +32,12 @@ export interface SubagentTrackerOptions { } const DEFAULT_SUBAGENT_TOOL_NAMES = ['task']; +let subagentGenerationSequence = 0; + +function createSubagentGeneration(): string { + subagentGenerationSequence += 1; + return `subagent-${subagentGenerationSequence}-${Math.random().toString(36).slice(2, 10)}`; +} /** * Lightweight Angular adapter for LangGraph subagent stream state. @@ -76,6 +89,7 @@ export class SubagentTracker { const existing = this.subagents.get(id); this.subagents.set(id, { id, + generation: existing?.generation ?? createSubagentGeneration(), status: existing?.status ?? 'pending', toolCall: { id, @@ -220,6 +234,21 @@ export class SubagentTracker { this.onSubagentChange?.(); } + getMessageDelivery(toolCallId: string, message: BaseMessage): MessageDelivery { + const id = getMessageId(message) ?? toolCallId; + const raw = message as unknown as Record; + const type = typeof message._getType === 'function' ? message._getType() : raw['type']; + if (type !== 'ai' && type !== 'assistant' && type !== 'AIMessage' && type !== 'AIMessageChunk') { + return staticDelivery(id); + } + + const subagent = this.subagents.get(toolCallId); + if (!subagent) return staticDelivery(id); + if (subagent.status === 'error') return completeDelivery(subagent.generation, 'error'); + if (subagent.status === 'complete') return completeDelivery(subagent.generation, 'success'); + return streamingDelivery(subagent.generation); + } + private retryPendingMatches(): void { for (const [namespaceId, description] of this.pendingMatches) { if (this.matchSubgraphToSubagent(namespaceId, description)) { From c5a2fc633c438835d7bf792d442c1233c5417234 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 23:27:33 -0700 Subject: [PATCH 07/12] feat(ag-ui): project message delivery lifecycle --- libs/ag-ui/src/lib/reducer.spec.ts | 192 ++++++- libs/ag-ui/src/lib/reducer.ts | 141 ++++- .../src/lib/to-agent.conformance.spec.ts | 9 + libs/ag-ui/src/lib/to-agent.spec.ts | 526 +++++++++++++++++- libs/ag-ui/src/lib/to-agent.ts | 284 ++++++---- libs/chat/src/lib/testing/mock-agent.spec.ts | 6 +- libs/chat/src/lib/testing/mock-agent.ts | 4 +- 7 files changed, 1006 insertions(+), 156 deletions(-) diff --git a/libs/ag-ui/src/lib/reducer.spec.ts b/libs/ag-ui/src/lib/reducer.spec.ts index 1645209ba..75db5aab7 100644 --- a/libs/ag-ui/src/lib/reducer.spec.ts +++ b/libs/ag-ui/src/lib/reducer.spec.ts @@ -2,10 +2,35 @@ import { describe, it, expect } from 'vitest'; import { signal } from '@angular/core'; import { Subject } from 'rxjs'; -import { AgentError, type AgentStatus, type Message, type ToolCall, type AgentEvent } from '@threadplane/chat'; +import { + AgentError, + completeDelivery, + staticDelivery, + streamingDelivery, + type AgentStatus, + type Message, + type ToolCall, + type AgentEvent, +} from '@threadplane/chat'; import { reduceEvent, type ReducerStore, type CustomStreamEvent, type ActivityEntry } from './reducer'; -function makeStore(): ReducerStore { +interface TestDeliveryRun { + generation: string; + baselineMessageIds: Set; + ownedMessageIds: Set; + currentAssistantMessageId?: string; + eligibleBaselineAssistantId?: string; + protocolRunId?: string; + outcome?: 'success' | 'error' | 'aborted'; +} + +type TestStore = ReducerStore & { + deliveryRun: TestDeliveryRun | null; + allocateDeliveryGeneration: (scope: string) => string; +}; + +function makeStore(generation = 'run-generation-1'): TestStore { + let activitySequence = 0; return { messages: signal([]), status: signal('idle'), @@ -17,42 +42,66 @@ function makeStore(): ReducerStore { events$: new Subject(), customEvents: signal([]), activities: signal>(new Map()), - }; + deliveryRun: { + generation, + baselineMessageIds: new Set(), + ownedMessageIds: new Set(), + }, + allocateDeliveryGeneration: (scope: string) => `${generation}:${scope}:${++activitySequence}`, + } as TestStore; } describe('reduceEvent', () => { - it('RUN_STARTED sets status running, isLoading true, clears error', () => { + it('RUN_STARTED establishes running state for the already-allocated generation', () => { const store = makeStore(); + const allocatedRun = store.deliveryRun; // Seed a previous AgentError so the clear can be observed. store.error.set(new AgentError({ kind: 'server', message: 'previous', retryable: true })); - reduceEvent({ type: 'RUN_STARTED' } as any, store); + reduceEvent({ type: 'RUN_STARTED', runId: 'protocol-run-1' } as any, store); expect(store.status()).toBe('running'); expect(store.isLoading()).toBe(true); expect(store.error()).toBeUndefined(); + expect(store.deliveryRun).toBe(allocatedRun); + expect(store.deliveryRun?.generation).toBe('run-generation-1'); + expect(store.deliveryRun?.protocolRunId).toBe('protocol-run-1'); }); - it('RUN_FINISHED sets status idle, isLoading false', () => { + it('RUN_FINISHED finalizes only active-generation messages as successful', () => { const store = makeStore(); store.status.set('running'); store.isLoading.set(true); + store.messages.set([ + { id: 'historical', role: 'assistant', content: 'old', delivery: staticDelivery('historical') }, + { id: 'active', role: 'assistant', content: 'new', delivery: streamingDelivery('run-generation-1') }, + ]); + store.deliveryRun!.ownedMessageIds.add('active'); reduceEvent({ type: 'RUN_FINISHED' } as any, store); expect(store.status()).toBe('idle'); expect(store.isLoading()).toBe(false); + expect(store.messages()[0].delivery).toEqual(staticDelivery('historical')); + expect(store.messages()[1].delivery).toEqual(completeDelivery('run-generation-1', 'success')); }); - it('RUN_ERROR sets status error, normalizes to AgentError', () => { + it('RUN_ERROR finalizes active-generation messages as error', () => { const store = makeStore(); + store.messages.set([ + { id: 'active', role: 'assistant', content: 'partial', delivery: streamingDelivery('run-generation-1') }, + ]); + store.deliveryRun!.ownedMessageIds.add('active'); reduceEvent({ type: 'RUN_ERROR', message: 'boom' } as any, store); expect(store.status()).toBe('error'); const err = store.error(); expect(err).toBeInstanceOf(AgentError); expect(err?.message).toContain('boom'); + expect(store.messages()[0].delivery).toEqual(completeDelivery('run-generation-1', 'error')); }); - it('TEXT_MESSAGE_START appends an empty assistant message', () => { + it('TEXT_MESSAGE_START appends an empty assistant message owned by the active generation', () => { const store = makeStore(); reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1' } as any, store); - expect(store.messages()).toEqual([{ id: 'm1', role: 'assistant', content: '' }]); + expect(store.messages()).toEqual([ + { id: 'm1', role: 'assistant', content: '', delivery: streamingDelivery('run-generation-1') }, + ]); }); it('TEXT_MESSAGE_CONTENT appends delta to in-flight message', () => { @@ -61,6 +110,15 @@ describe('reduceEvent', () => { reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'hi ' } as any, store); reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'there' } as any, store); expect(store.messages()[0].content).toBe('hi there'); + expect(store.messages()[0].delivery).toEqual(streamingDelivery('run-generation-1')); + }); + + it('TOOL_CALL_START creates a tool-call-only assistant slot owned by the active generation', () => { + const store = makeStore(); + reduceEvent({ + type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search', parentMessageId: 'tool-parent', + } as any, store); + expect(store.messages()[0].delivery).toEqual(streamingDelivery('run-generation-1')); }); it('MESSAGES_SNAPSHOT re-applies citations from prior STATE onto the final message', () => { @@ -90,6 +148,115 @@ describe('reduceEvent', () => { expect(msg?.citations?.[0]).toMatchObject({ id: 'ng-signals-overview', title: 'Signals — Angular guide' }); }); + it('restored MESSAGES_SNAPSHOT messages are static complete/success', () => { + const store = makeStore(); + store.deliveryRun = null; + reduceEvent({ + type: 'MESSAGES_SNAPSHOT', + messages: [ + { id: 'u1', role: 'user', content: 'hello' }, + { id: 'a1', role: 'assistant', content: 'restored' }, + ], + } as any, store); + expect(store.messages().map(message => message.delivery)).toEqual([ + staticDelivery('u1'), + staticDelivery('a1'), + ]); + }); + + it('in-run canonical snapshot preserves the current attempt generation without retagging history', () => { + const store = makeStore(); + store.messages.set([ + { id: 'u-old', role: 'user', content: 'old', delivery: staticDelivery('u-old') }, + { id: 'a-old', role: 'assistant', content: 'history', delivery: completeDelivery('prior-run', 'success') }, + { id: 'u-current', role: 'user', content: 'new', delivery: staticDelivery('u-current') }, + { id: 'chunk-id', role: 'assistant', content: 'streamed', delivery: streamingDelivery('run-generation-1') }, + ]); + store.deliveryRun!.baselineMessageIds = new Set(['u-old', 'a-old', 'u-current']); + store.deliveryRun!.ownedMessageIds.add('chunk-id'); + store.deliveryRun!.currentAssistantMessageId = 'chunk-id'; + + reduceEvent({ + type: 'MESSAGES_SNAPSHOT', + messages: [ + { id: 'u-old', role: 'user', content: 'old' }, + { id: 'a-old', role: 'assistant', content: 'history enriched' }, + { id: 'u-current', role: 'user', content: 'new' }, + { id: 'canonical-id', role: 'assistant', content: 'canonical' }, + ], + } as any, store); + + expect(store.messages().find(message => message.id === 'a-old')?.delivery) + .toEqual(completeDelivery('prior-run', 'success')); + expect(store.messages().find(message => message.id === 'canonical-id')?.delivery) + .toEqual(streamingDelivery('run-generation-1')); + expect(store.deliveryRun?.ownedMessageIds.has('canonical-id')).toBe(true); + expect(store.deliveryRun?.currentAssistantMessageId).toBe('canonical-id'); + }); + + it('owns a snapshot-only active assistant and finalizes it on RUN_FINISHED', () => { + const store = makeStore(); + store.messages.set([ + { id: 'u1', role: 'user', content: 'hello', delivery: staticDelivery('u1') }, + ]); + store.deliveryRun!.baselineMessageIds = new Set(['u1']); + reduceEvent({ type: 'RUN_STARTED', runId: 'run-1' } as any, store); + + reduceEvent({ + type: 'MESSAGES_SNAPSHOT', + messages: [ + { id: 'u1', role: 'user', content: 'hello' }, + { id: 'snapshot-ai', role: 'assistant', content: 'snapshot only' }, + ], + } as any, store); + + expect(store.messages().find(message => message.id === 'snapshot-ai')?.delivery) + .toEqual(streamingDelivery('run-generation-1')); + reduceEvent({ type: 'RUN_FINISHED', runId: 'run-1' } as any, store); + expect(store.messages().find(message => message.id === 'snapshot-ai')?.delivery) + .toEqual(completeDelivery('run-generation-1', 'success')); + }); + + it('retags only an explicitly eligible reused baseline tail', () => { + const store = makeStore(); + store.messages.set([ + { id: 'historical-ai', role: 'assistant', content: 'history', delivery: staticDelivery('historical-ai') }, + { id: 'u1', role: 'user', content: 'hello', delivery: staticDelivery('u1') }, + { id: 'tail-ai', role: 'assistant', content: 'prior', delivery: completeDelivery('prior-run', 'success') }, + ]); + store.deliveryRun!.baselineMessageIds = new Set(['historical-ai', 'u1', 'tail-ai']); + store.deliveryRun!.eligibleBaselineAssistantId = 'tail-ai'; + + reduceEvent({ + type: 'MESSAGES_SNAPSHOT', + messages: [ + { id: 'historical-ai', role: 'assistant', content: 'history enriched' }, + { id: 'u1', role: 'user', content: 'hello' }, + { id: 'tail-ai', role: 'assistant', content: 'replacement' }, + ], + } as any, store); + + expect(store.messages().find(message => message.id === 'historical-ai')?.delivery) + .toEqual(staticDelivery('historical-ai')); + expect(store.messages().find(message => message.id === 'tail-ai')?.delivery) + .toEqual(streamingDelivery('run-generation-1')); + }); + + it('does not retag baseline assistant history without continuation permission', () => { + const store = makeStore(); + store.messages.set([ + { id: 'tail-ai', role: 'assistant', content: 'prior', delivery: completeDelivery('prior-run', 'success') }, + ]); + store.deliveryRun!.baselineMessageIds = new Set(['tail-ai']); + + reduceEvent({ + type: 'MESSAGES_SNAPSHOT', + messages: [{ id: 'tail-ai', role: 'assistant', content: 'same history' }], + } as any, store); + + expect(store.messages()[0].delivery).toEqual(completeDelivery('prior-run', 'success')); + }); + it('TOOL_CALL_START appends a running tool call', () => { const store = makeStore(); reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as any, store); @@ -214,12 +381,15 @@ describe('reduceEvent', () => { it('MESSAGES_SNAPSHOT replaces messages wholesale', () => { const store = makeStore(); + store.deliveryRun = null; store.messages.set([{ id: 'old', role: 'user', content: 'old' }]); reduceEvent({ type: 'MESSAGES_SNAPSHOT', messages: [{ id: 'new', role: 'assistant', content: 'fresh' }], } as any, store); - expect(store.messages()).toEqual([{ id: 'new', role: 'assistant', content: 'fresh' }]); + expect(store.messages()).toEqual([ + { id: 'new', role: 'assistant', content: 'fresh', delivery: staticDelivery('new') }, + ]); }); it('MESSAGES_SNAPSHOT bridges assistant toolCalls to toolCallIds', () => { @@ -327,6 +497,7 @@ describe('reduceEvent — REASONING_MESSAGE_*', () => { expect(msgs[0].id).toBe('m1'); expect(msgs[0].role).toBe('assistant'); expect(msgs[0].reasoning).toBe(''); + expect(msgs[0].delivery).toEqual(streamingDelivery('run-generation-1')); }); it('REASONING_MESSAGE_CONTENT appends to the existing reasoning string', () => { @@ -335,6 +506,7 @@ describe('reduceEvent — REASONING_MESSAGE_*', () => { reduceEvent({ type: 'REASONING_MESSAGE_CONTENT', messageId: 'm1', delta: 'first ' } as any, store); reduceEvent({ type: 'REASONING_MESSAGE_CONTENT', messageId: 'm1', delta: 'then second' } as any, store); expect(store.messages()[0].reasoning).toBe('first then second'); + expect(store.messages()[0].delivery).toEqual(streamingDelivery('run-generation-1')); }); it('REASONING_MESSAGE_CHUNK is treated identically to CONTENT', () => { diff --git a/libs/ag-ui/src/lib/reducer.ts b/libs/ag-ui/src/lib/reducer.ts index e453e44cb..d57753167 100644 --- a/libs/ag-ui/src/lib/reducer.ts +++ b/libs/ag-ui/src/lib/reducer.ts @@ -5,7 +5,14 @@ // file has no runtime dependency on the EventType enum import. import { signal, type WritableSignal } from '@angular/core'; import type { Subject } from 'rxjs'; -import { toAgentError, type AgentError } from '@threadplane/chat'; +import { + completeDelivery, + staticDelivery, + streamingDelivery, + toAgentError, + type AgentError, + type CompleteOutcome, +} from '@threadplane/chat'; import type { Message, AgentStatus, ToolCall, AgentEvent, AgentInterrupt, } from '@threadplane/chat'; @@ -50,9 +57,20 @@ export interface CustomStreamEvent { export interface ActivityEntry { messageId: string; activityType: string; + generation: string; content: WritableSignal>; } +export interface ReducerDeliveryRun { + generation: string; + baselineMessageIds: Set; + ownedMessageIds: Set; + eligibleBaselineAssistantId?: string; + currentAssistantMessageId?: string; + protocolRunId?: string; + outcome?: CompleteOutcome; +} + export interface ReducerStore { messages: WritableSignal; status: WritableSignal; @@ -64,6 +82,8 @@ export interface ReducerStore { events$: Subject; customEvents: WritableSignal; activities: WritableSignal>; + deliveryRun: ReducerDeliveryRun | null; + allocateDeliveryGeneration(scope: string): string; /** Accumulated raw TOOL_CALL_ARGS text per toolCallId. A live model streams * args as many partial-JSON fragments, so each delta must be appended here * and the ACCUMULATED buffer parsed — parsing a lone delta only succeeds @@ -73,6 +93,22 @@ export interface ReducerStore { argsBuffers?: Map; } +/** Finalize one run without touching messages owned by another generation. */ +export function finalizeDeliveryRun( + store: ReducerStore, + run: ReducerDeliveryRun, + outcome: CompleteOutcome, +): boolean { + if (run.outcome !== undefined) return false; + run.outcome = outcome; + store.messages.update(messages => messages.map(message => + message.delivery.generation === run.generation + ? { ...message, delivery: completeDelivery(run.generation, outcome) } + : message, + )); + return true; +} + /** * Per-message reasoning timing. Populated by REASONING_MESSAGE_START / * REASONING_MESSAGE_END handlers. The map lives on the module — same @@ -100,6 +136,8 @@ function resolveReasoningDurationMs(messageId: string): number | undefined { export function reduceEvent(event: BaseEvent, store: ReducerStore): void { switch (event.type) { case 'RUN_STARTED': { + const run = store.deliveryRun; + if (!run || run.outcome !== undefined || !bindRunId(event, run)) return; store.status.set('running'); store.isLoading.set(true); store.error.set(undefined); @@ -109,11 +147,15 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { return; } case 'RUN_FINISHED': { + const run = currentRunForEvent(event, store); + if (!run || !finalizeDeliveryRun(store, run, 'success')) return; store.status.set('idle'); store.isLoading.set(false); return; } case 'RUN_ERROR': { + const run = currentRunForEvent(event, store); + if (!run || !finalizeDeliveryRun(store, run, 'error')) return; store.status.set('error'); store.isLoading.set(false); const runErrorMsg = (event as { message?: unknown }).message; @@ -124,33 +166,39 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { } case 'TEXT_MESSAGE_START': { const id = messageIdFrom(event); + const delivery = ownAssistantMessage(store, id); + if (!delivery) return; store.messages.update((prev) => prev.some((m) => m.id === id) - ? prev.map((m) => m.id === id ? { ...m, content: m.content ?? '' } : m) - : [...prev, { id, role: 'assistant', content: '' }], + ? prev.map((m) => m.id === id ? { ...m, content: m.content ?? '', delivery } : m) + : [...prev, { id, role: 'assistant', content: '', delivery }], ); return; } case 'REASONING_MESSAGE_START': { const id = messageIdFrom(event); + const delivery = ownAssistantMessage(store, id); + if (!delivery) return; reasoningTimingMap.set(id, { startedAt: Date.now() }); // Initialize an assistant slot with empty reasoning if it doesn't already exist. store.messages.update((prev) => prev.some((m) => m.id === id) ? prev.map((m) => m.id === id - ? { ...m, reasoning: m.reasoning ?? '' } + ? { ...m, reasoning: m.reasoning ?? '', delivery } : m) - : [...prev, { id, role: 'assistant', content: '', reasoning: '' }], + : [...prev, { id, role: 'assistant', content: '', reasoning: '', delivery }], ); return; } case 'REASONING_MESSAGE_CONTENT': case 'REASONING_MESSAGE_CHUNK': { const id = messageIdFrom(event); + const delivery = ownAssistantMessage(store, id); + if (!delivery) return; const delta = (event as { delta?: string }).delta ?? ''; store.messages.update((prev) => prev.map((m) => m.id === id - ? { ...m, reasoning: (m.reasoning ?? '') + delta } + ? { ...m, reasoning: (m.reasoning ?? '') + delta, delivery } : m), ); return; @@ -172,9 +220,11 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { } case 'TEXT_MESSAGE_CONTENT': { const id = messageIdFrom(event); + const delivery = ownAssistantMessage(store, id); + if (!delivery) return; const delta = (event as { delta?: string }).delta ?? ''; store.messages.update((prev) => - prev.map((m) => m.id === id ? { ...m, content: m.content + delta } : m), + prev.map((m) => m.id === id ? { ...m, content: m.content + delta, delivery } : m), ); return; } @@ -196,16 +246,18 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { // tool-call-only turn emits no TEXT_MESSAGE_START), create a slot. const parentId = e.parentMessageId; if (parentId) { + const delivery = ownAssistantMessage(store, parentId); + if (!delivery) return; store.messages.update((prev) => { const existing = prev.find((m) => m.id === parentId); if (existing) { return prev.map((m) => m.id === parentId - ? { ...m, toolCallIds: [...(m.toolCallIds ?? []), e.toolCallId] } + ? { ...m, toolCallIds: [...(m.toolCallIds ?? []), e.toolCallId], delivery } : m, ); } - return [...prev, { id: parentId, role: 'assistant', content: '', toolCallIds: [e.toolCallId] }]; + return [...prev, { id: parentId, role: 'assistant', content: '', toolCallIds: [e.toolCallId], delivery }]; }); } return; @@ -272,6 +324,9 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { case 'MESSAGES_SNAPSHOT': { const e = event as unknown as { messages: AgUiSnapshotMessage[] }; const raw = e.messages ?? []; + const previousById = new Map(store.messages().map(message => [message.id, message])); + const run = store.deliveryRun?.outcome === undefined ? store.deliveryRun : null; + const canonicalAssistantId = resolveCanonicalAssistantId(raw, run); // AG-UI AssistantMessage carries `toolCalls` (ToolCall objects) on the // snapshot wire. Bridge them to `toolCallIds` so that the chat lib's // per-message tool-call resolution (resolveMessageToolCalls) can scope @@ -279,8 +334,14 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { // so the data is visible to . const snapshotToolCalls: ToolCall[] = []; const messages: Message[] = raw.map((m) => { + let delivery = previousById.get(m.id)?.delivery ?? staticDelivery(m.id); + if (run && (run.ownedMessageIds.has(m.id) || m.id === canonicalAssistantId)) { + delivery = streamingDelivery(run.generation); + run.ownedMessageIds.add(m.id); + run.currentAssistantMessageId = m.id; + } if (m.role !== 'assistant' || !m.toolCalls || m.toolCalls.length === 0) { - return m as unknown as Message; + return { ...m, delivery } as unknown as Message; } const ids: string[] = []; for (const tc of m.toolCalls) { @@ -293,7 +354,7 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { }); } const { toolCalls: _dropped, ...rest } = m; - return { ...rest, toolCallIds: ids } as unknown as Message; + return { ...rest, toolCallIds: ids, delivery } as unknown as Message; }); // Re-apply per-message citations from the already-received STATE. A // MESSAGES_SNAPSHOT replaces the streamed messages wholesale — and the @@ -341,12 +402,14 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { }; const map = new Map(store.activities()); const existing = map.get(e.messageId); - if (existing && existing.activityType === e.activityType && !e.replace) { - existing.content.update((c) => ({ ...c, ...e.content })); + if (existing && existing.activityType === e.activityType) { + if (e.replace) existing.content.set(e.content ?? {}); + else existing.content.update((c) => ({ ...c, ...e.content })); } else { map.set(e.messageId, { messageId: e.messageId, activityType: e.activityType, + generation: store.allocateDeliveryGeneration(`activity:${e.messageId}`), content: signal>(e.content ?? {}), }); } @@ -383,6 +446,58 @@ function randomId(): string { return Math.random().toString(36).slice(2); } +function eventRunId(event: BaseEvent): string | undefined { + const runId = (event as { runId?: unknown }).runId; + return typeof runId === 'string' ? runId : undefined; +} + +function bindRunId(event: BaseEvent, run: ReducerDeliveryRun): boolean { + const runId = eventRunId(event); + if (!runId) return true; + if (run.protocolRunId && run.protocolRunId !== runId) return false; + run.protocolRunId = runId; + return true; +} + +function currentRunForEvent(event: BaseEvent, store: ReducerStore): ReducerDeliveryRun | null { + const run = store.deliveryRun; + if (!run || !bindRunId(event, run)) return null; + return run; +} + +function ownAssistantMessage(store: ReducerStore, id: string) { + const run = store.deliveryRun; + if (!run || run.outcome !== undefined) return undefined; + run.ownedMessageIds.add(id); + run.currentAssistantMessageId = id; + return streamingDelivery(run.generation); +} + +function resolveCanonicalAssistantId( + messages: readonly AgUiSnapshotMessage[], + run: ReducerDeliveryRun | null, +): string | undefined { + if (!run) return undefined; + if ( + run.currentAssistantMessageId + && messages.some(message => message.id === run.currentAssistantMessageId) + ) { + return run.currentAssistantMessageId; + } + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role === 'assistant' && !run.baselineMessageIds.has(message.id)) { + return message.id; + } + } + return run.eligibleBaselineAssistantId + && messages.some(message => + message.role === 'assistant' && message.id === run.eligibleBaselineAssistantId + ) + ? run.eligibleBaselineAssistantId + : undefined; +} + function messageIdFrom(event: BaseEvent): string { return (event as { messageId?: string }).messageId ?? 'unknown'; } diff --git a/libs/ag-ui/src/lib/to-agent.conformance.spec.ts b/libs/ag-ui/src/lib/to-agent.conformance.spec.ts index d3ff74018..c04bd8cdd 100644 --- a/libs/ag-ui/src/lib/to-agent.conformance.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.conformance.spec.ts @@ -76,7 +76,16 @@ describe('AG-UI reducer — reasoning-fixture conformance', () => { error: signal(undefined), toolCalls: signal([]), state: signal>({}), + interrupt: signal(undefined), events$: new Subject(), + customEvents: signal([]), + activities: signal(new Map()), + deliveryRun: { + generation: 'reasoning-fixture-run', + baselineMessageIds: new Set(), + ownedMessageIds: new Set(), + }, + allocateDeliveryGeneration: (scope: string) => `reasoning-fixture:${scope}`, }; for (const evt of REASONING_FIXTURE_EVENTS) { reduceEvent(abstractToAgUi(evt, REASONING_FIXTURE_MESSAGE_ID), store); diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts index 03bbb22a9..f8060e3c8 100644 --- a/libs/ag-ui/src/lib/to-agent.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -3,7 +3,12 @@ import { describe, it, expect, vi } from 'vitest'; import { Observable, Subject } from 'rxjs'; import type { AbstractAgent, BaseEvent } from '@ag-ui/client'; import type { RunAgentInput } from '@ag-ui/core'; -import { AgentError, type AgentRuntimeTelemetryPayload } from '@threadplane/chat'; +import { + AgentError, + completeDelivery, + staticDelivery, + type AgentRuntimeTelemetryPayload, +} from '@threadplane/chat'; import { toAgent } from './to-agent'; /** @@ -27,24 +32,38 @@ class StubAgent { state: Record = {}; // Simulate subscriber list just like AbstractAgent does - private readonly _subscribers: Array<{ onEvent?: (p: { event: BaseEvent }) => void; onRunFailed?: (p: { error: Error }) => void }> = []; - - subscribe(sub: { onEvent?: (p: { event: BaseEvent }) => void; onRunFailed?: (p: { error: Error }) => void }) { + private readonly _subscribers: Array<{ + onRunInitialized?: (p: { input: { runId?: string } }) => void; + onEvent?: (p: { event: BaseEvent; input: { runId?: string } }) => void; + onRunFailed?: (p: { error: Error; input: { runId?: string } }) => void; + }> = []; + + subscribe(sub: { + onRunInitialized?: (p: { input: { runId?: string } }) => void; + onEvent?: (p: { event: BaseEvent; input: { runId?: string } }) => void; + onRunFailed?: (p: { error: Error; input: { runId?: string } }) => void; + }) { this._subscribers.push(sub); return { unsubscribe: () => { /* no-op for tests */ } }; } + initializeRun(runId: string): void { + for (const sub of this._subscribers) { + sub.onRunInitialized?.({ input: { runId } }); + } + } + /** Convenience: push an event to all subscribers. */ - emit(event: BaseEvent): void { + emit(event: BaseEvent, callbackRunId?: string): void { for (const sub of this._subscribers) { - sub.onEvent?.({ event }); + sub.onEvent?.({ event, input: { runId: callbackRunId } }); } } /** Convenience: fail the run by calling onRunFailed on all subscribers. */ - failRun(error: Error): void { + failRun(error: Error, callbackRunId?: string): void { for (const sub of this._subscribers) { - sub.onRunFailed?.({ error }); + sub.onRunFailed?.({ error, input: { runId: callbackRunId } }); } } @@ -68,6 +87,18 @@ class StubAgent { } } +function deferNextRun(source: StubAgent): { resolve: () => void; reject: (error: Error) => void } { + let resolve!: () => void; + let reject!: (error: Error) => void; + source.runAgent.mockImplementationOnce( + () => new Promise((done, fail) => { + resolve = () => done({ result: undefined, newMessages: [] }); + reject = fail; + }), + ); + return { resolve: () => resolve(), reject: error => reject(error) }; +} + describe('toAgent', () => { it('starts with idle status and no messages', () => { const stub = new StubAgent(); @@ -80,6 +111,7 @@ describe('toAgent', () => { it('reduces RUN_STARTED into running status', () => { const stub = new StubAgent(); const a = toAgent(stub as unknown as AbstractAgent); + void a.submit({}); stub.emit({ type: 'RUN_STARTED' } as BaseEvent); expect(a.status()).toBe('running'); expect(a.isLoading()).toBe(true); @@ -88,6 +120,7 @@ describe('toAgent', () => { it('reduces RUN_FINISHED into idle status', () => { const stub = new StubAgent(); const a = toAgent(stub as unknown as AbstractAgent); + void a.submit({}); stub.emit({ type: 'RUN_STARTED' } as BaseEvent); stub.emit({ type: 'RUN_FINISHED' } as BaseEvent); expect(a.status()).toBe('idle'); @@ -99,6 +132,7 @@ describe('toAgent', () => { const a = toAgent(stub as unknown as AbstractAgent); void a.submit({ message: 'hello' }); expect(a.messages()[0]).toEqual(expect.objectContaining({ role: 'user', content: 'hello' })); + expect(a.messages()[0].delivery).toEqual(staticDelivery(a.messages()[0].id)); }); it('syncs user message to source.addMessage()', async () => { @@ -162,6 +196,56 @@ describe('toAgent', () => { expect(JSON.stringify(seen)).not.toContain('private app state'); }); + it.each(['resolve', 'reject'] as const)( + 'reports a superseded run as stream_errored exactly once when it later %s', + async (lateOutcome) => { + const source = new StubAgent(); + const seen: AgentRuntimeTelemetryPayload[] = []; + const runA = deferNextRun(source); + const agent = toAgent(source as never, { telemetry: payload => seen.push(payload) }); + const pendingA = agent.submit({ message: 'first' }); + source.initializeRun('telemetry-a'); + source.emit({ type: 'RUN_STARTED' } as BaseEvent, 'telemetry-a'); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-a', role: 'assistant' } as never, 'telemetry-a'); + + const runB = deferNextRun(source); + const pendingB = agent.submit({ message: 'second' }); + source.initializeRun('telemetry-b'); + source.emit({ type: 'RUN_STARTED' } as BaseEvent, 'telemetry-b'); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-b', role: 'assistant' } as never, 'telemetry-b'); + const runBDelivery = agent.messages().find(message => message.id === 'ai-b')!.delivery; + + const terminalEvents = () => seen.filter(payload => + payload.event === 'tplane:stream_ended' || payload.event === 'tplane:stream_errored' + ); + expect(terminalEvents()).toEqual([ + expect.objectContaining({ + event: 'tplane:stream_errored', + properties: expect.objectContaining({ errorClass: 'InterruptedError' }), + }), + ]); + + if (lateOutcome === 'resolve') runA.resolve(); + else runA.reject(new Error('late rejection')); + await pendingA; + source.emit({ type: 'RUN_ERROR', message: 'late run error' } as never, 'telemetry-a'); + source.failRun(new Error('late onRunFailed'), 'telemetry-a'); + + expect(terminalEvents()).toHaveLength(1); + expect(agent.status()).toBe('running'); + expect(agent.error()).toBeUndefined(); + expect(agent.messages().find(message => message.id === 'ai-b')?.delivery).toEqual(runBDelivery); + + source.emit({ type: 'RUN_FINISHED' } as BaseEvent, 'telemetry-b'); + runB.resolve(); + await pendingB; + expect(terminalEvents().map(payload => payload.event)).toEqual([ + 'tplane:stream_errored', + 'tplane:stream_ended', + ]); + }, + ); + it('stop() calls source.abortRun()', async () => { const stub = new StubAgent(); const a = toAgent(stub as unknown as AbstractAgent); @@ -271,7 +355,311 @@ describe('toAgent', () => { }); }); + describe('message delivery lifecycle', () => { + it.each(['submit', 'retry', 'resume', 'regenerate'] as const)( + '%s finalizes streamed messages as interrupted when transport resolves without a terminal event', + async (operation) => { + const source = new StubAgent(); + const agent = toAgent(source as never); + + if (operation === 'retry') { + await agent.submit({ message: 'seed retry' }); + } else if (operation === 'regenerate') { + const seedRun = deferNextRun(source); + const seedPending = agent.submit({ message: 'seed regenerate' }); + source.emit({ type: 'RUN_STARTED' } as BaseEvent, 'seed-run'); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'seed-ai', role: 'assistant' } as never, 'seed-run'); + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'seed-ai', delta: 'seed' } as never, 'seed-run'); + source.emit({ type: 'RUN_FINISHED' } as BaseEvent, 'seed-run'); + seedRun.resolve(); + await seedPending; + } + + const deferred = deferNextRun(source); + const pending = operation === 'submit' + ? agent.submit({ message: 'hello' }) + : operation === 'retry' + ? agent.retry() + : operation === 'resume' + ? agent.submit({ resume: { approved: true } }) + : agent.regenerate(1); + const runId = `close-${operation}`; + const messageId = `ai-${operation}`; + source.emit({ type: 'RUN_STARTED' } as BaseEvent, runId); + source.emit({ type: 'TEXT_MESSAGE_START', messageId, role: 'assistant' } as never, runId); + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId, delta: 'partial' } as never, runId); + const generation = agent.messages().find(message => message.id === messageId)!.delivery.generation; + + deferred.resolve(); + await pending; + + expect(agent.messages().find(message => message.id === messageId)?.delivery) + .toEqual(completeDelivery(generation, 'interrupted')); + expect(agent.status()).toBe('idle'); + expect(agent.isLoading()).toBe(false); + expect(agent.error()).toBeUndefined(); + }, + ); + + it('settles a terminal-event-free run with no assistant chunks as successful and idle', async () => { + const source = new StubAgent(); + const deferred = deferNextRun(source); + const agent = toAgent(source as never); + const pending = agent.submit({ message: 'hello' }); + source.emit({ type: 'RUN_STARTED' } as BaseEvent, 'empty-close'); + + deferred.resolve(); + await pending; + + expect(agent.status()).toBe('idle'); + expect(agent.isLoading()).toBe(false); + expect(agent.error()).toBeUndefined(); + expect(agent.messages().filter(message => message.role === 'assistant')).toEqual([]); + }); + + it('does not let an old transport resolution settle a newer run', async () => { + const source = new StubAgent(); + const runA = deferNextRun(source); + const agent = toAgent(source as never); + const pendingA = agent.submit({ message: 'first' }); + source.emit({ type: 'RUN_STARTED' } as BaseEvent, 'late-a'); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-a', role: 'assistant' } as never, 'late-a'); + + const runB = deferNextRun(source); + const pendingB = agent.submit({ message: 'second' }); + source.emit({ type: 'RUN_STARTED' } as BaseEvent, 'current-b'); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-b', role: 'assistant' } as never, 'current-b'); + const before = agent.messages().find(message => message.id === 'ai-b')!.delivery; + + runA.resolve(); + await pendingA; + + expect(agent.status()).toBe('running'); + expect(agent.isLoading()).toBe(true); + expect(agent.messages().find(message => message.id === 'ai-b')?.delivery).toEqual(before); + runB.resolve(); + await pendingB; + }); + + it('onRunFailed finalizes the active generation as error', async () => { + const source = new StubAgent(); + const deferred = deferNextRun(source); + const agent = toAgent(source as never); + const pending = agent.submit({ message: 'hello' }); + source.emit({ type: 'RUN_STARTED', runId: 'run-1' } as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-1', role: 'assistant' } as never); + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'ai-1', delta: 'partial' } as never); + const generation = agent.messages().find(message => message.id === 'ai-1')!.delivery.generation; + + source.failRun(new Error('transport failed')); + deferred.resolve(); + await pending; + + expect(agent.messages().find(message => message.id === 'ai-1')?.delivery) + .toEqual(completeDelivery(generation, 'error')); + expect(agent.status()).toBe('error'); + }); + + it('deduplicates RUN_ERROR followed by onRunFailed without changing the terminal outcome', async () => { + const source = new StubAgent(); + const deferred = deferNextRun(source); + const agent = toAgent(source as never); + const pending = agent.submit({ message: 'hello' }); + source.emit({ type: 'RUN_STARTED', runId: 'run-1' } as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-1', role: 'assistant' } as never); + const generation = agent.messages().find(message => message.id === 'ai-1')!.delivery.generation; + + source.emit({ type: 'RUN_ERROR', runId: 'run-1', message: 'first failure' } as never); + source.failRun(new Error('duplicate failure')); + deferred.resolve(); + await pending; + + expect(agent.messages().find(message => message.id === 'ai-1')?.delivery) + .toEqual(completeDelivery(generation, 'error')); + expect(agent.error()?.message).toContain('first failure'); + }); + + it('does not let a stale onRunFailed duplicate corrupt a newer run', async () => { + const source = new StubAgent(); + const agent = toAgent(source as never); + const runA = deferNextRun(source); + const pendingA = agent.submit({ message: 'first' }); + source.emit({ type: 'RUN_STARTED', runId: 'run-1' } as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-1', role: 'assistant' } as never); + source.emit({ type: 'RUN_ERROR', runId: 'run-1', message: 'old failure' } as never); + runA.resolve(); + await pendingA; + + const runB = deferNextRun(source); + const pendingB = agent.submit({ message: 'second' }); + source.emit({ type: 'RUN_STARTED', runId: 'run-2' } as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-2', role: 'assistant' } as never); + const runBDelivery = agent.messages().find(message => message.id === 'ai-2')!.delivery; + + source.failRun(new Error('duplicate old failure'), 'run-1'); + + expect(agent.status()).toBe('running'); + expect(agent.isLoading()).toBe(true); + expect(agent.messages().find(message => message.id === 'ai-2')?.delivery).toEqual(runBDelivery); + runB.resolve(); + await pendingB; + }); + + it('routes every stale callback to its originating protocol run', async () => { + const source = new StubAgent(); + const agent = toAgent(source as never); + const runA = deferNextRun(source); + const pendingA = agent.submit({ message: 'first' }); + source.emit({ type: 'RUN_STARTED' } as BaseEvent, 'run-a'); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-a', role: 'assistant' } as never, 'run-a'); + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'ai-a', delta: 'old' } as never, 'run-a'); + source.emit({ type: 'RUN_ERROR', message: 'old failure' } as never, 'run-a'); + runA.resolve(); + await pendingA; + + const runB = deferNextRun(source); + const pendingB = agent.submit({ message: 'second' }); + source.emit({ type: 'RUN_STARTED' } as BaseEvent, 'run-b'); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-b', role: 'assistant' } as never, 'run-b'); + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'ai-b', delta: 'current' } as never, 'run-b'); + const beforeMessages = agent.messages(); + const beforeDelivery = beforeMessages.find(message => message.id === 'ai-b')!.delivery; + + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'late-a', delta: 'stale' } as never, 'run-a'); + source.emit({ type: 'RUN_ERROR', message: 'duplicate old failure' } as never, 'run-a'); + source.emit({ type: 'RUN_ERROR', message: 'extra duplicate old failure' } as never, 'run-a'); + source.failRun(new Error('duplicate old failure'), 'run-a'); + source.failRun(new Error('extra duplicate old failure'), 'run-a'); + + expect(agent.messages()).toEqual(beforeMessages); + expect(agent.messages().find(message => message.id === 'ai-b')?.delivery).toEqual(beforeDelivery); + expect(agent.status()).toBe('running'); + expect(agent.isLoading()).toBe(true); + expect(agent.error()).toBeUndefined(); + runB.resolve(); + await pendingB; + }); + + it('binds eventless runs during initialization before a lone stale failure', async () => { + const source = new StubAgent(); + const runA = deferNextRun(source); + const agent = toAgent(source as never); + const pendingA = agent.submit({ message: 'first' }); + source.initializeRun('eventless-a'); + + const runB = deferNextRun(source); + const pendingB = agent.submit({ message: 'second' }); + source.initializeRun('eventless-b'); + const beforeMessages = agent.messages(); + + source.failRun(new Error('late eventless failure'), 'eventless-a'); + + expect(agent.messages()).toEqual(beforeMessages); + expect(agent.status()).toBe('idle'); + expect(agent.error()).toBeUndefined(); + runA.resolve(); + runB.resolve(); + await Promise.all([pendingA, pendingB]); + }); + + it.each(['retry', 'regenerate', 'resume'] as const)( + '%s allocates a fresh generation when an assistant message id is reused', + async (operation) => { + const source = new StubAgent(); + const agent = toAgent(source as never); + const firstRun = deferNextRun(source); + const firstPending = agent.submit({ message: 'hello' }); + source.emit({ type: 'RUN_STARTED', runId: 'run-1' } as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-reused', role: 'assistant' } as never); + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'ai-reused', delta: 'first' } as never); + source.emit({ type: 'RUN_FINISHED', runId: 'run-1' } as BaseEvent); + firstRun.resolve(); + await firstPending; + const firstGeneration = agent.messages().find(message => message.id === 'ai-reused')!.delivery.generation; + + const secondRun = deferNextRun(source); + const secondPending = operation === 'retry' + ? agent.retry() + : operation === 'regenerate' + ? agent.regenerate(1) + : agent.submit({ resume: { approved: true } }); + source.emit({ type: 'RUN_STARTED', runId: 'run-2' } as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-reused', role: 'assistant' } as never); + const nextDelivery = agent.messages().find(message => message.id === 'ai-reused')!.delivery; + + expect(nextDelivery.phase).toBe('streaming'); + expect(nextDelivery.generation).not.toBe(firstGeneration); + secondRun.resolve(); + await secondPending; + }, + ); + + it.each(['retry', 'resume'] as const)( + '%s gives a reused canonical snapshot tail a fresh generation', + async (operation) => { + const source = new StubAgent(); + const seedRun = deferNextRun(source); + const agent = toAgent(source as never); + const seedPending = agent.submit({ message: 'hello' }); + const userId = agent.messages().find(message => message.role === 'user')!.id; + source.emit({ type: 'RUN_STARTED' } as BaseEvent, 'seed-snapshot'); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'reused-ai', role: 'assistant' } as never, 'seed-snapshot'); + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'reused-ai', delta: 'prior' } as never, 'seed-snapshot'); + source.emit({ type: 'RUN_FINISHED' } as BaseEvent, 'seed-snapshot'); + seedRun.resolve(); + await seedPending; + const priorGeneration = agent.messages().find(message => message.id === 'reused-ai')!.delivery.generation; + + const nextRun = deferNextRun(source); + const pending = operation === 'retry' + ? agent.retry() + : agent.submit({ resume: { approved: true } }); + source.emit({ type: 'RUN_STARTED' } as BaseEvent, `snapshot-${operation}`); + source.emit({ + type: 'MESSAGES_SNAPSHOT', + messages: [ + { id: userId, role: 'user', content: 'hello' }, + { id: 'reused-ai', role: 'assistant', content: 'replacement' }, + ], + } as never, `snapshot-${operation}`); + const replacement = agent.messages().find(message => message.id === 'reused-ai')!.delivery; + + expect(replacement.phase).toBe('streaming'); + expect(replacement.generation).not.toBe(priorGeneration); + source.emit({ type: 'RUN_FINISHED' } as BaseEvent, `snapshot-${operation}`); + nextRun.resolve(); + await pending; + expect(agent.messages().find(message => message.id === 'reused-ai')?.delivery) + .toEqual(completeDelivery(replacement.generation, 'success')); + }, + ); + }); + describe('stop() — graceful cancellation (F3)', () => { + it('stop immediately finalizes the active generation as aborted', async () => { + const source = new StubAgent(); + let resolveRun!: () => void; + source.runAgent.mockImplementation( + () => new Promise((resolve) => { + resolveRun = () => resolve({ result: undefined, newMessages: [] }); + }), + ); + const agent = toAgent(source as never); + const pending = agent.submit({ message: 'long story' }); + source.emit({ type: 'RUN_STARTED', runId: 'run-1' } as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-1', role: 'assistant' } as never); + const generation = agent.messages().find(message => message.id === 'ai-1')!.delivery.generation; + + await agent.stop!(); + + expect(agent.messages().find(message => message.id === 'ai-1')?.delivery) + .toEqual(completeDelivery(generation, 'aborted')); + expect(agent.status()).toBe('idle'); + expect(agent.isLoading()).toBe(false); + resolveRun(); + await pending; + }); + it('treats an abort-induced onRunFailed as cancellation, not error', async () => { const source = new StubAgent(); // Keep the run in flight so stop() races it like a real stream. @@ -288,7 +676,7 @@ describe('toAgent', () => { expect(source.abortRun).toHaveBeenCalledTimes(1); // HttpAgent surfaces the abort as a run failure. - source.failRun(new Error('BodyStreamBuffer was aborted')); + source.failRun(new Error('BodyStreamBuffer was aborted'), 'run-a'); resolveRun(); await pending; @@ -347,14 +735,52 @@ describe('toAgent', () => { expect(agent.isLoading()).toBe(false); }); + it('ignores a stale duplicate abort after a new run has started', async () => { + const source = new StubAgent(); + let resolveRunA!: () => void; + let resolveRunB!: () => void; + source.runAgent + .mockImplementationOnce(() => new Promise((resolve) => { + resolveRunA = () => resolve({ result: undefined, newMessages: [] }); + })) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveRunB = () => resolve({ result: undefined, newMessages: [] }); + })); + const agent = toAgent(source as never); + + const pendingA = agent.submit({ message: 'first' }); + source.emit({ type: 'RUN_STARTED', runId: 'run-a' } as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-reused', role: 'assistant' } as never); + await agent.stop!(); + resolveRunA(); + await pendingA; + + const pendingB = agent.submit({ message: 'second' }); + source.emit({ type: 'RUN_STARTED', runId: 'run-b' } as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-reused', role: 'assistant' } as never); + const runBDelivery = agent.messages().find(message => message.id === 'ai-reused')!.delivery; + + source.failRun(new Error('BodyStreamBuffer was aborted'), 'run-a'); + + expect(agent.status()).toBe('running'); + expect(agent.isLoading()).toBe(true); + expect(agent.messages().find(message => message.id === 'ai-reused')?.delivery).toEqual(runBDelivery); + resolveRunB(); + await pendingB; + }); + it('still surfaces real failures as errors after a previous stop', async () => { const source = new StubAgent(); const agent = toAgent(source as never); // A stop on an earlier run must not swallow later genuine failures. await agent.stop!(); - await agent.submit({ message: 'hi' }); // submit resets the abort flag + const deferred = deferNextRun(source); + const pending = agent.submit({ message: 'hi' }); + source.emit({ type: 'RUN_STARTED' } as BaseEvent, 'real-failure'); source.failRun(new Error('boom')); + deferred.resolve(); + await pending; expect(agent.status()).toBe('error'); expect(agent.error()).toBeInstanceOf(Error); @@ -427,11 +853,14 @@ describe('toAgent', () => { const a = toAgent(stub as unknown as AbstractAgent); // Seed 2 messages: user then assistant - await a.submit({ message: 'hello' }); + const seedRun = deferNextRun(stub); + const seedPending = a.submit({ message: 'hello' }); stub.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-1', role: 'assistant' } as unknown as BaseEvent); stub.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'ai-1', delta: 'hi there' } as unknown as BaseEvent); stub.emit({ type: 'TEXT_MESSAGE_END', messageId: 'ai-1' } as unknown as BaseEvent); stub.emit({ type: 'RUN_FINISHED' } as BaseEvent); + seedRun.resolve(); + await seedPending; stub.runAgent.mockResolvedValue({ result: undefined, newMessages: [] }); expect(a.messages()).toHaveLength(2); @@ -465,6 +894,7 @@ describe('toAgent', () => { it('throws when agent is loading', async () => { const stub = new StubAgent(); const a = toAgent(stub as unknown as AbstractAgent); + void a.submit({ message: 'seed' }); stub.emit({ type: 'RUN_STARTED' } as BaseEvent); // isLoading is now true await expect(a.regenerate(0)).rejects.toThrow(/loading/); @@ -535,12 +965,15 @@ describe('toAgent', () => { const a = toAgent(stub as unknown as AbstractAgent); // Initial submit appends a user message and runs. - await a.submit({ message: 'hello' }); + const deferred = deferNextRun(stub); + const pending = a.submit({ message: 'hello' }); const countAfterSubmit = a.messages().length; expect(stub.runAgent).toHaveBeenCalledTimes(1); // Simulate a failure. stub.failRun(new Error('HTTP 503 Service Unavailable')); + deferred.resolve(); + await pending; expect(a.error()).toBeInstanceOf(AgentError); // Retry: should clear error and call runAgent again, no new user message. @@ -557,11 +990,12 @@ describe('toAgent', () => { const stub = new StubAgent(); const a = toAgent(stub as unknown as AbstractAgent); // Simulate a run in progress. + void a.submit({ message: 'seed' }); stub.emit({ type: 'RUN_STARTED' } as BaseEvent); expect(a.isLoading()).toBe(true); void a.retry(); - // runAgent should NOT have been called (nothing submitted, and loading guard). - expect(stub.runAgent).not.toHaveBeenCalled(); + // retry must not add another runAgent call while the submit is loading. + expect(stub.runAgent).toHaveBeenCalledTimes(1); }); it('retry() is a no-op when no prior input exists', async () => { @@ -587,7 +1021,9 @@ describe('subagents projection (F5)', () => { expect(sa?.toolCallId).toBe('tc-1'); expect(sa?.name).toBe('research'); expect(sa?.status()).toBe('running'); - expect(sa?.messages()).toEqual([{ id: 'tc-1', role: 'assistant', content: '' }]); + expect(sa?.messages()).toEqual([ + { id: 'tc-1', role: 'assistant', content: '', delivery: expect.objectContaining({ phase: 'streaming' }) }, + ]); }); it('text deltas flow into the subagent message; finished flips status', () => { const source = new StubAgent(); @@ -600,8 +1036,37 @@ describe('subagents projection (F5)', () => { source.emit({ type: 'ACTIVITY_DELTA', messageId: 'tc-1', activityType: 'subagent', patch: [{ op: 'replace', path: '/status', value: 'complete' }] } as never); expect(agent.subagents!().get('tc-1')?.status()).toBe('complete'); + const completed = agent.subagents!().get('tc-1')?.messages()[0].delivery; + expect(completed).toEqual(completeDelivery(completed!.generation, 'success')); expect(agent.subagents!().get('tc-1')).toBe(before); // stable identity across deltas }); + it('finalizes assistant subagent messages as error', () => { + const source = new StubAgent(); + const agent = toAgent(source as never); + source.emit(snapshot('tc-1', 'research') as never); + const generation = agent.subagents!().get('tc-1')!.messages()[0].delivery.generation; + source.emit({ type: 'ACTIVITY_DELTA', messageId: 'tc-1', activityType: 'subagent', + patch: [{ op: 'replace', path: '/status', value: 'error' }] } as never); + expect(agent.subagents!().get('tc-1')?.messages()[0].delivery) + .toEqual(completeDelivery(generation, 'error')); + }); + it('keeps one invocation generation across replacement snapshots', () => { + const source = new StubAgent(); + const agent = toAgent(source as never); + source.emit(snapshot('tc-1', 'research') as never); + const before = agent.subagents!().get('tc-1'); + const generation = before!.messages()[0].delivery.generation; + + source.emit({ + ...snapshot('tc-1', 'research'), + content: { toolCallId: 'tc-1', name: 'research', status: 'running', text: 'replacement' }, + } as never); + + const after = agent.subagents!().get('tc-1'); + expect(after).toBe(before); + expect(after?.messages()[0].content).toBe('replacement'); + expect(after?.messages()[0].delivery.generation).toBe(generation); + }); it('ignores non-subagent activityTypes', () => { const source = new StubAgent(); const agent = toAgent(source as never); @@ -613,15 +1078,36 @@ describe('subagents projection (F5)', () => { const source = new StubAgent(); const agent = toAgent(source as never); source.emit(snapshot('tc-1', 'research') as never); + const firstGeneration = agent.subagents!().get('tc-1')!.messages()[0].delivery.generation; source.emit({ type: 'ACTIVITY_DELTA', messageId: 'tc-1', activityType: 'subagent', patch: [{ op: 'replace', path: '/text', value: 'old run text' }] } as never); expect(agent.subagents!().get('tc-1')?.messages()[0].content).toBe('old run text'); // New run resets activities; reuse the same id. + void agent.submit({}); source.emit({ type: 'RUN_STARTED' } as never); expect(agent.subagents!().size).toBe(0); // pruned source.emit(snapshot('tc-1', 'research') as never); // Fresh wrapper bound to the NEW content signal — no stale 'old run text'. expect(agent.subagents!().get('tc-1')?.messages()[0].content).toBe(''); + expect(agent.subagents!().get('tc-1')?.messages()[0].delivery.generation).not.toBe(firstGeneration); + }); + it('rebuilds a same-id wrapper after reset even when the empty map was never observed', () => { + const source = new StubAgent(); + const agent = toAgent(source as never); + source.emit(snapshot('tc-1', 'research') as never); + source.emit({ type: 'ACTIVITY_DELTA', messageId: 'tc-1', activityType: 'subagent', + patch: [{ op: 'replace', path: '/text', value: 'old invocation' }] } as never); + const oldWrapper = agent.subagents!().get('tc-1')!; + const oldGeneration = oldWrapper.messages()[0].delivery.generation; + + void agent.submit({}); + source.emit({ type: 'RUN_STARTED' } as never); + source.emit(snapshot('tc-1', 'research') as never); + + const freshWrapper = agent.subagents!().get('tc-1')!; + expect(freshWrapper).not.toBe(oldWrapper); + expect(freshWrapper.messages()[0].content).toBe(''); + expect(freshWrapper.messages()[0].delivery.generation).not.toBe(oldGeneration); }); }); @@ -647,7 +1133,10 @@ describe('subagents transcript projection (F5-transcript)', () => { }) as never); const sa = agent.subagents!().get('tc-1'); expect(sa?.messages()).toEqual([ - { id: 'm1', role: 'assistant', content: 'hi', toolCallIds: ['t1'], reasoning: 'think' }, + { + id: 'm1', role: 'assistant', content: 'hi', toolCallIds: ['t1'], reasoning: 'think', + delivery: expect.objectContaining({ phase: 'streaming' }), + }, ]); }); @@ -674,7 +1163,9 @@ describe('subagents transcript projection (F5-transcript)', () => { text: 'partial', }) as never); const sa = agent.subagents!().get('sub-1'); - expect(sa?.messages()).toEqual([{ id: 'sub-1', role: 'assistant', content: 'partial' }]); + expect(sa?.messages()).toEqual([ + { id: 'sub-1', role: 'assistant', content: 'partial', delivery: expect.objectContaining({ phase: 'streaming' }) }, + ]); expect(sa?.toolCalls!()).toEqual([]); }); @@ -693,5 +1184,6 @@ describe('subagents transcript projection (F5-transcript)', () => { // Content and id must pass through unchanged. expect(sa?.messages()[0].content).toBe('leak'); expect(sa?.messages()[0].id).toBe('m1'); + expect(sa?.messages()[0].delivery).toEqual(staticDelivery('m1')); }); }); diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index e38c53eb9..5a729f5d6 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -2,7 +2,14 @@ import { computed, signal, type Signal } from '@angular/core'; import { Subject } from 'rxjs'; import type { AbstractAgent } from '@ag-ui/client'; -import { toAgentError, isAbortError, type AgentError } from '@threadplane/chat'; +import { + completeDelivery, + staticDelivery, + streamingDelivery, + toAgentError, + isAbortError, + type AgentError, +} from '@threadplane/chat'; import type { Agent, Message, AgentStatus, ToolCall, AgentEvent, AgentInterrupt, @@ -13,7 +20,14 @@ import type { ClientToolsCapability, Subagent, SubagentStatus, } from '@threadplane/chat'; -import { reduceEvent, type ReducerStore, type CustomStreamEvent, type ActivityEntry } from './reducer'; +import { + finalizeDeliveryRun, + reduceEvent, + type ReducerDeliveryRun, + type ReducerStore, + type CustomStreamEvent, + type ActivityEntry, +} from './reducer'; import { createClientToolsCapability } from './client-tools'; export interface ToAgentOptions { @@ -88,6 +102,9 @@ export interface AgUiAgent> extends Agent + `${scope}-${++generationSequence}-${Math.random().toString(36).slice(2, 10)}`; const store: ReducerStore = { messages: signal([]), status: signal('idle'), @@ -99,55 +116,40 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag events$: new Subject(), customEvents: signal([]), activities: signal>(new Map()), + deliveryRun: null, + allocateDeliveryGeneration, }; const telemetryProperties = { transport: 'ag-ui' as const, surface: 'to_agent' }; - let activeRun: { startedAt: number; errored: boolean } | null = null; - - // Set by stop(); lets run-failure handlers distinguish a user-initiated - // abort (graceful cancel) from a genuine stream failure. - let abortRequested = false; - - // Set to true the first time settleIfAborted() handles an abort error for the - // current run. The AG-UI client can surface the same abort via both the event - // stream (RUN_ERROR event) AND onRunFailed — abortSettled lets the second - // delivery see through as a no-op rather than re-writing store state or - // triggering a real error path. Both flags are reset together at the top of - // submit() so the next run starts clean. - let abortSettled = false; + interface AdapterRun extends ReducerDeliveryRun { + startedAt: number; + telemetrySettled: boolean; + } + let activeRun: AdapterRun | null = null; + const runsByProtocolId = new Map(); // Tracks the last AgentSubmitInput so retry() can re-run it without // duplicating the user message. Set at the top of submit()'s message path. let lastInput: AgentSubmitInput | undefined; - /** Settles the store as idle for stop()-induced failures; returns true if handled. */ - function settleIfAborted(error: unknown): boolean { - // If we already settled this abort (duplicate delivery — e.g. RUN_ERROR - // event THEN onRunFailed), defensively re-apply the idle settle so any - // state written between the two deliveries (e.g. RUN_STARTED from a new - // run that started before flags were reset) is corrected. Telemetry is - // not re-emitted — the guard returns true to suppress further processing. - if (abortSettled && isAbortError(error)) { - store.status.set('idle'); - store.isLoading.set(false); - return true; - } - - if (!abortRequested || !isAbortError(error)) return false; - abortRequested = false; - abortSettled = true; - store.status.set('idle'); - store.isLoading.set(false); - // Not a failure: leave store.error null and close out telemetry as a - // normal finish so the aborted run doesn't count as errored. - const run = activeRun; - if (run) { - finishRunTelemetry(run); - // Mark errored so any subsequent finishRunTelemetry/failRunTelemetry - // call on the same run object (e.g. from submit's try block resolving - // after the abort) is a no-op — telemetry fires at most once per run. - run.errored = true; + function resolveCallbackRun(protocolRunId: string | undefined): AdapterRun | null { + if (!protocolRunId) return activeRun; + const known = runsByProtocolId.get(protocolRunId); + if (known) return known; + if (!activeRun || activeRun.protocolRunId) return null; + activeRun.protocolRunId = protocolRunId; + runsByProtocolId.set(protocolRunId, activeRun); + while (runsByProtocolId.size > 16) { + const oldestId = runsByProtocolId.keys().next().value as string | undefined; + if (!oldestId) break; + if (runsByProtocolId.get(oldestId) === activeRun) { + const current = runsByProtocolId.get(oldestId)!; + runsByProtocolId.delete(oldestId); + runsByProtocolId.set(oldestId, current); + continue; + } + runsByProtocolId.delete(oldestId); } - return true; + return activeRun; } // Build the client-tools capability. catalogAsAgUiTools() is used below to @@ -171,9 +173,26 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag telemetryProperties, ); - function startRunTelemetry(requestType: string): { startedAt: number; errored: boolean } { - const run = { startedAt: Date.now(), errored: false }; + function beginRun(requestType: string, allowBaselineTail = false): AdapterRun { + if (activeRun && activeRun.outcome === undefined) { + const supersededRun = activeRun; + finalizeDeliveryRun(store, supersededRun, 'interrupted'); + const interruption = new Error('Run superseded by a newer request'); + interruption.name = 'InterruptedError'; + failRunTelemetry(interruption, supersededRun); + } + const run: AdapterRun = { + generation: allocateDeliveryGeneration('run'), + baselineMessageIds: new Set(store.messages().map(message => message.id)), + ownedMessageIds: new Set(), + eligibleBaselineAssistantId: allowBaselineTail + ? getTailAssistantMessageId(store.messages()) + : undefined, + startedAt: Date.now(), + telemetrySettled: false, + }; activeRun = run; + store.deliveryRun = run; captureAgentRuntimeTelemetry(options.telemetry, 'tplane:runtime_request_created', { ...telemetryProperties, requestType, @@ -182,24 +201,46 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag return run; } - function finishRunTelemetry(run: { startedAt: number; errored: boolean }): void { - if (run.errored) return; + function finishRunTelemetry(run: AdapterRun): void { + if (run.telemetrySettled) return; + run.telemetrySettled = true; captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', { ...telemetryProperties, durationMs: Date.now() - run.startedAt, }); - if (activeRun === run) activeRun = null; } - function failRunTelemetry(error: unknown, run = activeRun): void { - if (!run || run.errored) return; - run.errored = true; + function failRunTelemetry(error: unknown, run: AdapterRun | null = activeRun): void { + if (!run || run.telemetrySettled) return; + run.telemetrySettled = true; captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', { ...telemetryProperties, durationMs: Date.now() - run.startedAt, errorClass: agentRuntimeTelemetryErrorClass(error), }); - if (activeRun === run) activeRun = null; + } + + function failRun(run: AdapterRun, error: unknown): void { + if (run.outcome !== undefined) return; + finalizeDeliveryRun(store, run, 'error'); + if (activeRun === run) { + store.status.set('error'); + store.isLoading.set(false); + store.error.set(toAgentError(error)); + } + failRunTelemetry(error, run); + } + + function settleTransportClose(run: AdapterRun): void { + if (run.outcome === undefined) { + finalizeDeliveryRun(store, run, run.ownedMessageIds.size > 0 ? 'interrupted' : 'success'); + if (activeRun === run) { + store.status.set('idle'); + store.isLoading.set(false); + store.error.set(undefined); + } + } + finishRunTelemetry(run); } /** @@ -207,49 +248,64 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag * Both submit() and retry() share this path; submit() appends the user * message first, retry() skips the append and calls this directly. */ - async function runCurrentMessages(): Promise { - const run = startRunTelemetry('submit'); + async function runCurrentMessages(requestType = 'submit', allowBaselineTail = false): Promise { + const run = beginRun(requestType, allowBaselineTail); const tools = clientToolsCap.catalogAsAgUiTools(); try { await source.runAgent(tools.length > 0 ? { tools } : undefined); - finishRunTelemetry(run); + settleTransportClose(run); } catch (err) { - if (!settleIfAborted(err)) { - store.status.set('error'); - store.isLoading.set(false); - store.error.set(toAgentError(err)); - failRunTelemetry(err, run); - } + if (run.outcome === 'aborted' && isAbortError(err)) return; + failRun(run, err); } } // Tap all events from the source agent via the AgentSubscriber API. // This subscription lives for the lifetime of `source`. source.subscribe({ - onEvent({ event }) { - // The AG-UI client surfaces a user-initiated abort both as a - // RUN_ERROR event (here) and via onRunFailed; guard the event path too - // so the reducer never marks a deliberate stop as an error. - if (event.type === 'RUN_ERROR') { - const message = (event as { message?: string }).message ?? ''; - if (settleIfAborted(new Error(message))) return; + onRunInitialized({ input }) { + resolveCallbackRun(input.runId); + }, + onEvent({ event, input }) { + const callbackRunId = input?.runId ?? (event as { runId?: string }).runId; + const run = resolveCallbackRun(callbackRunId); + if (!run) { + if (!callbackRunId) reduceEvent(event, store); + return; + } + if (run !== activeRun) { + if (event.type === 'RUN_FINISHED') finalizeDeliveryRun(store, run, 'success'); + else if (event.type === 'RUN_ERROR') finalizeDeliveryRun(store, run, 'error'); + return; } + if (event.type === 'RUN_ERROR' && run.outcome === 'aborted') return; reduceEvent(event, store); + if (run && event.type === 'RUN_FINISHED' && run.outcome === 'success') { + finishRunTelemetry(run); + } else if (run && event.type === 'RUN_ERROR' && run.outcome === 'error') { + failRunTelemetry((event as { message?: unknown }).message ?? event, run); + } }, - onRunFailed({ error }) { - if (settleIfAborted(error)) return; + onRunFailed({ error, input }) { + const run = resolveCallbackRun(input?.runId); + if (run) { + if (run.outcome === 'aborted' && isAbortError(error)) return; + failRun(run, error); + return; + } + if (input?.runId) return; store.status.set('error'); store.isLoading.set(false); store.error.set(toAgentError(error)); - failRunTelemetry(error); }, }); // Stable Subagent wrappers per messageId so chat-subagents (tracks by // toolCallId) doesn't churn as activity content streams. - const subagentWrappers = new Map(); + const subagentWrappers = new Map(); function subagentFor(id: string, entry: ActivityEntry): Subagent { - let w = subagentWrappers.get(id); + const cached = subagentWrappers.get(id); + let w = cached?.generation === entry.generation ? cached.wrapper : undefined; if (!w) { w = { toolCallId: (entry.content()['toolCallId'] as string) ?? id, @@ -257,17 +313,27 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag status: computed(() => (entry.content()['status'] as SubagentStatus) ?? 'running'), messages: computed(() => { const c = entry.content(); + const status = (c['status'] as SubagentStatus) ?? 'running'; + const assistantDelivery = status === 'error' + ? completeDelivery(entry.generation, 'error') + : status === 'complete' + ? completeDelivery(entry.generation, 'success') + : streamingDelivery(entry.generation); const raw = c['messages']; if (Array.isArray(raw)) { - return (raw as Array>).map((m, i) => ({ - id: (m['id'] as string) ?? `${id}-${i}`, - role: 'assistant' as Message['role'], - content: typeof m['content'] === 'string' ? (m['content'] as string) : (m['content'] as Message['content']) ?? '', - ...(Array.isArray(m['toolCallIds']) ? { toolCallIds: m['toolCallIds'] as string[] } : {}), - ...(typeof m['reasoning'] === 'string' ? { reasoning: m['reasoning'] as string } : {}), - })); + return (raw as Array>).map((m, i) => { + const messageId = (m['id'] as string) ?? `${id}-${i}`; + return { + id: messageId, + role: 'assistant' as Message['role'], + content: typeof m['content'] === 'string' ? (m['content'] as string) : (m['content'] as Message['content']) ?? '', + delivery: m['role'] === 'assistant' ? assistantDelivery : staticDelivery(messageId), + ...(Array.isArray(m['toolCallIds']) ? { toolCallIds: m['toolCallIds'] as string[] } : {}), + ...(typeof m['reasoning'] === 'string' ? { reasoning: m['reasoning'] as string } : {}), + }; + }); } - return [{ id, role: 'assistant', content: String(c['text'] ?? '') }]; + return [{ id, role: 'assistant', content: String(c['text'] ?? ''), delivery: assistantDelivery }]; }), toolCalls: computed(() => { const raw = entry.content()['toolCalls']; @@ -275,7 +341,7 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag }), state: computed(() => (entry.content()['state'] as Record) ?? {}), }; - subagentWrappers.set(id, w); + subagentWrappers.set(id, { generation: entry.generation, wrapper: w }); } return w; } @@ -306,32 +372,23 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag clientTools: clientToolsCap, submit: async (input: AgentSubmitInput, _opts?: AgentSubmitOptions) => { - // Reset both abort flags so a new submit starts clean and genuine - // failures after a previous stop are never swallowed. - abortRequested = false; - abortSettled = false; - if (input.resume !== undefined) { // Resume path: clear the pending interrupt and replay the run with the // resume payload forwarded to the LangGraph backend via AG-UI's // forwardedProps.command.resume mechanism. applyStatePatch(input.state); store.interrupt.set(undefined); - const run = startRunTelemetry('resume'); + const run = beginRun('resume', true); const tools = clientToolsCap.catalogAsAgUiTools(); try { await source.runAgent({ forwardedProps: { command: { resume: input.resume } }, ...(tools.length > 0 ? { tools } : {}), }); - finishRunTelemetry(run); + settleTransportClose(run); } catch (err) { - if (!settleIfAborted(err)) { - store.status.set('error'); - store.isLoading.set(false); - store.error.set(toAgentError(err)); - failRunTelemetry(err, run); - } + if (run.outcome === 'aborted' && isAbortError(err)) return; + failRun(run, err); } return; } @@ -361,11 +418,18 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag // Re-run the same message list against the source without appending a // duplicate user message — the message is already in store.messages and // source's internal list from the original submit(). - await runCurrentMessages(); + await runCurrentMessages('retry', true); }, stop: async () => { - abortRequested = true; + const run = activeRun; + if (run && run.outcome === undefined) { + finalizeDeliveryRun(store, run, 'aborted'); + store.status.set('idle'); + store.isLoading.set(false); + store.error.set(undefined); + finishRunTelemetry(run); + } source.abortRun(); }, @@ -373,12 +437,6 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag if (store.isLoading()) { throw new Error('Cannot regenerate while agent is loading another response'); } - // Reset abort flags so a regenerate starts clean, exactly like submit(). - // Without this, flags left over from a prior stop() would cause the - // duplicate-delivery guard in settleIfAborted() to silently swallow the - // abort error without settling, wedging the store in streaming/running. - abortRequested = false; - abortSettled = false; const msgs = store.messages(); const target = msgs[assistantMessageIndex]; if (!target || target.role !== 'assistant') { @@ -408,18 +466,14 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag // message in `trimmed` becomes the active prompt for the next run. source.setMessages(trimmed as Parameters[0]); - const run = startRunTelemetry('regenerate'); + const run = beginRun('regenerate'); const regenTools = clientToolsCap.catalogAsAgUiTools(); try { await source.runAgent(regenTools.length > 0 ? { tools: regenTools } : undefined); - finishRunTelemetry(run); + settleTransportClose(run); } catch (err) { - if (!settleIfAborted(err)) { - store.status.set('error'); - store.isLoading.set(false); - store.error.set(toAgentError(err)); - failRunTelemetry(err, run); - } + if (run.outcome === 'aborted' && isAbortError(err)) return; + failRun(run, err); } }, }; @@ -430,9 +484,15 @@ function buildUserMessage(input: AgentSubmitInput): Message | undefined { const content = typeof input.message === 'string' ? input.message : input.message.map((b) => b.type === 'text' ? b.text : JSON.stringify(b)).join(''); - return { id: randomId(), role: 'user', content }; + const id = randomId(); + return { id, role: 'user', content, delivery: staticDelivery(id) }; } function randomId(): string { return Math.random().toString(36).slice(2); } + +function getTailAssistantMessageId(messages: readonly Message[]): string | undefined { + const tail = messages[messages.length - 1]; + return tail?.role === 'assistant' ? tail.id : undefined; +} diff --git a/libs/chat/src/lib/testing/mock-agent.spec.ts b/libs/chat/src/lib/testing/mock-agent.spec.ts index 52051dfde..3ce838038 100644 --- a/libs/chat/src/lib/testing/mock-agent.spec.ts +++ b/libs/chat/src/lib/testing/mock-agent.spec.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import { mockAgent } from './mock-agent'; -import type { AgentWithHistory } from '../agent'; +import { staticDelivery, type AgentWithHistory } from '../agent'; describe('mockAgent', () => { it('starts in idle state with empty messages', () => { @@ -14,7 +14,7 @@ describe('mockAgent', () => { it('exposes writable signals for test control', () => { const agent = mockAgent(); - agent.messages.set([{ id: '1', role: 'user', content: 'hi' }]); + agent.messages.set([{ id: '1', role: 'user', content: 'hi', delivery: staticDelivery('1') }]); expect(agent.messages().length).toBe(1); }); @@ -27,7 +27,7 @@ describe('mockAgent', () => { it('accepts initial state overrides', () => { const agent = mockAgent({ status: 'running', - messages: [{ id: '1', role: 'user', content: 'hi' }], + messages: [{ id: '1', role: 'user', content: 'hi', delivery: staticDelivery('1') }], }); expect(agent.status()).toBe('running'); expect(agent.messages().length).toBe(1); diff --git a/libs/chat/src/lib/testing/mock-agent.ts b/libs/chat/src/lib/testing/mock-agent.ts index 5bd97f59d..34952f26e 100644 --- a/libs/chat/src/lib/testing/mock-agent.ts +++ b/libs/chat/src/lib/testing/mock-agent.ts @@ -72,8 +72,10 @@ export interface MockAgentOptions { * @returns A {@link MockAgent} satisfying the full `Agent` contract. * @example * ```ts + * import { staticDelivery } from '@threadplane/chat'; + * * const agent = mockAgent({ - * messages: [{ id: '1', role: 'assistant', content: 'Hi' }], + * messages: [{ id: '1', role: 'assistant', content: 'Hi', delivery: staticDelivery('1') }], * isLoading: true, * }); * ``` From 581e5f8b880e92249c1e7e3443dd66e8c9c75f88 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 12 Jul 2026 00:06:14 -0700 Subject: [PATCH 08/12] refactor(chat): finalize markdown from explicit lifecycle --- libs/chat/package.json | 2 +- .../streaming-markdown.component.spec.ts | 473 +++++++++++++++--- .../streaming/streaming-markdown.component.ts | 230 +++++---- .../streaming-markdown.identity.spec.ts | 42 +- .../streaming-markdown.integration.spec.ts | 59 ++- .../streaming-markdown.ng0956.spec.ts | 50 +- .../streaming-markdown.table-stream.spec.ts | 155 +++--- .../streaming-markdown.torture.spec.ts | 54 +- .../streaming-markdown.variants.spec.ts | 146 ++++-- libs/chat/src/public-api.ts | 9 +- package-lock.json | 10 +- package.json | 2 +- 12 files changed, 870 insertions(+), 362 deletions(-) diff --git a/libs/chat/package.json b/libs/chat/package.json index a71d944d1..e64aa5668 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@cacheplane/partial-json": ">=0.1.1 <0.3.0", - "@cacheplane/partial-markdown": "^0.5.7" + "@cacheplane/partial-markdown": "file:/tmp/cacheplane-partial-markdown-0.5.8.tgz" }, "peerDependencies": { "zod": "^3.25.0", diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts index 394992c44..645eda8a2 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts @@ -1,104 +1,411 @@ // libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts // SPDX-License-Identifier: MIT -import { describe, it, expect, beforeEach } from 'vitest'; -import { TestBed } from '@angular/core/testing'; import { Component, signal } from '@angular/core'; -import { ChatStreamingMdComponent } from './streaming-markdown.component'; +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + createPartialMarkdownParser, + materialize, + type MarkdownDocumentNode, + type PartialMarkdownParser, +} from '@cacheplane/partial-markdown'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { CitationsResolverService } from '../markdown/citations-resolver.service'; +import { + ChatStreamingMdComponent, + STREAMING_MARKDOWN_CONTRACT_VIOLATION_POLICY, + STREAMING_MARKDOWN_PARSER_FACTORY, + type StreamingMarkdownContractViolationPolicy, + type StreamingMarkdownDocument, +} from './streaming-markdown.component'; + +const streaming = ( + content: string, + generation = 'generation-1' +): StreamingMarkdownDocument => ({ generation, phase: 'streaming', content }); + +const complete = ( + content: string, + generation = 'generation-1' +): StreamingMarkdownDocument => ({ generation, phase: 'complete', content }); @Component({ standalone: true, imports: [ChatStreamingMdComponent], - template: ``, + template: ``, }) class HostComponent { - content = signal(''); - streaming = signal(false); + readonly document = signal(streaming('')); } -describe('ChatStreamingMdComponent', () => { - beforeEach(() => TestBed.configureTestingModule({ imports: [HostComponent] })); +interface ParserCall { + readonly parser: number; + readonly method: 'push' | 'finish'; + readonly chunk?: string; + readonly contentAtCall: string; +} - it('renders a heading from markdown', () => { - const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set('# Heading\n'); - fixture.componentInstance.streaming.set(false); - fixture.detectChanges(); - const h1 = fixture.nativeElement.querySelector('h1'); - expect(h1).toBeTruthy(); - expect(h1.textContent?.trim()).toBe('Heading'); - }); +function parserTrace(): { + readonly calls: ParserCall[]; + readonly parsers: PartialMarkdownParser[]; + readonly rootReads: number[]; + readonly factory: () => PartialMarkdownParser; +} { + const calls: ParserCall[] = []; + const parsers: PartialMarkdownParser[] = []; + const rootReads: number[] = []; - it('renders a paragraph from markdown', () => { - const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set('Hello world.\n'); - fixture.componentInstance.streaming.set(false); - fixture.detectChanges(); - const p = fixture.nativeElement.querySelector('p'); - expect(p).toBeTruthy(); - expect(p.textContent?.trim()).toBe('Hello world.'); - }); + return { + calls, + parsers, + rootReads, + factory: () => { + const delegate = createPartialMarkdownParser(); + const parser = parsers.length; + let content = ''; + const traced: PartialMarkdownParser = { + get root() { + rootReads.push(parser); + return delegate.root; + }, + push(chunk) { + content += chunk; + calls.push({ parser, method: 'push', chunk, contentAtCall: content }); + return delegate.push(chunk); + }, + finish() { + calls.push({ parser, method: 'finish', contentAtCall: content }); + return delegate.finish(); + }, + getByPath(path) { + return delegate.getByPath(path); + }, + }; + parsers.push(traced); + return traced; + }, + }; +} - it('updates rendered output when content changes (shrink)', () => { - const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set('# Long heading\n'); - fixture.componentInstance.streaming.set(false); - fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('h1')?.textContent?.trim()).toBe('Long heading'); +function expectedRoot( + document: StreamingMarkdownDocument +): MarkdownDocumentNode | null { + const parser = createPartialMarkdownParser(); + parser.push(document.content); + if (document.phase === 'complete') parser.finish(); + return materialize(parser.root) as MarkdownDocumentNode | null; +} - // Content shrinks — component resets and re-parses from scratch - fixture.componentInstance.content.set('# Short\n'); - fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('h1')?.textContent?.trim()).toBe('Short'); +function setup(policy: StreamingMarkdownContractViolationPolicy) { + const trace = parserTrace(); + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [ + CitationsResolverService, + { + provide: STREAMING_MARKDOWN_CONTRACT_VIOLATION_POLICY, + useValue: policy, + }, + { provide: STREAMING_MARKDOWN_PARSER_FACTORY, useValue: trace.factory }, + ], }); - - it('renders multiple paragraphs when content extends the prior prefix', () => { - const fixture = TestBed.createComponent(HostComponent); - // Start with one paragraph (non-streaming — this is the common finalized state) - fixture.componentInstance.content.set('First.\n\nSecond.\n\n'); - fixture.componentInstance.streaming.set(false); + const fixture = TestBed.createComponent(HostComponent); + const host = fixture.componentInstance; + const resolver = TestBed.inject(CitationsResolverService); + const component = () => + fixture.debugElement.query(By.directive(ChatStreamingMdComponent)) + .componentInstance as ChatStreamingMdComponent; + const transition = (document: StreamingMarkdownDocument): void => { + host.document.set(document); fixture.detectChanges(); + }; + return { fixture, host, component, transition, trace, resolver }; +} - const ps = fixture.nativeElement.querySelectorAll('p'); - expect(ps.length).toBe(2); - expect(ps[0].textContent?.trim()).toBe('First.'); - expect(ps[1].textContent?.trim()).toBe('Second.'); - }); +function textContent( + fixture: ReturnType> +): string { + return (fixture.nativeElement.textContent as string) + .replace(/\s+/g, ' ') + .trim(); +} - it('renders nothing when content is empty', () => { - const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set(''); - fixture.componentInstance.streaming.set(false); - fixture.detectChanges(); - // No block-level elements should be present - expect(fixture.nativeElement.querySelector('p')).toBeNull(); - expect(fixture.nativeElement.querySelector('h1')).toBeNull(); - }); +const policies: StreamingMarkdownContractViolationPolicy[] = [ + 'throw', + 'rebuild', +]; - it('renders a paragraph for plain text WITHOUT a trailing newline (LLM-response shape)', () => { - // Regression: @cacheplane/partial-markdown@0.3 does not flush trailing - // text on finish() unless the buffer ends with '\n'. LLM responses - // typically omit the trailing newline. The component must push a - // sentinel newline before finish() so the message renders. - const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set('Hello — nice to meet you!'); - fixture.componentInstance.streaming.set(false); - fixture.detectChanges(); - const p = fixture.nativeElement.querySelector('p'); - expect(p).toBeTruthy(); - expect(p.textContent?.trim()).toBe('Hello — nice to meet you!'); - }); +describe.each(['streaming', 'complete'] as const)( + 'citation lifecycle for an empty %s document', + (phase) => { + beforeEach(() => TestBed.resetTestingModule()); - it('renders plain text when streaming flips to false (mirrored else-branch)', () => { - // The else-if branch (no content change, streaming flipped to false) - // must also push a sentinel newline before finish(). - const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set('Plain answer.'); - fixture.componentInstance.streaming.set(true); - fixture.detectChanges(); - fixture.componentInstance.streaming.set(false); - fixture.detectChanges(); - const p = fixture.nativeElement.querySelector('p'); - expect(p).toBeTruthy(); - expect(p.textContent?.trim()).toBe('Plain answer.'); - }); -}); + it('clears definitions when a new generation has no materialized root', () => { + const { resolver, transition } = setup('throw'); + transition( + complete( + 'Answer[^source].\n\n[^source]: Source https://example.com\n', + 'cited' + ) + ); + expect(resolver.markdownDefs().has('source')).toBe(true); + + transition({ generation: 'empty', phase, content: '' }); + + expect(resolver.markdownDefs()).toEqual(new Map()); + }); + } +); + +describe.each(policies)( + 'ChatStreamingMdComponent transitions (%s policy)', + (policy) => { + beforeEach(() => TestBed.resetTestingModule()); + + it('none -> streaming creates a parser and pushes the supplied content', () => { + const { fixture, component, transition, trace } = setup(policy); + const document = streaming('Hello'); + + transition(document); + + expect(trace.parsers).toHaveLength(1); + expect(trace.calls).toEqual([ + { parser: 0, method: 'push', chunk: 'Hello', contentAtCall: 'Hello' }, + ]); + expect(component().root()).toEqual(expectedRoot(document)); + expect(textContent(fixture)).toBe('Hello'); + }); + + it('none -> complete pushes content and finishes exactly once synchronously', () => { + const { component, transition, trace } = setup(policy); + const document = complete('Hello'); + + transition(document); + + expect(trace.calls).toEqual([ + { parser: 0, method: 'push', chunk: 'Hello', contentAtCall: 'Hello' }, + { parser: 0, method: 'finish', contentAtCall: 'Hello' }, + ]); + expect(component().root()).toEqual(expectedRoot(document)); + }); + + it('streaming -> streaming appends only the delta', () => { + const { component, transition, trace } = setup(policy); + transition(streaming('Hello')); + + const document = streaming('Hello world'); + transition(document); + + expect(trace.parsers).toHaveLength(1); + expect(trace.calls).toEqual([ + { parser: 0, method: 'push', chunk: 'Hello', contentAtCall: 'Hello' }, + { + parser: 0, + method: 'push', + chunk: ' world', + contentAtCall: 'Hello world', + }, + ]); + expect(component().root()).toEqual(expectedRoot(document)); + }); + + it('streaming -> complete pushes the final delta before one synchronous finish', () => { + const { component, transition, trace } = setup(policy); + transition(streaming('Hello')); + + const document = complete('Hello world'); + transition(document); + + expect(trace.calls).toEqual([ + { parser: 0, method: 'push', chunk: 'Hello', contentAtCall: 'Hello' }, + { + parser: 0, + method: 'push', + chunk: ' world', + contentAtCall: 'Hello world', + }, + { parser: 0, method: 'finish', contentAtCall: 'Hello world' }, + ]); + expect(component().root()).toEqual(expectedRoot(document)); + }); + + it('complete -> identical complete is an idempotent no-op', () => { + const { component, transition, trace } = setup(policy); + const document = complete('Hello'); + transition(document); + const root = component().root(); + const calls = [...trace.calls]; + + transition({ ...document }); + + expect(trace.parsers).toHaveLength(1); + expect(trace.calls).toEqual(calls); + expect(component().root()).toBe(root); + expect( + trace.calls.filter((call) => call.method === 'finish') + ).toHaveLength(1); + }); + + it('streaming -> identical streaming is an exact snapshot no-op', () => { + const { component, transition, trace } = setup(policy); + const document = streaming('Hello'); + transition(document); + const root = component().root(); + const calls = [...trace.calls]; + const rootReads = [...trace.rootReads]; + + transition({ ...document }); + + expect(trace.calls).toEqual(calls); + expect(trace.rootReads).toEqual(rootReads); + expect(component().root()).toBe(root); + }); + + it('new generation -> streaming replaces the parser and processes the full snapshot', () => { + const { component, transition, trace } = setup(policy); + transition(streaming('Old content', 'old')); + + const document = streaming('New', 'new'); + transition(document); + + expect(trace.parsers).toHaveLength(2); + expect(trace.calls).toEqual([ + { + parser: 0, + method: 'push', + chunk: 'Old content', + contentAtCall: 'Old content', + }, + { parser: 1, method: 'push', chunk: 'New', contentAtCall: 'New' }, + ]); + expect(component().root()).toEqual(expectedRoot(document)); + }); + + it('new generation -> complete replaces the parser, pushes full content, and finishes once', () => { + const { component, transition, trace } = setup(policy); + transition(complete('Old content', 'old')); + + const document = complete('New', 'new'); + transition(document); + + expect(trace.parsers).toHaveLength(2); + expect(trace.calls).toEqual([ + { + parser: 0, + method: 'push', + chunk: 'Old content', + contentAtCall: 'Old content', + }, + { parser: 0, method: 'finish', contentAtCall: 'Old content' }, + { parser: 1, method: 'push', chunk: 'New', contentAtCall: 'New' }, + { parser: 1, method: 'finish', contentAtCall: 'New' }, + ]); + expect(component().root()).toEqual(expectedRoot(document)); + }); + } +); + +interface InvalidTransition { + readonly name: string; + readonly prior: StreamingMarkdownDocument; + readonly supplied: StreamingMarkdownDocument; + readonly expectedReason: + | 'complete-to-streaming' + | 'post-completion-content-mutation' + | 'content-shrink' + | 'content-divergence'; +} + +const invalidTransitions: InvalidTransition[] = [ + { + name: 'complete -> streaming in the same generation', + prior: complete('Complete'), + supplied: streaming('Complete'), + expectedReason: 'complete-to-streaming', + }, + { + name: 'complete -> changed complete in the same generation', + prior: complete('Complete'), + supplied: complete('Changed'), + expectedReason: 'post-completion-content-mutation', + }, + { + name: 'streaming -> streaming with shrinking content', + prior: streaming('Long content'), + supplied: streaming('Long'), + expectedReason: 'content-shrink', + }, + { + name: 'streaming -> complete with shrinking content', + prior: streaming('Long content'), + supplied: complete('Long'), + expectedReason: 'content-shrink', + }, + { + name: 'streaming -> streaming with divergent content', + prior: streaming('Prefix one'), + supplied: streaming('Different text'), + expectedReason: 'content-divergence', + }, + { + name: 'streaming -> complete with divergent content', + prior: streaming('Prefix one'), + supplied: complete('Different text'), + expectedReason: 'content-divergence', + }, +]; + +describe.each(invalidTransitions)( + 'contract violation: $name', + ({ prior, supplied, expectedReason }) => { + beforeEach(() => TestBed.resetTestingModule()); + + it('throw policy reports the violation and preserves the prior parser and snapshot', () => { + const { fixture, transition, trace } = setup('throw'); + transition(prior); + const calls = [...trace.calls]; + const priorText = textContent(fixture); + + expect(() => transition(supplied)).toThrowError( + new RegExp( + `Streaming markdown document contract violation: ${expectedReason};.*to ${supplied.phase}` + ) + ); + expect(trace.parsers).toHaveLength(1); + expect(trace.calls).toEqual(calls); + + transition(prior); + expect(trace.parsers).toHaveLength(1); + expect(trace.calls).toEqual(calls); + expect(textContent(fixture)).toBe(priorText); + }); + + it('rebuild policy replaces the parser and exactly materializes the supplied snapshot', () => { + const { component, transition, trace } = setup('rebuild'); + transition(prior); + const callCount = trace.calls.length; + + transition(supplied); + + expect(trace.parsers).toHaveLength(2); + expect(trace.calls.slice(callCount)).toEqual([ + { + parser: 1, + method: 'push', + chunk: supplied.content, + contentAtCall: supplied.content, + }, + ...(supplied.phase === 'complete' + ? [ + { + parser: 1, + method: 'finish' as const, + contentAtCall: supplied.content, + }, + ] + : []), + ]); + expect(component().root()).toEqual(expectedRoot(supplied)); + }); + } +); diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.ts index 34039d720..f0382dcad 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.ts @@ -1,14 +1,15 @@ // libs/chat/src/lib/streaming/streaming-markdown.component.ts // SPDX-License-Identifier: MIT import { - Component, ChangeDetectionStrategy, + Component, + InjectionToken, ViewEncapsulation, computed, effect, inject, input, - signal, + isDevMode, } from '@angular/core'; import { createPartialMarkdownParser, @@ -17,38 +18,77 @@ import { type PartialMarkdownParser, } from '@cacheplane/partial-markdown'; import type { ViewRegistry } from '@threadplane/render'; -import { CHAT_MARKDOWN_STYLES } from '../styles/chat-markdown.styles'; -import { MARKDOWN_VIEW_REGISTRY } from '../markdown/markdown-view-registry'; +import { CitationsResolverService } from '../markdown/citations-resolver.service'; import { MarkdownChildrenComponent } from '../markdown/markdown-children.component'; +import { MARKDOWN_VIEW_REGISTRY } from '../markdown/markdown-view-registry'; import { cacheplaneMarkdownViews } from '../markdown/cacheplane-markdown-views'; -import { CitationsResolverService } from '../markdown/citations-resolver.service'; +import { CHAT_MARKDOWN_STYLES } from '../styles/chat-markdown.styles'; + +export interface StreamingMarkdownDocument { + readonly generation: string; + readonly phase: 'streaming' | 'complete'; + readonly content: string; +} + +export type StreamingMarkdownContractViolationPolicy = 'throw' | 'rebuild'; + +export const STREAMING_MARKDOWN_CONTRACT_VIOLATION_POLICY = + new InjectionToken( + 'STREAMING_MARKDOWN_CONTRACT_VIOLATION_POLICY', + { + providedIn: 'root', + factory: () => (isDevMode() ? 'throw' : 'rebuild'), + } + ); + +type StreamingMarkdownParserFactory = () => PartialMarkdownParser; +type ContractViolationReason = + | 'complete-to-streaming' + | 'post-completion-content-mutation' + | 'content-shrink' + | 'content-divergence'; + +/** @internal Test seam for verifying parser lifecycle ordering. */ +export const STREAMING_MARKDOWN_PARSER_FACTORY = + new InjectionToken( + 'STREAMING_MARKDOWN_PARSER_FACTORY', + { + providedIn: 'root', + factory: () => createPartialMarkdownParser, + } + ); + +function contractViolation( + prior: StreamingMarkdownDocument, + supplied: StreamingMarkdownDocument, + reason: ContractViolationReason +): Error { + return new Error( + `Streaming markdown document contract violation: ${reason}; ` + + `generation ${JSON.stringify(supplied.generation)} cannot transition ` + + `from ${prior.phase} content of length ${prior.content.length} ` + + `to ${supplied.phase} content of length ${supplied.content.length}.` + ); +} -// How long streaming must be false AND content stable before we finalize the -// parser. finish() is only needed to mark final node status (not used visually) -// and to revert a genuinely-truncated trailing construct to CommonMark — the -// live `parser.root` projection already renders everything during streaming, so -// this delay has NO visual cost. It must comfortably exceed real inter-chunk -// gaps (e.g. the pause between a table's header row and its delimiter row) and -// any `streaming` flag flap, so finalize never fires mid-stream and reverts an -// in-progress table to raw "| a | b |" text. -const FINALIZE_DEBOUNCE_MS = 600; +function contractViolationReason( + prior: StreamingMarkdownDocument, + supplied: StreamingMarkdownDocument +): ContractViolationReason | null { + if (prior.phase === 'complete') { + return supplied.phase === 'streaming' + ? 'complete-to-streaming' + : 'post-completion-content-mutation'; + } + if (supplied.content.length < prior.content.length) return 'content-shrink'; + if (!supplied.content.startsWith(prior.content)) return 'content-divergence'; + return null; +} /** - * Renders streaming markdown by walking a @cacheplane/partial-markdown AST - * through @threadplane/render's view registry. - * - * Reactivity model: the live `parser.root` keeps a stable JS reference - * across pushes (partial-markdown's identity guarantee). To make Angular - * signals propagate downstream when the underlying tree changes, we surface - * a materialized snapshot via `materialize()`. The snapshot shares - * structurally — unchanged subtrees keep the SAME reference, and any - * descendant change yields a NEW root reference. This lets Angular's - * `Object.is` equality check both detect changes (root reference differs) - * and short-circuit unchanged subtrees (per-node references stable). - * - * Override per-node-type renderers via the `[viewRegistry]` input or by - * supplying a different `MARKDOWN_VIEW_REGISTRY` provider in the injector - * tree. + * Renders one explicitly-versioned markdown document through the shared view + * registry. A generation owns one parser session; append-only updates preserve + * parser and subtree identity, while generation changes replace the session. */ @Component({ selector: 'chat-streaming-md', @@ -71,93 +111,77 @@ const FINALIZE_DEBOUNCE_MS = 600; ], }) export class ChatStreamingMdComponent { - readonly content = input(''); - readonly streaming = input(false); + readonly document = input.required(); readonly viewRegistry = input(undefined); readonly resolvedRegistry = computed( - () => this.viewRegistry() ?? cacheplaneMarkdownViews, + () => this.viewRegistry() ?? cacheplaneMarkdownViews + ); + + private readonly resolver = inject(CitationsResolverService, { + optional: true, + }); + private readonly violationPolicy = inject( + STREAMING_MARKDOWN_CONTRACT_VIOLATION_POLICY ); + private readonly createParser = inject(STREAMING_MARKDOWN_PARSER_FACTORY); - private readonly resolver = inject(CitationsResolverService, { optional: true }); + private parser: PartialMarkdownParser | null = null; + private prior: StreamingMarkdownDocument | null = null; + private materializedRoot: MarkdownDocumentNode | null = null; + + readonly root = computed(() => { + this.process(this.document()); + return this.materializedRoot; + }); constructor() { effect(() => { - const r = this.root(); - if (this.resolver && r) { - this.resolver.markdownDefs.set(r.citations ?? new Map()); - } - }); - - // Debounced finalization. `finish()` is terminal and DESTRUCTIVE: it reverts - // an incomplete trailing construct to its CommonMark fallback — e.g. a table - // header before its delimiter row becomes raw "| a | b |" paragraph text. We - // must therefore never finalize while tokens are still arriving. The - // `streaming` input is not a reliable "still arriving" signal — it can flap - // false mid-stream, and at cold start it can read false for an entire live - // stream — so we finalize only once streaming is false AND no new content - // has arrived for a short, imperceptible window. Any new content or a - // streaming=true flap re-arms the timer, so finalize fires exactly once, - // after the stream truly stops. Until then the live `parser.root` projection - // (0.5.x) renders the in-progress content, including streaming tables. - let timer: ReturnType | null = null; - effect((onCleanup) => { - const isStreaming = this.streaming(); - this.content(); // re-arm whenever new content arrives - if (timer) { - clearTimeout(timer); - timer = null; + const root = this.root(); + if (this.resolver) { + this.resolver.markdownDefs.set(root?.citations ?? new Map()); } - if (isStreaming || this.finished) return; - timer = setTimeout(() => { - timer = null; - if (this.streaming() || this.finished) return; - if (!this.prior.endsWith('\n')) this.parser.push('\n'); - this.parser.finish(); - this.finished = true; - this.finalizeTick.update((v) => v + 1); - }, FINALIZE_DEBOUNCE_MS); - onCleanup(() => { - if (timer) { - clearTimeout(timer); - timer = null; - } - }); }); } - // Parser instance is rebuilt only when content diverges from the prior - // prefix (rare). For the common streaming case where content extends the - // prior content, we push the delta and reuse the existing parser tree. - private parser: PartialMarkdownParser = createPartialMarkdownParser(); - private prior = ''; - private finished = false; - // Bumped by the debounced finalizer so the `root` computed re-materializes - // the now-finished parser tree. - private readonly finalizeTick = signal(0); + private process(supplied: StreamingMarkdownDocument): void { + const prior = this.prior; + if (!prior || supplied.generation !== prior.generation) { + this.replaceFrom(supplied); + return; + } - readonly root = computed(() => { - const c = this.content(); - this.finalizeTick(); // re-materialize after a debounced finalize - if (c !== this.prior) { - // Re-parse from scratch when the content diverged from the prior prefix, - // OR when the parser was already finalized — finish() is terminal, so - // pushing further deltas into a finished parser corrupts its state. A - // transient `streaming=false` mid-stream that finalized early thus - // recovers here: new content rebuilds an open, projecting parser. - if (c.startsWith(this.prior) && !this.finished) { - this.parser.push(c.slice(this.prior.length)); - } else { - this.parser = createPartialMarkdownParser(); - this.finished = false; - if (c.length > 0) this.parser.push(c); + if (supplied.phase === prior.phase && supplied.content === prior.content) { + return; + } + + const violationReason = contractViolationReason(prior, supplied); + if (violationReason) { + if (this.violationPolicy === 'throw') { + throw contractViolation(prior, supplied, violationReason); } - this.prior = c; + this.replaceFrom(supplied); + return; } - // Materialize for Angular reactivity: produces a NEW root reference when - // any descendant subtree changed; same reference when nothing changed - // (structural sharing). This is what makes signal-based CD propagate - // downstream changes despite the parser preserving identity. - return materialize(this.parser.root) as MarkdownDocumentNode | null; - }); + + const parser = this.parser as PartialMarkdownParser; + const delta = supplied.content.slice(prior.content.length); + if (delta.length > 0) parser.push(delta); + if (supplied.phase === 'complete') parser.finish(); + this.materializedRoot = materialize( + parser.root + ) as MarkdownDocumentNode | null; + this.prior = { ...supplied }; + } + + private replaceFrom(supplied: StreamingMarkdownDocument): void { + const parser = this.createParser(); + parser.push(supplied.content); + if (supplied.phase === 'complete') parser.finish(); + const root = materialize(parser.root) as MarkdownDocumentNode | null; + + this.parser = parser; + this.prior = { ...supplied }; + this.materializedRoot = root; + } } diff --git a/libs/chat/src/lib/streaming/streaming-markdown.identity.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.identity.spec.ts index 5eca17256..2a489f239 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.identity.spec.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.identity.spec.ts @@ -3,29 +3,45 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { Component, signal } from '@angular/core'; -import { ChatStreamingMdComponent } from './streaming-markdown.component'; +import { + ChatStreamingMdComponent, + type StreamingMarkdownDocument, +} from './streaming-markdown.component'; @Component({ standalone: true, imports: [ChatStreamingMdComponent], - template: ``, + template: ``, }) class HostComponent { - content = signal(''); - streaming = signal(true); + document = signal({ + generation: 'test', + phase: 'streaming', + content: '', + }); } describe('chat-streaming-md — identity preservation', () => { - beforeEach(() => TestBed.configureTestingModule({ imports: [HostComponent] })); + beforeEach(() => + TestBed.configureTestingModule({ imports: [HostComponent] }) + ); it('keeps the first paragraph DOM stable when a second paragraph is appended', () => { const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set('First.\n\n'); + fixture.componentInstance.document.set({ + generation: 'test', + phase: 'streaming', + content: 'First.\n\n', + }); fixture.detectChanges(); const firstP = fixture.nativeElement.querySelector('p'); expect(firstP?.textContent?.trim()).toBe('First.'); - fixture.componentInstance.content.set('First.\n\nSecond.\n\n'); + fixture.componentInstance.document.set({ + generation: 'test', + phase: 'streaming', + content: 'First.\n\nSecond.\n\n', + }); fixture.detectChanges(); const allPs = fixture.nativeElement.querySelectorAll('p'); @@ -37,11 +53,19 @@ describe('chat-streaming-md — identity preservation', () => { it('keeps the heading DOM stable when subsequent paragraphs stream in', () => { const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set('# Title\n\n'); + fixture.componentInstance.document.set({ + generation: 'test', + phase: 'streaming', + content: '# Title\n\n', + }); fixture.detectChanges(); const h1 = fixture.nativeElement.querySelector('h1'); - fixture.componentInstance.content.set('# Title\n\nA paragraph.\n\n'); + fixture.componentInstance.document.set({ + generation: 'test', + phase: 'streaming', + content: '# Title\n\nA paragraph.\n\n', + }); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('h1')).toBe(h1); diff --git a/libs/chat/src/lib/streaming/streaming-markdown.integration.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.integration.spec.ts index 49e64e599..36dbcf7cb 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.integration.spec.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.integration.spec.ts @@ -3,29 +3,41 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { Component, signal } from '@angular/core'; -import { ChatStreamingMdComponent } from './streaming-markdown.component'; +import { + ChatStreamingMdComponent, + type StreamingMarkdownDocument, +} from './streaming-markdown.component'; import { katexLoaded } from '../markdown/katex-loader'; @Component({ standalone: true, imports: [ChatStreamingMdComponent], - template: ``, + template: ``, }) class HostComponent { - content = signal(''); - streaming = signal(false); + document = signal({ + generation: 'test', + phase: 'complete', + content: '', + }); } -const samples: { name: string; markdown: string; assertDom: (el: HTMLElement) => void }[] = [ +const samples: { + name: string; + markdown: string; + assertDom: (el: HTMLElement) => void; +}[] = [ { name: 'paragraph', markdown: 'Hello world.\n', - assertDom: (el) => expect(el.querySelector('p')?.textContent?.trim()).toBe('Hello world.'), + assertDom: (el) => + expect(el.querySelector('p')?.textContent?.trim()).toBe('Hello world.'), }, { name: 'h1 heading', markdown: '# Title\n', - assertDom: (el) => expect(el.querySelector('h1')?.textContent?.trim()).toBe('Title'), + assertDom: (el) => + expect(el.querySelector('h1')?.textContent?.trim()).toBe('Title'), }, { name: 'unordered list', @@ -75,27 +87,39 @@ const samples: { name: string; markdown: string; assertDom: (el: HTMLElement) => ]; describe('chat-streaming-md integration', () => { - beforeEach(() => TestBed.configureTestingModule({ imports: [HostComponent] })); + beforeEach(() => + TestBed.configureTestingModule({ imports: [HostComponent] }) + ); for (const sample of samples) { it(`renders ${sample.name} (whole-string)`, () => { const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set(sample.markdown); - fixture.componentInstance.streaming.set(false); + fixture.componentInstance.document.set({ + generation: 'test', + phase: 'complete', + content: sample.markdown, + }); fixture.detectChanges(); sample.assertDom(fixture.nativeElement); }); it(`renders ${sample.name} (chunked with per-chunk CD)`, () => { const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.streaming.set(true); let acc = ''; for (const ch of sample.markdown) { acc += ch; - fixture.componentInstance.content.set(acc); - fixture.detectChanges(); // per-chunk CD must work — materialize() gives new root ref when tree changes + fixture.componentInstance.document.set({ + generation: 'test', + phase: 'streaming', + content: acc, + }); + fixture.detectChanges(); // per-chunk CD must work — materialize() gives new root ref when tree changes } - fixture.componentInstance.streaming.set(false); + fixture.componentInstance.document.set({ + generation: 'test', + phase: 'complete', + content: acc, + }); fixture.detectChanges(); sample.assertDom(fixture.nativeElement); }); @@ -104,8 +128,11 @@ describe('chat-streaming-md integration', () => { it('renders inline math as KaTeX instead of leaking raw $ delimiters', async () => { await katexLoaded; const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set('Euler: $e^{i\\pi}+1=0$ done.\n'); - fixture.componentInstance.streaming.set(false); + fixture.componentInstance.document.set({ + generation: 'test', + phase: 'complete', + content: 'Euler: $e^{i\\pi}+1=0$ done.\n', + }); fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; expect(el.querySelector('.katex')).toBeTruthy(); diff --git a/libs/chat/src/lib/streaming/streaming-markdown.ng0956.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.ng0956.spec.ts index 49a176e98..95adfb6f0 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.ng0956.spec.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.ng0956.spec.ts @@ -17,10 +17,20 @@ // The first test is a NEGATIVE CONTROL proving the capture mechanism actually // observes NG0956 in this environment — without it, the assertions below could // pass simply because nothing ever emits the warning. -import { describe, it, expect, beforeEach, vi, type MockInstance } from 'vitest'; +import { + describe, + it, + expect, + beforeEach, + vi, + type MockInstance, +} from 'vitest'; import { TestBed } from '@angular/core/testing'; import { Component, signal } from '@angular/core'; -import { ChatStreamingMdComponent } from './streaming-markdown.component'; +import { + ChatStreamingMdComponent, + type StreamingMarkdownDocument, +} from './streaming-markdown.component'; /** Spy console.warn + console.error; collect any NG0956 messages. */ function captureNg0956(): { hits: string[]; restore: () => void } { @@ -40,7 +50,8 @@ function captureNg0956(): { hits: string[]; restore: () => void } { // are replaced with fresh refs each cycle — the canonical NG0956 trigger. @Component({ standalone: true, - template: `@for (item of items(); track item) {{{ item.v }}}`, + template: `@for (item of items(); track item) {{{ item.v }}}`, }) class IdentityTrackHost { readonly items = signal<{ v: number }[]>([]); @@ -55,16 +66,21 @@ class IdentityTrackHost { @Component({ standalone: true, imports: [ChatStreamingMdComponent], - template: ``, + template: ``, }) class MarkdownHost { - readonly content = signal(''); - readonly streaming = signal(true); + readonly document = signal({ + generation: 'initial', + phase: 'streaming', + content: '', + }); } describe('NG0956 streaming regression guard', () => { describe('negative control (proves NG0956 is observable here)', () => { - beforeEach(() => TestBed.configureTestingModule({ imports: [IdentityTrackHost] })); + beforeEach(() => + TestBed.configureTestingModule({ imports: [IdentityTrackHost] }) + ); it('captures NG0956 when a fixed-length @for tracks by churning identity', () => { const cap = captureNg0956(); @@ -82,7 +98,9 @@ describe('NG0956 streaming regression guard', () => { }); describe('streaming markdown emits no NG0956 across re-materialization', () => { - beforeEach(() => TestBed.configureTestingModule({ imports: [MarkdownHost] })); + beforeEach(() => + TestBed.configureTestingModule({ imports: [MarkdownHost] }) + ); it('a re-parsing fixed-length list does not warn NG0956', () => { const cap = captureNg0956(); @@ -97,8 +115,12 @@ describe('NG0956 streaming regression guard', () => { '- alpha 1\n- beta 2\n- gamma 3\n', '- alpha one!\n- beta two!\n- gamma three!\n', ]; - for (const s of steps) { - fixture.componentInstance.content.set(s); + for (const [index, content] of steps.entries()) { + fixture.componentInstance.document.set({ + generation: `list-${index}`, + phase: 'streaming', + content, + }); fixture.detectChanges(); } expect(cap.hits).toEqual([]); @@ -119,8 +141,12 @@ describe('NG0956 streaming regression guard', () => { table('a', 'b', 'c'), table('one', 'two', 'three'), ]; - for (const s of steps) { - fixture.componentInstance.content.set(s); + for (const [index, content] of steps.entries()) { + fixture.componentInstance.document.set({ + generation: `table-${index}`, + phase: 'streaming', + content, + }); fixture.detectChanges(); } expect(cap.hits).toEqual([]); diff --git a/libs/chat/src/lib/streaming/streaming-markdown.table-stream.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.table-stream.spec.ts index 675336457..f89bc4364 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.table-stream.spec.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.table-stream.spec.ts @@ -2,26 +2,27 @@ // SPDX-License-Identifier: MIT // // Regression: a streaming table must render as a as it arrives, not as -// raw "| a | b |" paragraph text. The bug was that ChatStreamingMdComponent -// called parser.finish() on every render where [streaming] was false — and -// finish() reverts an incomplete table (header with no delimiter row yet) to a -// CommonMark paragraph (raw pipes). Because the [streaming] flag is unreliable -// (observed false for an entire live stream at cold start), the whole table -// rendered as raw pipes until the message completed. The fix: do not finalize -// the parser while content is still growing; finalize only once it settles. -import { describe, it, expect, beforeEach, vi } from 'vitest'; +// raw "| a | b |" paragraph text. Explicit document phases keep the parser open +// until the producer atomically marks the generation complete. +import { describe, it, expect, beforeEach } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { Component, signal } from '@angular/core'; -import { ChatStreamingMdComponent } from './streaming-markdown.component'; +import { + ChatStreamingMdComponent, + type StreamingMarkdownDocument, +} from './streaming-markdown.component'; @Component({ standalone: true, imports: [ChatStreamingMdComponent], - template: ``, + template: ``, }) class HostComponent { - content = signal(''); - streaming = signal(true); + document = signal({ + generation: 'test', + phase: 'streaming', + content: '', + }); } describe('ChatStreamingMdComponent — streaming table rendering', () => { @@ -34,17 +35,26 @@ describe('ChatStreamingMdComponent — streaming table rendering', () => { host = fixture.componentInstance; el = fixture.nativeElement as HTMLElement; }); - const grow = (c: string) => { host.content.set(c); fixture.detectChanges(); }; + const grow = ( + content: string, + phase: StreamingMarkdownDocument['phase'] = 'streaming' + ) => { + host.document.set({ generation: 'test', phase, content }); + fixture.detectChanges(); + }; - it('renders a
as a table streams in even when [streaming] lags false', () => { - // The cold-start race: content is actively growing but streaming is false. - host.streaming.set(false); + it('renders a
from an explicitly streaming document', () => { grow('Here is a table:\n\n| Name '); grow('Here is a table:\n\n| Name | Age |'); // header on the open line, no delimiter - // Before the fix: finish() reverted this to raw-pipe paragraphs. - expect(el.querySelector('table'), 'header should render as a table, not raw pipes').toBeTruthy(); + expect( + el.querySelector('table'), + 'header should render as a table, not raw pipes' + ).toBeTruthy(); const paras = [...el.querySelectorAll('p')].map((p) => p.textContent || ''); - expect(paras.some((t) => t.includes('| Name | Age |')), 'no raw-pipe paragraph').toBe(false); + expect( + paras.some((t) => t.includes('| Name | Age |')), + 'no raw-pipe paragraph' + ).toBe(false); }); it('renders a
while streaming (flag true), through the delimiter wait', () => { @@ -55,11 +65,10 @@ describe('ChatStreamingMdComponent — streaming table rendering', () => { }); it('finalizes the table once the stream settles (streaming -> false)', () => { - host.streaming.set(true); - grow('| Name | Age |\n| --- | --- |\n| Ada | 36 |\n'); + const content = '| Name | Age |\n| --- | --- |\n| Ada | 36 |\n'; + grow(content); expect(el.querySelector('table')).toBeTruthy(); - host.streaming.set(false); // settle - fixture.detectChanges(); + grow(content, 'complete'); const table = el.querySelector('table'); expect(table).toBeTruthy(); expect(el.querySelectorAll('thead th').length).toBe(2); @@ -67,52 +76,48 @@ describe('ChatStreamingMdComponent — streaming table rendering', () => { }); it('renders a complete one-shot (non-streaming) table message', () => { - host.streaming.set(false); - grow('| Name | Age |\n| --- | --- |\n| Ada | 36 |\n'); + grow('| Name | Age |\n| --- | --- |\n| Ada | 36 |\n', 'complete'); expect(el.querySelector('table')).toBeTruthy(); expect(el.querySelectorAll('thead th').length).toBe(2); }); - it('does not flash raw pipes when [streaming] flaps false mid-stream', () => { - vi.useFakeTimers(); - try { - host.streaming.set(true); - grow('| Name | Age |'); // streaming header → table - expect(el.querySelector('table')).toBeTruthy(); - // Flap: streaming reads false for a moment with no new content. - host.streaming.set(false); - fixture.detectChanges(); - vi.advanceTimersByTime(60); // less than the debounce — must NOT finalize - expect(el.querySelector('table'), 'table must survive the flap').toBeTruthy(); - expect( - [...el.querySelectorAll('p')].some((p) => (p.textContent || '').includes('|')), - 'no raw-pipe paragraph during the flap', - ).toBe(false); - // Flap recovers: streaming true again + more content arrives. - host.streaming.set(true); - grow('| Name | Age |\n| --- | --- |\n'); - vi.advanceTimersByTime(300); - expect(el.querySelector('table')).toBeTruthy(); - } finally { - vi.useRealTimers(); - } + it('does not finalize or flash raw pipes while the document remains streaming', () => { + grow('| Name | Age |'); + expect(el.querySelector('table')).toBeTruthy(); + fixture.detectChanges(); + fixture.detectChanges(); + expect( + el.querySelector('table'), + 'table must survive unchanged change detection' + ).toBeTruthy(); + expect( + [...el.querySelectorAll('p')].some((p) => + (p.textContent || '').includes('|') + ), + 'no raw-pipe paragraph while streaming' + ).toBe(false); + grow('| Name | Age |\n| --- | --- |\n'); + expect(el.querySelector('table')).toBeTruthy(); }); it('streams body rows inside the table — no paragraph, no second table (0.5.3)', () => { - host.streaming.set(true); grow('| A | B |\n| - | - |\n'); for (const c of ['|', '| x1', '| x1 | y', '| x1 | y1 |', '| x1 | y1 |\n']) { grow('| A | B |\n| - | - |\n' + c); - expect(el.querySelectorAll('table').length, `one table at ${JSON.stringify(c)}`).toBe(1); expect( - [...el.querySelectorAll('p')].some((p) => (p.textContent || '').includes('|')), - `no raw-pipe paragraph at ${JSON.stringify(c)}`, + el.querySelectorAll('table').length, + `one table at ${JSON.stringify(c)}` + ).toBe(1); + expect( + [...el.querySelectorAll('p')].some((p) => + (p.textContent || '').includes('|') + ), + `no raw-pipe paragraph at ${JSON.stringify(c)}` ).toBe(false); } }); it('streams a realistic comparison table as one table across small chunks', () => { - host.streaming.set(true); const content = 'Here is the comparison:\n\n' + '| Name | Mental model | When to use |\n' + @@ -126,35 +131,37 @@ describe('ChatStreamingMdComponent — streaming table rendering', () => { grow(content.slice(0, i)); const tableCount = el.querySelectorAll('table').length; if (tableCount > 0) { - expect(tableCount, `one table at ${JSON.stringify(content.slice(0, i).slice(-40))}`).toBe(1); + expect( + tableCount, + `one table at ${JSON.stringify(content.slice(0, i).slice(-40))}` + ).toBe(1); } expect( - [...el.querySelectorAll('p')].some((p) => (p.textContent || '').includes('| zone.js')), - `no detached row paragraph at ${JSON.stringify(content.slice(0, i).slice(-40))}`, + [...el.querySelectorAll('p')].some((p) => + (p.textContent || '').includes('| zone.js') + ), + `no detached row paragraph at ${JSON.stringify( + content.slice(0, i).slice(-40) + )}` ).toBe(false); } }); - it('keeps a finalized partial body row in the table when the stream pauses', () => { - vi.useFakeTimers(); - try { - host.streaming.set(false); - grow( - '| Name | Mental model | When to use |\n' + + it('keeps a partial body row in the table when the document completes', () => { + grow( + '| Name | Mental model | When to use |\n' + '| --- | --- | --- |\n' + '| Angular signals | Fine-grained values | Local state |\n' + '| RxJS (Observables) [', - ); - vi.advanceTimersByTime(650); - fixture.detectChanges(); - expect(el.querySelectorAll('table').length).toBe(1); - expect(el.querySelectorAll('tbody tr').length).toBe(2); - expect( - [...el.querySelectorAll('p')].some((p) => (p.textContent || '').includes('| RxJS')), - 'no raw-pipe paragraph after finalizing a partial body row', - ).toBe(false); - } finally { - vi.useRealTimers(); - } + 'complete' + ); + expect(el.querySelectorAll('table').length).toBe(1); + expect(el.querySelectorAll('tbody tr').length).toBe(2); + expect( + [...el.querySelectorAll('p')].some((p) => + (p.textContent || '').includes('| RxJS') + ), + 'no raw-pipe paragraph after finalizing a partial body row' + ).toBe(false); }); }); diff --git a/libs/chat/src/lib/streaming/streaming-markdown.torture.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.torture.spec.ts index 6c64124aa..5f56f0625 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.torture.spec.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.torture.spec.ts @@ -2,16 +2,22 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { Component, signal } from '@angular/core'; -import { ChatStreamingMdComponent } from './streaming-markdown.component'; +import { + ChatStreamingMdComponent, + type StreamingMarkdownDocument, +} from './streaming-markdown.component'; @Component({ standalone: true, imports: [ChatStreamingMdComponent], - template: ``, + template: ``, }) class HostComponent { - content = signal(''); - streaming = signal(true); + document = signal({ + generation: 'test', + phase: 'streaming', + content: '', + }); } describe('ChatStreamingMdComponent — streaming markdown torture set', () => { @@ -27,40 +33,52 @@ describe('ChatStreamingMdComponent — streaming markdown torture set', () => { }); const grow = (content: string) => { - host.content.set(content); + host.document.set({ generation: 'test', phase: 'streaming', content }); fixture.detectChanges(); }; it('streams list items in one list without raw marker paragraphs', () => { - for (const content of ['- alpha', '- alpha\n- be', '- alpha\n- beta\n- gam']) { + for (const content of [ + '- alpha', + '- alpha\n- be', + '- alpha\n- beta\n- gam', + ]) { grow(content); expect(el.querySelectorAll('ul')).toHaveLength(1); expect( - [...el.querySelectorAll('p')].some((p) => (p.textContent ?? '').startsWith('- ')), - `no raw list marker paragraph at ${JSON.stringify(content)}`, + [...el.querySelectorAll('p')].some((p) => + (p.textContent ?? '').startsWith('- ') + ), + `no raw list marker paragraph at ${JSON.stringify(content)}` ).toBe(false); } - expect([...el.querySelectorAll('li')].map((li) => li.textContent?.trim())).toEqual([ - 'alpha', - 'beta', - 'gam', - ]); + expect( + [...el.querySelectorAll('li')].map((li) => li.textContent?.trim()) + ).toEqual(['alpha', 'beta', 'gam']); }); it('streams fenced code as one code block while the closing fence is pending', () => { - for (const content of ['```ts\n', '```ts\nconst answer =', '```ts\nconst answer = 42;']) { + for (const content of [ + '```ts\n', + '```ts\nconst answer =', + '```ts\nconst answer = 42;', + ]) { grow(content); expect(el.querySelectorAll('pre code')).toHaveLength(1); expect(el.querySelector('pre code')?.className).toBe('language-ts'); expect(el.querySelector('p')?.textContent ?? '').not.toContain('```'); } - expect(el.querySelector('pre code')?.textContent).toBe('const answer = 42;'); + expect(el.querySelector('pre code')?.textContent).toBe( + 'const answer = 42;' + ); }); it('streams nested inline formatting without leaking delimiter text', () => { grow('A *em and **strong and `code'); expect(el.querySelector('em')?.textContent).toContain('em and'); - expect(el.querySelector('strong')?.textContent).toContain('strong and code'); + expect(el.querySelector('strong')?.textContent).toContain( + 'strong and code' + ); expect(el.querySelector('strong code')?.textContent).toBe('code'); expect(el.textContent).not.toContain('**strong'); }); @@ -72,6 +90,8 @@ describe('ChatStreamingMdComponent — streaming markdown torture set', () => { const paragraph = quote?.querySelector('p'); expect(paragraph?.textContent).toBe('hellowor'); expect(paragraph?.querySelector('br')).toBeTruthy(); - expect([...el.querySelectorAll('p')].every((p) => quote?.contains(p))).toBe(true); + expect([...el.querySelectorAll('p')].every((p) => quote?.contains(p))).toBe( + true + ); }); }); diff --git a/libs/chat/src/lib/streaming/streaming-markdown.variants.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.variants.spec.ts index d25cfd158..c539b50a0 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.variants.spec.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.variants.spec.ts @@ -1,17 +1,23 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { Component, signal } from '@angular/core'; -import { ChatStreamingMdComponent } from './streaming-markdown.component'; +import { + ChatStreamingMdComponent, + type StreamingMarkdownDocument, +} from './streaming-markdown.component'; @Component({ standalone: true, imports: [ChatStreamingMdComponent], - template: ``, + template: ``, }) class HostComponent { - content = signal(''); - streaming = signal(false); + document = signal({ + generation: 'test', + phase: 'complete', + content: '', + }); } interface FinalizedRow { @@ -26,16 +32,55 @@ interface FinalizedRow { } const finalizedRows: FinalizedRow[] = [ - { name: 'plain text no trailing newline', input: 'Hello', expectedText: 'Hello', selectorPresent: 'p' }, - { name: 'plain text with trailing newline', input: 'Hello\n', expectedText: 'Hello', selectorPresent: 'p' }, - { name: 'heading no trailing newline', input: '# Title', expectedText: 'Title', selectorPresent: 'h1' }, - { name: 'heading with trailing newline', input: '# Title\n', expectedText: 'Title', selectorPresent: 'h1' }, - { name: 'completed bold', input: '**bold**', expectedText: 'bold', selectorPresent: 'strong' }, - { name: 'inline code', input: 'Run `npm test` to verify', expectedText: 'Run npm test to verify', selectorPresent: 'code' }, - { name: 'CRLF line endings', input: 'Line one\r\nLine two\r\n', expectedText: 'Line one Line two' }, + { + name: 'plain text no trailing newline', + input: 'Hello', + expectedText: 'Hello', + selectorPresent: 'p', + }, + { + name: 'plain text with trailing newline', + input: 'Hello\n', + expectedText: 'Hello', + selectorPresent: 'p', + }, + { + name: 'heading no trailing newline', + input: '# Title', + expectedText: 'Title', + selectorPresent: 'h1', + }, + { + name: 'heading with trailing newline', + input: '# Title\n', + expectedText: 'Title', + selectorPresent: 'h1', + }, + { + name: 'completed bold', + input: '**bold**', + expectedText: 'bold', + selectorPresent: 'strong', + }, + { + name: 'inline code', + input: 'Run `npm test` to verify', + expectedText: 'Run npm test to verify', + selectorPresent: 'code', + }, + { + name: 'CRLF line endings', + input: 'Line one\r\nLine two\r\n', + expectedText: 'Line one Line two', + }, { name: 'whitespace only', input: ' ', expectedText: '' }, { name: 'empty string', input: '', expectedText: '', selectorAbsent: 'p' }, - { name: 'trailing whitespace no newline', input: 'Answer ', expectedText: 'Answer', selectorPresent: 'p' }, + { + name: 'trailing whitespace no newline', + input: 'Answer ', + expectedText: 'Answer', + selectorPresent: 'p', + }, ]; function normalize(s: string): string { @@ -46,15 +91,24 @@ describe('ChatStreamingMdComponent — finalized input variance', () => { it.each(finalizedRows)('$name', (row) => { TestBed.configureTestingModule({ imports: [HostComponent] }); const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set(row.input); - fixture.componentInstance.streaming.set(false); + fixture.componentInstance.document.set({ + generation: 'test', + phase: 'complete', + content: row.input, + }); fixture.detectChanges(); - expect(normalize(fixture.nativeElement.textContent ?? '')).toBe(row.expectedText); + expect(normalize(fixture.nativeElement.textContent ?? '')).toBe( + row.expectedText + ); if (row.selectorPresent) { - expect(fixture.nativeElement.querySelector(row.selectorPresent)).toBeTruthy(); + expect( + fixture.nativeElement.querySelector(row.selectorPresent) + ).toBeTruthy(); } if (row.selectorAbsent) { - expect(fixture.nativeElement.querySelector(row.selectorAbsent)).toBeNull(); + expect( + fixture.nativeElement.querySelector(row.selectorAbsent) + ).toBeNull(); } }); }); @@ -69,31 +123,43 @@ interface MidStreamRow { } const midStreamRows: MidStreamRow[] = [ - { name: 'partial bold mid-stream then unchanged', midStream: '**bo', expectedText: '**bo' }, - { name: 'partial bold mid-stream then completed', midStream: '**bo', onFinish: '**bold**', expectedText: 'bold' }, - { name: 'unfinished sentence then finalized', midStream: 'The quick', onFinish: 'The quick brown fox.', expectedText: 'The quick brown fox.' }, + { + name: 'partial bold mid-stream then unchanged', + midStream: '**bo', + expectedText: '**bo', + }, + { + name: 'partial bold mid-stream then completed', + midStream: '**bo', + onFinish: '**bold**', + expectedText: 'bold', + }, + { + name: 'unfinished sentence then finalized', + midStream: 'The quick', + onFinish: 'The quick brown fox.', + expectedText: 'The quick brown fox.', + }, ]; describe('ChatStreamingMdComponent — mid-stream input variance', () => { it.each(midStreamRows)('$name', (row) => { - // Finalization is debounced (the component must not finalize on a transient - // streaming=false), so an unclosed construct only reverts to its literal - // CommonMark form once the debounce elapses. Drive fake timers to settle. - vi.useFakeTimers(); - try { - TestBed.configureTestingModule({ imports: [HostComponent] }); - const fixture = TestBed.createComponent(HostComponent); - fixture.componentInstance.content.set(row.midStream); - fixture.componentInstance.streaming.set(true); - fixture.detectChanges(); - fixture.componentInstance.content.set(row.onFinish ?? row.midStream); - fixture.componentInstance.streaming.set(false); - fixture.detectChanges(); - vi.advanceTimersByTime(800); // elapse the finalize debounce - fixture.detectChanges(); - expect(normalize(fixture.nativeElement.textContent ?? '')).toBe(row.expectedText); - } finally { - vi.useRealTimers(); - } + TestBed.configureTestingModule({ imports: [HostComponent] }); + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.document.set({ + generation: 'test', + phase: 'streaming', + content: row.midStream, + }); + fixture.detectChanges(); + fixture.componentInstance.document.set({ + generation: 'test', + phase: 'complete', + content: row.onFinish ?? row.midStream, + }); + fixture.detectChanges(); + expect(normalize(fixture.nativeElement.textContent ?? '')).toBe( + row.expectedText + ); }); }); diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index a2032352d..3234ce5e0 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -134,7 +134,14 @@ export { CitationsResolverService } from './lib/markdown/citations-resolver.serv export type { ResolvedCitation } from './lib/markdown/citations-resolver.service'; // Streaming -export { ChatStreamingMdComponent } from './lib/streaming/streaming-markdown.component'; +export { + ChatStreamingMdComponent, + STREAMING_MARKDOWN_CONTRACT_VIOLATION_POLICY, +} from './lib/streaming/streaming-markdown.component'; +export type { + StreamingMarkdownContractViolationPolicy, + StreamingMarkdownDocument, +} from './lib/streaming/streaming-markdown.component'; // Markdown rendering primitives + registry export { MARKDOWN_VIEW_REGISTRY } from './lib/markdown/markdown-view-registry'; diff --git a/package-lock.json b/package-lock.json index daebd753d..3a8e04193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@angular/platform-browser": "~21.1.0", "@angular/router": "~21.1.0", "@cacheplane/partial-json": "^0.1.1", - "@cacheplane/partial-markdown": "^0.5.7", + "@cacheplane/partial-markdown": "file:/tmp/cacheplane-partial-markdown-0.5.8.tgz", "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", "@neondatabase/serverless": "^0.10.0", @@ -207,7 +207,7 @@ "license": "PolyForm-Noncommercial-1.0.0 OR LicenseRef-Threadplane-Commercial", "dependencies": { "@cacheplane/partial-json": ">=0.1.1 <0.3.0", - "@cacheplane/partial-markdown": "^0.5.7" + "@cacheplane/partial-markdown": "file:/tmp/cacheplane-partial-markdown-0.5.8.tgz" }, "peerDependencies": { "@angular/common": "^20.0.0 || ^21.0.0", @@ -7272,9 +7272,9 @@ "license": "MIT" }, "node_modules/@cacheplane/partial-markdown": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@cacheplane/partial-markdown/-/partial-markdown-0.5.7.tgz", - "integrity": "sha512-8Gs3bbRuo8vsPjf3U5Ms+awZpzL+80mJ9EVlbygE8EzP2UNB5YuKd2CqFIxIoN1VlGrjjiqfOccJTLl3LyzngQ==", + "version": "0.5.8", + "resolved": "file:../../../../../../tmp/cacheplane-partial-markdown-0.5.8.tgz", + "integrity": "sha512-mHimYvOkoQmXo5idajmPkNg5yvsosnV7YmC+wV8Ni3zvpQWLv1Qlw2wuVAJ9JUg9/xE01uSYVKIOJlw4qB0N9w==", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index ee06d2bcc..73ea20e30 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "@angular/platform-browser": "~21.1.0", "@angular/router": "~21.1.0", "@cacheplane/partial-json": "^0.1.1", - "@cacheplane/partial-markdown": "^0.5.7", + "@cacheplane/partial-markdown": "file:/tmp/cacheplane-partial-markdown-0.5.8.tgz", "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", "@neondatabase/serverless": "^0.10.0", From de2732b1bb63174068ec2d3f3af5e287e63c0ff5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 12 Jul 2026 00:38:16 -0700 Subject: [PATCH 09/12] refactor(chat): bind markdown to message delivery --- .../chat-subagent-card.component.spec.ts | 44 ++- .../chat-subagent-card.component.ts | 43 ++- .../compositions/chat/chat.component.spec.ts | 324 ++++++++++++++++-- .../lib/compositions/chat/chat.component.ts | 102 +++--- .../chat-reasoning.component.spec.ts | 55 ++- .../chat-reasoning.component.ts | 13 +- .../chat-tool-calls.component.spec.ts | 32 +- .../streaming/streaming-markdown.component.ts | 13 + libs/chat/src/public-api.ts | 1 + 9 files changed, 539 insertions(+), 88 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts index 809f964b2..8cb095657 100644 --- a/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts @@ -5,6 +5,8 @@ import { signal } from '@angular/core'; import { By } from '@angular/platform-browser'; import { ChatSubagentCardComponent, statusColor } from './chat-subagent-card.component'; import type { Subagent } from '../../agent/subagent'; +import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component'; +import { completeDelivery, staticDelivery, streamingDelivery } from '../../agent'; describe('ChatSubagentCardComponent', () => { it('is defined', () => { @@ -25,11 +27,13 @@ describe('ChatSubagentCardComponent', () => { content: 'searching', reasoning: 'plan', toolCallIds: ['t1'], + delivery: streamingDelivery('nested-1'), }, { id: 'm2', role: 'assistant', content: 'done', + delivery: completeDelivery('nested-2', 'error'), }, ]), toolCalls: signal([ @@ -59,6 +63,12 @@ describe('ChatSubagentCardComponent', () => { const toolCallEls = fixture.debugElement.queryAll(By.css('chat-tool-call-card')); expect(toolCallEls.length).toBe(1); + + const markdown = fixture.debugElement.queryAll(By.directive(ChatStreamingMdComponent)); + expect(markdown.map((el) => el.componentInstance.document())).toEqual([ + { generation: 'nested-1', phase: 'streaming', content: 'searching' }, + { generation: 'nested-2', phase: 'complete', content: 'done' }, + ]); }); it('shows the message count in the collapsed summary when complete', async () => { @@ -68,8 +78,8 @@ describe('ChatSubagentCardComponent', () => { // 'complete' → chat-trace collapses; the transcript DOM is hidden. status: signal('complete'), messages: signal([ - { id: 'm1', role: 'assistant', content: 'a' }, - { id: 'm2', role: 'assistant', content: 'b' }, + { id: 'm1', role: 'assistant', content: 'a', delivery: staticDelivery('m1') }, + { id: 'm2', role: 'assistant', content: 'b', delivery: staticDelivery('m2') }, ]), toolCalls: signal([]), state: signal({}), @@ -94,6 +104,36 @@ describe('ChatSubagentCardComponent', () => { const text = host.textContent ?? ''; expect(text).toMatch(/2 message/); }); + + it('preserves nested markdown document identity across unrelated parent updates', () => { + const messages = signal([{ + id: 'm1', + role: 'assistant' as const, + content: 'stable', + delivery: staticDelivery('m1'), + }]); + const fakeSubagent: Subagent = { + toolCallId: 'tc-root', + name: 'Research', + status: signal('running'), + messages, + toolCalls: signal([]), + state: signal({}), + }; + TestBed.configureTestingModule({ imports: [ChatSubagentCardComponent] }); + const fixture = TestBed.createComponent(ChatSubagentCardComponent); + fixture.componentRef.setInput('subagent', fakeSubagent); + fixture.detectChanges(); + + let markdown = fixture.debugElement.query(By.directive(ChatStreamingMdComponent)); + const document = markdown.componentInstance.document(); + + messages.set([...messages()]); + fixture.detectChanges(); + + markdown = fixture.debugElement.query(By.directive(ChatStreamingMdComponent)); + expect(markdown.componentInstance.document()).toBe(document); + }); }); describe('statusColor', () => { diff --git a/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts index 6b48a4b00..13822cf64 100644 --- a/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts +++ b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts @@ -1,9 +1,13 @@ // libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts // SPDX-License-Identifier: MIT -import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, computed, effect } from '@angular/core'; import { ChatTraceComponent, type TraceState } from '../../primitives/chat-trace/chat-trace.component'; import { ChatToolCallCardComponent, type ToolCallInfo } from '../chat-tool-call-card/chat-tool-call-card.component'; -import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component'; +import { + ChatStreamingMdComponent, + markdownDocument, + type StreamingMarkdownDocument, +} from '../../streaming/streaming-markdown.component'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import type { Subagent, SubagentStatus } from '../../agent/subagent'; import type { Message, ToolCall } from '../../agent'; @@ -76,7 +80,7 @@ function statusToTraceState(s: SubagentStatus): TraceState {
{{ m.reasoning }}
} @if (textOf(m); as t) { - + } @for (tc of toolCallsFor(m); track tc.id) { @@ -89,6 +93,39 @@ function statusToTraceState(s: SubagentStatus): TraceState { export class ChatSubagentCardComponent { readonly subagent = input.required(); readonly state = computed(() => statusToTraceState(this.subagent().status())); + private readonly markdownDocuments = new Map(); + + constructor() { + effect(() => { + let liveIds: Set; + try { + liveIds = new Set(this.subagent().messages().map((message) => message.id)); + } catch { + return; + } + for (const id of [...this.markdownDocuments.keys()]) { + if (!liveIds.has(id)) this.markdownDocuments.delete(id); + } + }); + } + + protected markdownDocumentFor( + content: string, + message: Message, + ): StreamingMarkdownDocument { + const prior = this.markdownDocuments.get(message.id); + const delivery = message.delivery; + if ( + prior?.generation === delivery.generation && + prior.phase === delivery.phase && + prior.content === content + ) { + return prior; + } + const document = markdownDocument(content, delivery); + this.markdownDocuments.set(message.id, document); + return document; + } protected textOf(m: Message): string { const c = m.content; diff --git a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts index 884a82b43..937a914f2 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import { describe, it, expect, beforeEach } from 'vitest'; import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { Subject } from 'rxjs'; import { signal, effect, DestroyRef, inject, Injector, runInInjectionContext } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -14,10 +15,47 @@ import { createA2uiSurfaceStore } from '../../a2ui/surface-store'; import { signalStateStore, toRenderRegistry, views } from '@threadplane/render'; import { a2uiBasicCatalog } from '../../a2ui/catalog/index'; import { ChatGenerativeUiComponent } from '../../primitives/chat-generative-ui/chat-generative-ui.component'; +import { ChatStreamingMdComponent, markdownDocument } from '../../streaming/streaming-markdown.component'; import type { Spec, StateStore } from '@json-render/core'; import type { AgentEvent } from '../../agent/agent-event'; import type { Subagent } from '../../agent/subagent'; -import type { ToolCall, Message } from '../../agent'; +import { + completeDelivery, + staticDelivery, + streamingDelivery, + type CompleteOutcome, + type ToolCall, + type Message, +} from '../../agent'; + +describe('markdownDocument', () => { + it('maps streaming delivery generation, phase, and content', () => { + expect(markdownDocument('partial', streamingDelivery('attempt-1'))).toEqual({ + generation: 'attempt-1', + phase: 'streaming', + content: 'partial', + }); + }); + + it.each(['success', 'error', 'aborted', 'interrupted', 'paused'])( + 'maps complete delivery with %s outcome without exposing the outcome', + (outcome) => { + expect(markdownDocument('final', completeDelivery('attempt-2', outcome))).toEqual({ + generation: 'attempt-2', + phase: 'complete', + content: 'final', + }); + }, + ); + + it('appends the suffix exactly', () => { + expect(markdownDocument('thought', streamingDelivery('attempt-3'), ':reasoning')).toEqual({ + generation: 'attempt-3:reasoning', + phase: 'streaming', + content: 'thought', + }); + }); +}); describe('ChatComponent', () => { it('is defined as a class', () => { @@ -623,7 +661,9 @@ describe('ChatComponent — subagent cards render once (no duplicate per-message toolCallId: 'call_t', name: 'research', status: signal<'running'>('running'), - messages: signal([{ id: 'm1', role: 'assistant', content: 'hi' } as never]), + messages: signal([{ + id: 'm1', role: 'assistant', content: 'hi', delivery: staticDelivery('m1'), + } as never]), state: signal({}), }; } @@ -632,8 +672,14 @@ describe('ChatComponent — subagent cards render once (no duplicate per-message TestBed.configureTestingModule({}); const agent = mockAgent({ messages: [ - { id: 'a1', role: 'assistant', content: 'first', toolCallIds: ['call_t'], extra: {} } as never, - { id: 'a2', role: 'assistant', content: 'second', extra: {} } as never, + { + id: 'a1', role: 'assistant', content: 'first', toolCallIds: ['call_t'], + extra: {}, delivery: staticDelivery('a1'), + } as never, + { + id: 'a2', role: 'assistant', content: 'second', extra: {}, + delivery: staticDelivery('a2'), + } as never, ], toolCalls: [{ id: 'call_t', name: 'task', args: {}, status: 'success' } as ToolCall], withSubagents: true, @@ -659,7 +705,7 @@ describe('ChatComponent — reasoning runs (merged pill)', () => { interface ReasoningRun { content: string; durationMs: number | undefined; - streaming: boolean; + delivery: Message['delivery']; label: string | undefined; } interface ReasoningApi { @@ -678,11 +724,20 @@ describe('ChatComponent — reasoning runs (merged pill)', () => { return comp as unknown as ReasoningApi; } - const user = (id: string, content = 'hi'): Message => ({ id, role: 'user', content }); - const tool = (id: string): Message => ({ id, role: 'tool', content: 'result', toolCallId: 'c' }); - const reasoning = (id: string, reasoning: string, durationMs?: number, content = ''): Message => - ({ id, role: 'assistant', content, reasoning, reasoningDurationMs: durationMs }); - const answer = (id: string, content: string): Message => ({ id, role: 'assistant', content }); + const user = (id: string, content = 'hi'): Message => + ({ id, role: 'user', content, delivery: staticDelivery(id) }); + const tool = (id: string): Message => + ({ id, role: 'tool', content: 'result', toolCallId: 'c', delivery: staticDelivery(id) }); + const reasoning = ( + id: string, + reasoning: string, + durationMs?: number, + content = '', + delivery: Message['delivery'] = staticDelivery(id), + ): Message => + ({ id, role: 'assistant', content, reasoning, reasoningDurationMs: durationMs, delivery }); + const answer = (id: string, content: string): Message => + ({ id, role: 'assistant', content, delivery: staticDelivery(id) }); describe('reasoningRunStart', () => { it('is true for a reasoning step that follows a user message', () => { @@ -709,7 +764,7 @@ describe('ChatComponent — reasoning runs (merged pill)', () => { expect(run.label).toBeUndefined(); // single step → no "· N steps" label expect(run.content).toBe('just this'); expect(run.durationMs).toBe(4000); - expect(run.streaming).toBe(false); // not loading + expect(run.delivery).toEqual(staticDelivery('a1')); }); it('two steps separated by a tool message merge: joined content, summed duration, "N steps" label', () => { @@ -747,24 +802,251 @@ describe('ChatComponent — reasoning runs (merged pill)', () => { expect(a.reasoningRun(1).durationMs).toBe(3000); }); - it('streaming is true when the run’s last step is the loading tail with no response text yet', () => { - const a = api([user('u1'), reasoning('a1', 'thinking', undefined, '')], /* isLoading */ true); + it('uses the reasoning step delivery even when the agent is not loading', () => { + const delivery = streamingDelivery('attempt-1'); + const a = api([user('u1'), reasoning('a1', 'thinking', undefined, '', delivery)]); const run = a.reasoningRun(1); - expect(run.streaming).toBe(true); + expect(run.delivery).toBe(delivery); expect(run.label).toBeUndefined(); // still a single step }); - it('streaming reflects the LAST step of a multi-step run', () => { - // Two-step run whose final step is the loading tail (empty content). - const a = api([user('u1'), reasoning('a1', 'first', 2000), tool('t1'), reasoning('a2', 'second', undefined, '')], true); + it('uses the LAST step delivery for a multi-step run', () => { + const firstDelivery = completeDelivery('attempt-1', 'success'); + const lastDelivery = streamingDelivery('attempt-2'); + const a = api([ + user('u1'), + reasoning('a1', 'first', 2000, '', firstDelivery), + tool('t1'), + reasoning('a2', 'second', undefined, '', lastDelivery), + ]); const run = a.reasoningRun(1); - expect(run.streaming).toBe(true); + expect(run.delivery).toBe(lastDelivery); + expect(markdownDocument(run.content, run.delivery, ':reasoning')).toEqual({ + generation: 'attempt-2:reasoning', + phase: 'streaming', + content: 'first\n\nsecond', + }); expect(run.label).toBe('Thought for 2s · 2 steps'); }); - it('streaming is false once response text has arrived on the tail step', () => { - const a = api([user('u1'), reasoning('a1', 'thinking', 2000, 'here is the answer')], true); - expect(a.reasoningRun(1).streaming).toBe(false); + it('ignores global loading and tail position when selecting delivery', () => { + const delivery = completeDelivery('attempt-1', 'paused'); + const messages = [ + user('u1'), + reasoning('a1', 'thinking', 2000, '', delivery), + answer('a2', 'later message'), + ]; + expect(api(messages, true).reasoningRun(1).delivery).toBe(delivery); }); }); }); + +describe('ChatComponent — markdown delivery', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ChatComponent] }); + }); + + it('binds the classified markdown document to the message delivery', () => { + const delivery = streamingDelivery('answer-attempt'); + const agent = mockAgent({ + messages: [ + { id: 'a1', role: 'assistant', content: '**partial**', delivery }, + ], + }); + const fixture = TestBed.createComponent(ChatComponent); + fixture.componentRef.setInput('agent', agent); + fixture.detectChanges(); + + const markdown = fixture.debugElement.query(By.directive(ChatStreamingMdComponent)); + expect(markdown.componentInstance.document()).toEqual({ + generation: 'answer-attempt', + phase: 'streaming', + content: '**partial**', + }); + }); + + it('keeps the markdown document stable when loading and tail position change', () => { + const delivery = completeDelivery('answer-attempt', 'paused'); + const message: Message = { + id: 'a1', role: 'assistant', content: 'paused answer', delivery, + }; + const agent = mockAgent({ messages: [message], isLoading: false }); + const fixture = TestBed.createComponent(ChatComponent); + fixture.componentRef.setInput('agent', agent); + fixture.detectChanges(); + + let markdown = fixture.debugElement.query(By.directive(ChatStreamingMdComponent)); + const document = markdown.componentInstance.document(); + + agent.isLoading.set(true); + fixture.detectChanges(); + markdown = fixture.debugElement.query(By.directive(ChatStreamingMdComponent)); + expect(markdown.componentInstance.document()).toBe(document); + expect(markdown.componentInstance.document()).toEqual({ + generation: 'answer-attempt', + phase: 'complete', + content: 'paused answer', + }); + + agent.messages.set([ + message, + { id: 'u2', role: 'user', content: 'next', delivery: staticDelivery('u2') }, + ]); + fixture.detectChanges(); + + markdown = fixture.debugElement.query(By.directive(ChatStreamingMdComponent)); + expect(markdown.componentInstance.document()).toBe(document); + expect(markdown.componentInstance.document()).toEqual({ + generation: 'answer-attempt', + phase: 'complete', + content: 'paused answer', + }); + }); + + it('reclassifies equal-length markdown when delivery generation changes', () => { + const agent = mockAgent({ + messages: [{ + id: 'a1', + role: 'assistant', + content: 'alpha', + delivery: completeDelivery('g1', 'success'), + }], + }); + const fixture = TestBed.createComponent(ChatComponent); + fixture.componentRef.setInput('agent', agent); + fixture.detectChanges(); + + let markdown = fixture.debugElement.query(By.directive(ChatStreamingMdComponent)); + expect(markdown.componentInstance.document()).toEqual({ + generation: 'g1', + phase: 'complete', + content: 'alpha', + }); + + agent.messages.set([{ + id: 'a1', + role: 'assistant', + content: 'bravo', + delivery: completeDelivery('g2', 'success'), + }]); + fixture.detectChanges(); + + markdown = fixture.debugElement.query(By.directive(ChatStreamingMdComponent)); + expect(markdown.componentInstance.document()).toEqual({ + generation: 'g2', + phase: 'complete', + content: 'bravo', + }); + }); + + it('reclassifies content type when delivery generation changes', () => { + const agent = mockAgent({ + messages: [{ + id: 'a1', + role: 'assistant', + content: 'plain markdown', + delivery: completeDelivery('g1', 'success'), + }], + }); + const fixture = TestBed.createComponent(ChatComponent); + fixture.componentRef.setInput('agent', agent); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.directive(ChatStreamingMdComponent))).not.toBeNull(); + expect(fixture.debugElement.query(By.directive(ChatGenerativeUiComponent))).toBeNull(); + + agent.messages.set([{ + id: 'a1', + role: 'assistant', + content: JSON.stringify({ + root: 'r1', + elements: { r1: { type: 'Text', props: { label: 'Structured' } } }, + }), + delivery: completeDelivery('g2', 'success'), + }]); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.directive(ChatStreamingMdComponent))).toBeNull(); + expect(fixture.debugElement.query(By.directive(ChatGenerativeUiComponent))).not.toBeNull(); + }); + + it('binds merged reasoning markdown to the last step delivery', () => { + const first: Message = { + id: 'a1', + role: 'assistant', + content: '', + reasoning: 'first thought', + delivery: streamingDelivery('g1'), + }; + const agent = mockAgent({ + messages: [ + { id: 'u1', role: 'user', content: 'question', delivery: staticDelivery('u1') }, + first, + ], + isLoading: false, + }); + const fixture = TestBed.createComponent(ChatComponent); + fixture.componentRef.setInput('agent', agent); + fixture.detectChanges(); + + let markdown = fixture.debugElement.query( + By.css('chat-reasoning chat-streaming-md'), + ); + expect(markdown.componentInstance.document()).toEqual({ + generation: 'g1:reasoning', + phase: 'streaming', + content: 'first thought', + }); + + const reasoningHeader = fixture.nativeElement.querySelector( + 'chat-reasoning .chat-reasoning__header', + ) as HTMLButtonElement; + reasoningHeader.click(); + reasoningHeader.click(); + fixture.detectChanges(); + + agent.messages.update((messages) => [ + ...messages, + { + id: 't1', + role: 'tool', + content: 'result', + toolCallId: 'call-1', + delivery: staticDelivery('t1'), + }, + { + id: 'a2', + role: 'assistant', + content: '', + reasoning: 'second thought', + delivery: completeDelivery('g2', 'paused'), + }, + ]); + fixture.detectChanges(); + + markdown = fixture.debugElement.query( + By.css('chat-reasoning chat-streaming-md'), + ); + const expected = { + generation: 'g2:reasoning', + phase: 'complete', + content: 'first thought\n\nsecond thought', + } as const; + expect(markdown.componentInstance.document()).toEqual(expected); + + agent.isLoading.set(true); + fixture.detectChanges(); + expect(markdown.componentInstance.document()).toEqual(expected); + + agent.messages.update((messages) => [ + ...messages, + { id: 'u2', role: 'user', content: 'next', delivery: staticDelivery('u2') }, + ]); + fixture.detectChanges(); + + markdown = fixture.debugElement.query( + By.css('chat-reasoning chat-streaming-md'), + ); + expect(markdown.componentInstance.document()).toEqual(expected); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 5e7d96e87..3026b2cb6 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -6,7 +6,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KeyValuePipe } from '@angular/common'; -import type { Agent, Message } from '../../agent'; +import type { Agent, Message, MessageDelivery } from '../../agent'; import { ChatReasoningComponent } from '../../primitives/chat-reasoning/chat-reasoning.component'; import type { ViewRegistry, RenderEvent } from '@threadplane/render'; import type { A2uiActionMessage } from '@threadplane/a2ui'; @@ -29,7 +29,11 @@ import { ChatErrorComponent } from '../../primitives/chat-error/chat-error.compo import { ChatThreadListComponent, type Thread } from '../../primitives/chat-thread-list/chat-thread-list.component'; import { ChatGenerativeUiComponent } from '../../primitives/chat-generative-ui/chat-generative-ui.component'; import { ChatToolViewsComponent } from '../../primitives/chat-tool-views/chat-tool-views.component'; -import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component'; +import { + ChatStreamingMdComponent, + markdownDocument, + type StreamingMarkdownDocument, +} from '../../streaming/streaming-markdown.component'; import { ChatToolCallsComponent } from '../../primitives/chat-tool-calls/chat-tool-calls.component'; import { ChatMessageActionsComponent } from '../../primitives/chat-message-actions/chat-message-actions.component'; import { ChatWelcomeComponent } from '../../primitives/chat-welcome/chat-welcome.component'; @@ -213,7 +217,7 @@ export function isPinned( @let run = reasoningRun(i); @@ -232,7 +236,7 @@ export function isPinned( (events)="onClientToolEvent($event)" /> @if (classified.markdown(); as md) { - + } @if (classified.spec(); as spec) {