diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index f078e159f..29f8a8a73 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1999,7 +1999,7 @@ "methods": [ { "name": "classifyMessage", - "signature": "classifyMessage(content: string, message: object): ContentClassifier", + "signature": "classifyMessage(content: string, message: Pick): ContentClassifier", "description": "", "params": [ { @@ -2010,7 +2010,7 @@ }, { "name": "message", - "type": "object", + "type": "Pick", "description": "", "optional": false } @@ -2067,19 +2067,19 @@ ] }, { - "name": "isReasoningStreaming", - "signature": "isReasoningStreaming(message: Message, index: number): boolean", - "description": "True while a message's reasoning is mid-stream — i.e. it's the latest\nmessage, the agent is loading, the message has reasoning content, and\nno response text has arrived yet. Once the response text begins, the\nreasoning pill collapses (per its internal logic).", + "name": "markdownDocumentFor", + "signature": "markdownDocumentFor(content: string, message: Pick): StreamingMarkdownDocument", + "description": "", "params": [ { - "name": "message", - "type": "Message", + "name": "content", + "type": "string", "description": "", "optional": false }, { - "name": "index", - "type": "number", + "name": "message", + "type": "Pick", "description": "", "optional": false } @@ -2253,7 +2253,7 @@ { "name": "reasoningRun", "signature": "reasoningRun(index: number): object", - "description": "Aggregate the reasoning RUN starting at `index`: joins each step's\nreasoning, sums durations, counts steps, and computes the streaming flag\nand the merged label when N > 1 (\"Thought for {total} · {N} steps\", or\njust \"{N} steps\" when no step reported timing).", + "description": "Aggregate the reasoning RUN starting at `index`: joins each step's\nreasoning, sums durations, counts steps, and returns the last step's\ndelivery because that step owns the current aggregate snapshot. Also\ncomputes the merged label when N > 1 (\"Thought for {total} · {N} steps\",\nor just \"{N} steps\" when no step reported timing).", "params": [ { "name": "index", @@ -3426,7 +3426,7 @@ { "name": "ChatReasoningComponent", "kind": "class", - "description": "Renders an assistant's reasoning content as a compact pill that\nexpands to reveal the underlying text. Three visual states:\n\n- Streaming: pill shows \"Thinking…\" with a pulsing dot; auto-expanded\n so the user sees reasoning stream in real time.\n- Idle, with durationMs known: pill shows \"Thought for {duration}\";\n collapsed by default, expand on click.\n- Idle, no duration: pill shows \"Show reasoning\"; collapsed by default.\n\nThe body re-uses chat-streaming-md so reasoning content gets the same\nmarkdown rendering pipeline as the visible response (lists, code,\nstep labels often appear in reasoning output).\n\nInternal state: a tristate \"expanded\" — null means follow auto state-\ndriven logic (force-expand on isStreaming, otherwise honor\ndefaultExpanded), boolean is a manual user choice that wins for the\nlifetime of the instance.", + "description": "Renders an assistant's reasoning content as a compact pill that\nexpands to reveal the underlying text. Three visual states:\n\n- Streaming: pill shows \"Thinking…\" with a pulsing dot; auto-expanded\n so the user sees reasoning stream in real time.\n- Idle, with durationMs known: pill shows \"Thought for {duration}\";\n collapsed by default, expand on click.\n- Idle, no duration: pill shows \"Show reasoning\"; collapsed by default.\n\nThe body re-uses chat-streaming-md so reasoning content gets the same\nmarkdown rendering pipeline as the visible response (lists, code,\nstep labels often appear in reasoning output).\n\nInternal state: a tristate \"expanded\" — null means follow auto state-\ndriven logic (force-expand while delivery is streaming, otherwise honor\ndefaultExpanded), boolean is a manual user choice that wins for the\nlifetime of the instance.", "params": [], "examples": [], "properties": [ @@ -3442,6 +3442,18 @@ "description": "", "optional": false }, + { + "name": "delivery", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "document", + "type": "Signal", + "description": "", + "optional": false + }, { "name": "durationMs", "type": "InputSignal", @@ -3468,7 +3480,7 @@ }, { "name": "isStreaming", - "type": "InputSignal", + "type": "Signal", "description": "", "optional": false }, @@ -3927,13 +3939,13 @@ { "name": "ChatStreamingMdComponent", "kind": "class", - "description": "Renders streaming markdown by walking a @cacheplane/partial-markdown AST\nthrough @threadplane/render's view registry.\n\nReactivity model: the live `parser.root` keeps a stable JS reference\nacross pushes (partial-markdown's identity guarantee). To make Angular\nsignals propagate downstream when the underlying tree changes, we surface\na materialized snapshot via `materialize()`. The snapshot shares\nstructurally — unchanged subtrees keep the SAME reference, and any\ndescendant change yields a NEW root reference. This lets Angular's\n`Object.is` equality check both detect changes (root reference differs)\nand short-circuit unchanged subtrees (per-node references stable).\n\nOverride per-node-type renderers via the `[viewRegistry]` input or by\nsupplying a different `MARKDOWN_VIEW_REGISTRY` provider in the injector\ntree.", + "description": "Renders one explicitly-versioned markdown document through the shared view\nregistry. A generation owns one parser session; append-only updates preserve\nparser and subtree identity, while generation changes replace the session.", "params": [], "examples": [], "properties": [ { - "name": "content", - "type": "InputSignal", + "name": "document", + "type": "InputSignal", "description": "", "optional": false }, @@ -3949,12 +3961,6 @@ "description": "", "optional": false }, - { - "name": "streaming", - "type": "InputSignal", - "description": "", - "optional": false - }, { "name": "viewRegistry", "type": "InputSignal | RenderViewEntry>> | undefined>", @@ -3985,6 +3991,25 @@ } ], "methods": [ + { + "name": "markdownDocumentFor", + "signature": "markdownDocumentFor(content: string, message: Message): StreamingMarkdownDocument", + "description": "", + "params": [ + { + "name": "content", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "message", + "type": "Message", + "description": "", + "optional": false + } + ] + }, { "name": "textOf", "signature": "textOf(m: Message): string", @@ -7355,6 +7380,12 @@ "description": "Plain text, or a list of structured content blocks.", "optional": false }, + { + "name": "delivery", + "type": "MessageDelivery", + "description": "Adapter-owned authoritative delivery lifecycle state for this message.", + "optional": false + }, { "name": "extra", "type": "Record", @@ -7850,6 +7881,32 @@ ], "examples": [] }, + { + "name": "StreamingMarkdownDocument", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "content", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "generation", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "phase", + "type": "\"streaming\" | \"complete\"", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "Subagent", "kind": "interface", @@ -8349,6 +8406,13 @@ "signature": "StandardSchemaInferOutput & { clientTool?: ClientToolLifecycle; status?: ToolCallStatus }", "examples": [] }, + { + "name": "CompleteOutcome", + "kind": "type", + "description": "Terminal result of one response attempt.\n\n`paused` is an intentional stop awaiting resumable input; `interrupted` means\nthe response stream ended unexpectedly. The other outcomes indicate normal\ncompletion, failure, or caller cancellation.", + "signature": "\"success\" | \"error\" | \"aborted\" | \"interrupted\" | \"paused\"", + "examples": [] + }, { "name": "ContentBlock", "kind": "type", @@ -8370,6 +8434,13 @@ "signature": "\"accept\" | \"edit\" | \"respond\" | \"ignore\"", "examples": [] }, + { + "name": "MessageDelivery", + "kind": "type", + "description": "Delivery lifecycle for one response attempt. `generation` identifies that\nattempt and is stable only for its lifetime. `streaming` means chunks may\nstill arrive; `complete` means the attempt has stopped with a terminal outcome.", + "signature": "object | object", + "examples": [] + }, { "name": "MessageTemplateType", "kind": "type", @@ -8405,6 +8476,13 @@ "signature": "NonNullable[\"output\"]", "examples": [] }, + { + "name": "StreamingMarkdownContractViolationPolicy", + "kind": "type", + "description": "", + "signature": "\"throw\" | \"rebuild\"", + "examples": [] + }, { "name": "SubagentStatus", "kind": "type", @@ -8496,6 +8574,13 @@ "signature": "InjectionToken | RenderViewEntry>>>", "examples": [] }, + { + "name": "STREAMING_MARKDOWN_CONTRACT_VIOLATION_POLICY", + "kind": "const", + "description": "", + "signature": "InjectionToken", + "examples": [] + }, { "name": "a2uiBasicCatalog", "kind": "function", @@ -8695,6 +8780,31 @@ }, "examples": [] }, + { + "name": "completeDelivery", + "kind": "function", + "description": "", + "signature": "completeDelivery(generation: string, outcome: TOutcome): object", + "params": [ + { + "name": "generation", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "outcome", + "type": "TOutcome", + "description": "", + "optional": false + } + ], + "returns": { + "type": "object", + "description": "" + }, + "examples": [] + }, { "name": "createA2uiSurfaceStore", "kind": "function", @@ -9197,6 +9307,37 @@ "```ts\nconst userTurns = agent.messages().filter(isUserMessage);\n```" ] }, + { + "name": "markdownDocument", + "kind": "function", + "description": "", + "signature": "markdownDocument(content: string, delivery: MessageDelivery, suffix: string): StreamingMarkdownDocument", + "params": [ + { + "name": "content", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "delivery", + "type": "MessageDelivery", + "description": "", + "optional": false + }, + { + "name": "suffix", + "type": "string", + "description": "", + "optional": true + } + ], + "returns": { + "type": "StreamingMarkdownDocument", + "description": "" + }, + "examples": [] + }, { "name": "messageContent", "kind": "function", @@ -9234,7 +9375,7 @@ "description": "" }, "examples": [ - "```ts\nconst agent = mockAgent({\n messages: [{ id: '1', role: 'assistant', content: 'Hi' }],\n isLoading: true,\n});\n```" + "```ts\nimport { staticDelivery } from '@threadplane/chat';\n\nconst agent = mockAgent({\n messages: [{ id: '1', role: 'assistant', content: 'Hi', delivery: staticDelivery('1') }],\n isLoading: true,\n});\n```" ] }, { @@ -9428,6 +9569,25 @@ }, "examples": [] }, + { + "name": "staticDelivery", + "kind": "function", + "description": "", + "signature": "staticDelivery(messageId: string): object", + "params": [ + { + "name": "messageId", + "type": "string", + "description": "", + "optional": false + } + ], + "returns": { + "type": "object", + "description": "" + }, + "examples": [] + }, { "name": "statusColor", "kind": "function", @@ -9447,6 +9607,25 @@ }, "examples": [] }, + { + "name": "streamingDelivery", + "kind": "function", + "description": "", + "signature": "streamingDelivery(generation: string): object", + "params": [ + { + "name": "generation", + "type": "string", + "description": "", + "optional": false + } + ], + "returns": { + "type": "object", + "description": "" + }, + "examples": [] + }, { "name": "submitMessage", "kind": "function", diff --git a/apps/website/content/docs/chat/api/mock-agent.mdx b/apps/website/content/docs/chat/api/mock-agent.mdx index 74038e41b..8e6caec27 100644 --- a/apps/website/content/docs/chat/api/mock-agent.mdx +++ b/apps/website/content/docs/chat/api/mock-agent.mdx @@ -5,7 +5,12 @@ **Import:** ```typescript -import { mockAgent } from '@threadplane/chat'; +import { + completeDelivery, + mockAgent, + staticDelivery, + streamingDelivery, +} from '@threadplane/chat'; import type { MockAgent } from '@threadplane/chat'; ``` @@ -34,13 +39,13 @@ function mockAgent(opts?: MockAgentOptions): MockAgent ```typescript import { TestBed } from '@angular/core/testing'; -import { ChatComponent, mockAgent } from '@threadplane/chat'; +import { ChatComponent, mockAgent, staticDelivery } from '@threadplane/chat'; it('renders messages from an agent', async () => { const agent = mockAgent({ messages: [ - { id: 'm1', role: 'user', content: 'Hello' }, - { id: 'm2', role: 'assistant', content: 'Hi there' }, + { id: 'm1', role: 'user', content: 'Hello', delivery: staticDelivery('m1') }, + { id: 'm2', role: 'assistant', content: 'Hi there', delivery: staticDelivery('m2') }, ], }); @@ -63,10 +68,26 @@ The returned mock exposes writable signals, so tests can update state directly: ```typescript const agent = mockAgent(); +const generation = 'attempt-1'; +agent.status.set('running'); agent.isLoading.set(true); +agent.messages.set([{ + id: 'm1', + role: 'assistant', + content: 'Ret', + delivery: streamingDelivery(generation), +}]); + +agent.status.set('error'); +agent.isLoading.set(false); agent.error.set(new Error('Connection failed')); -agent.messages.set([{ id: 'm1', role: 'assistant', content: 'Retry?' }]); +agent.messages.set([{ + id: 'm1', + role: 'assistant', + content: 'Retry?', + delivery: completeDelivery(generation, 'error'), +}]); ``` ## Spying on Actions diff --git a/apps/website/content/docs/chat/components/chat-reasoning.mdx b/apps/website/content/docs/chat/components/chat-reasoning.mdx index ee77bee0b..38d5b8fd4 100644 --- a/apps/website/content/docs/chat/components/chat-reasoning.mdx +++ b/apps/website/content/docs/chat/components/chat-reasoning.mdx @@ -7,23 +7,29 @@ **Import:** ```typescript -import { ChatReasoningComponent, formatDuration } from '@threadplane/chat'; +import { + ChatReasoningComponent, + completeDelivery, + formatDuration, + streamingDelivery, + type MessageDelivery, +} from '@threadplane/chat'; ``` ## Visual states | State | Pill label | Behavior | |---|---|---| -| `[isStreaming]="true"` | "Thinking…" with pulsing dot | Auto-expanded; body streams in | -| Idle, `[durationMs]` set | "Thought for Ns" | Collapsed by default; click to expand | -| Idle, no `[durationMs]` | "Show reasoning" | Collapsed by default; click to expand | +| `[delivery]` phase is `streaming` | "Thinking…" with pulsing dot | Auto-expanded; body streams in | +| `[delivery]` phase is `complete`, `[durationMs]` set | "Thought for Ns" | Collapsed by default; click to expand | +| `[delivery]` phase is `complete`, no `[durationMs]` | "Show reasoning" | Collapsed by default; click to expand | ## Inputs | Input | Type | Default | Description | |---|---|---|---| | `[content]` | `string` | `''` | The reasoning text to render | -| `[isStreaming]` | `boolean` | `false` | True while the model is mid-reasoning | +| `[delivery]` | `MessageDelivery` | Required | Authoritative generation, phase, and terminal outcome from the adapter | | `[durationMs]` | `number \| undefined` | `undefined` | Wall-clock duration of the reasoning phase | | `[label]` | `string \| undefined` | `undefined` | Override the auto-derived label | | `[defaultExpanded]` | `boolean` | `false` | Open the panel by default when idle | @@ -33,11 +39,21 @@ import { ChatReasoningComponent, formatDuration } from '@threadplane/chat'; ```html ``` +Keep the generation stable while the same response attempt moves from streaming to complete: + +```typescript +reasoningDelivery: MessageDelivery = streamingDelivery('turn-42'); + +finishReasoning() { + this.reasoningDelivery = completeDelivery('turn-42', 'success'); +} +``` + ## formatDuration utility Use `formatDuration(ms)` to render the duration string yourself (e.g. for a sidebar): @@ -51,7 +67,7 @@ formatDuration(72_000) // "1m 12s" ## Behavior - The component hides itself entirely (`display: none`) when `[content]` is empty. -- `[isStreaming]="true"` force-expands the panel so streaming content is visible. -- A user click on the pill toggles the panel; the user choice persists across `[isStreaming]` transitions for the lifetime of the instance. -- When `isStreaming` re-engages on a follow-up turn (a new reasoning phase begins after a prior idle period), the panel resets to expanded. +- A `streaming` delivery force-expands the panel so streaming content is visible. +- A user click on the pill toggles the panel; the user choice persists across delivery phase transitions for the lifetime of the instance. +- When the delivery enters `streaming` after a completed phase, the panel resets to expanded. - The body re-uses `` so reasoning content gets the same markdown rendering pipeline as the response (lists, code blocks, headings render). diff --git a/apps/website/content/docs/chat/concepts/message-model.mdx b/apps/website/content/docs/chat/concepts/message-model.mdx index 0c7dc084a..ce9b33d3e 100644 --- a/apps/website/content/docs/chat/concepts/message-model.mdx +++ b/apps/website/content/docs/chat/concepts/message-model.mdx @@ -9,6 +9,7 @@ This matters because provider message formats aren't stable enough to build cust ```ts interface Message { id: string; + delivery: MessageDelivery; role: 'user' | 'assistant' | 'system' | 'tool'; content: string | ContentBlock[]; toolCallId?: string; @@ -23,7 +24,39 @@ interface Message { } ``` -The fields are intentionally small. Put portable UI state in known fields. Put runtime-specific data in `extra`. +The fields are intentionally small. Put portable UI state in known fields. Put runtime-specific data in `extra`. The adapter must provide `delivery` for every message. + +## Delivery Lifecycle + +`Message.delivery` is the adapter's authoritative lifecycle state for one response attempt: + +```ts +type MessageDelivery = + | { generation: string; phase: 'streaming' } + | { + generation: string; + phase: 'complete'; + outcome: 'success' | 'error' | 'aborted' | 'interrupted' | 'paused'; + }; +``` + +Use the public helpers rather than repeating these object literals: + +```ts +import { + completeDelivery, + staticDelivery, + streamingDelivery, +} from '@threadplane/chat'; + +const partial = streamingDelivery('attempt-7'); +const finished = completeDelivery('attempt-7', 'success'); +const historical = staticDelivery('message-42'); +``` + +Keep `generation` stable while an attempt streams and transitions to complete. Streaming content is append-only; after completion, content and delivery are immutable. Retries and replacement attempts use a new generation. `staticDelivery(messageId)` is the concise choice for user messages, tool results, and already-complete history. + +Components consume this field directly. Do not derive a message's phase from agent-wide loading state: concurrent work, interruptions, retries, and restored history can all make that inference wrong. ## Roles diff --git a/apps/website/content/docs/chat/getting-started/quickstart.mdx b/apps/website/content/docs/chat/getting-started/quickstart.mdx index 7e75a16de..073d2e52d 100644 --- a/apps/website/content/docs/chat/getting-started/quickstart.mdx +++ b/apps/website/content/docs/chat/getting-started/quickstart.mdx @@ -79,7 +79,7 @@ No LangGraph server yet? Bind `` to `mockAgent()` from `@threadplane/chat` ```ts // chat-page.component.ts import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { ChatComponent, mockAgent } from '@threadplane/chat'; +import { ChatComponent, mockAgent, staticDelivery } from '@threadplane/chat'; @Component({ selector: 'app-chat-page', @@ -95,8 +95,13 @@ import { ChatComponent, mockAgent } from '@threadplane/chat'; export class ChatPageComponent { protected readonly chatAgent = mockAgent({ messages: [ - { id: 'm1', role: 'user', content: 'Hello' }, - { id: 'm2', role: 'assistant', content: 'Hi there — I am a mock agent.' }, + { id: 'm1', role: 'user', content: 'Hello', delivery: staticDelivery('m1') }, + { + id: 'm2', + role: 'assistant', + content: 'Hi there — I am a mock agent.', + delivery: staticDelivery('m2'), + }, ], }); } diff --git a/apps/website/content/docs/chat/guides/markdown.mdx b/apps/website/content/docs/chat/guides/markdown.mdx index 713714179..068b97559 100644 --- a/apps/website/content/docs/chat/guides/markdown.mdx +++ b/apps/website/content/docs/chat/guides/markdown.mdx @@ -53,6 +53,9 @@ import { ChatStreamingMdComponent, ChatMessageListComponent, MessageTemplateDirective, + markdownDocument, + type Message, + type StreamingMarkdownDocument, } from '@threadplane/chat'; @Component({ @@ -62,13 +65,23 @@ import { template: ` - + `, }) export class ChatViewComponent { // chatRef = injectAgent(); // with provideAgent({...}) in the component's providers + + documentFor(message: Message): StreamingMarkdownDocument { + const content = typeof message.content === 'string' + ? message.content + : message.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join(''); + return markdownDocument(content, message.delivery); + } } ``` @@ -95,7 +108,19 @@ export class MarkdownHtmlComponent { ## Streaming Markdown with chat-streaming-md -`` is the component that renders AI message content token-by-token using the node-based rendering pipeline. It resolves each markdown node type against `MARKDOWN_VIEW_REGISTRY` — a chat-internal DI token exported from `@threadplane/chat`. +`` is the component that renders AI message content token-by-token using the node-based rendering pipeline. It requires one atomic `[document]` input with this shape: + +```typescript +interface StreamingMarkdownDocument { + generation: string; + phase: 'streaming' | 'complete'; + content: string; +} +``` + +Use the exported `markdownDocument(content, delivery)` helper to derive it from message content and the adapter-owned `Message.delivery`. Within one generation, streaming content must be append-only. Complete the same generation once, then leave its content unchanged. A retry or replacement starts a new generation. The component uses those guarantees to preserve parser and rendered-node identity while chunks arrive. + +It resolves each markdown node type against `MARKDOWN_VIEW_REGISTRY` — a chat-internal DI token exported from `@threadplane/chat`. By default the component provides `cacheplaneMarkdownViews` (the full 22-node registry) on its own component injector. You can override this at two levels: @@ -138,7 +163,12 @@ Pass a `ViewRegistry` directly to a single `` via its `[viewR ```typescript import { Component } from '@angular/core'; -import { ChatStreamingMdComponent, cacheplaneMarkdownViews } from '@threadplane/chat'; +import { + ChatStreamingMdComponent, + cacheplaneMarkdownViews, + markdownDocument, + staticDelivery, +} from '@threadplane/chat'; import { overrideViews } from '@threadplane/render'; import { MyCodeBlockComponent } from './my-code-block.component'; @@ -147,11 +177,14 @@ import { MyCodeBlockComponent } from './my-code-block.component'; standalone: true, imports: [ChatStreamingMdComponent], template: ` - + `, }) export class CustomChatComponent { - content = ''; + document = markdownDocument( + '# Custom renderer\n\nThis document is already complete.', + staticDelivery('custom-preview'), + ); myRegistry = overrideViews(cacheplaneMarkdownViews, { 'code-block': MyCodeBlockComponent, }); diff --git a/apps/website/content/docs/chat/guides/writing-an-adapter.mdx b/apps/website/content/docs/chat/guides/writing-an-adapter.mdx index 61d7117fa..30db34033 100644 --- a/apps/website/content/docs/chat/guides/writing-an-adapter.mdx +++ b/apps/website/content/docs/chat/guides/writing-an-adapter.mdx @@ -50,7 +50,7 @@ The design invariant is: **state lives on signals; `events$` carries only things Below is a complete `EchoAgent` factory — roughly 80 lines — that satisfies the full `Agent` contract without any network call. It shows the signal pattern clearly and is a solid starting point for your own adapter. -On `submit`, the factory optimistically appends the user message, then after a short delay appends an assistant message that echoes the input back. There are no tool calls, no custom events, and no interrupts. +On `submit`, the factory appends a complete user message and starts an assistant delivery generation. After a short delay it completes that same generation with a successful echo; stopping early completes it as aborted. There are no tool calls, custom events, or interrupts. ```typescript import { signal, type Signal } from '@angular/core'; @@ -59,6 +59,11 @@ import type { Agent, Message, AgentStatus, ToolCall, AgentEvent, AgentSubmitInput, AgentSubmitOptions, } from '@threadplane/chat'; +import { + completeDelivery, + staticDelivery, + streamingDelivery, +} from '@threadplane/chat'; export interface EchoAgentOptions { /** Delay before the echoed reply appears, in ms. Defaults to 400. */ @@ -73,6 +78,7 @@ export function createEchoAgent(opts: EchoAgentOptions = {}): Agent { const toolCalls = signal([]); const state = signal>({}); let pending: ReturnType | undefined; + let activeReply: { id: string; generation: string } | undefined; const submit = async (input: AgentSubmitInput, _opts?: AgentSubmitOptions) => { if (input.message === undefined) return; @@ -81,30 +87,62 @@ export function createEchoAgent(opts: EchoAgentOptions = {}): Agent { ? input.message : input.message.map((b) => b.type === 'text' ? b.text : '').join(''); - // Optimistic user message + const userId = cryptoRandomId(); + const assistantId = cryptoRandomId(); + const generation = cryptoRandomId(); + + // Static user input is already complete. The assistant owns a new + // generation that remains stable until this attempt reaches an outcome. messages.update((prev) => [ ...prev, - { id: cryptoRandomId(), role: 'user', content: text }, + { + id: userId, + role: 'user', + content: text, + delivery: staticDelivery(userId), + }, + { + id: assistantId, + role: 'assistant', + content: '', + delivery: streamingDelivery(generation), + }, ]); + activeReply = { id: assistantId, generation }; status.set('running'); isLoading.set(true); error.set(null); pending = setTimeout(() => { - messages.update((prev) => [ - ...prev, - { id: cryptoRandomId(), role: 'assistant', content: `You said: ${text}` }, - ]); + messages.update((prev) => prev.map((message) => + message.id === assistantId + ? { + ...message, + content: `You said: ${text}`, + delivery: completeDelivery(generation, 'success'), + } + : message + )); status.set('idle'); isLoading.set(false); pending = undefined; + activeReply = undefined; }, opts.delayMs ?? 400); }; const stop = async () => { if (pending !== undefined) clearTimeout(pending); + if (activeReply) { + const { id, generation } = activeReply; + messages.update((prev) => prev.map((message) => + message.id === id + ? { ...message, delivery: completeDelivery(generation, 'aborted') } + : message + )); + } pending = undefined; + activeReply = undefined; status.set('idle'); isLoading.set(false); }; diff --git a/apps/website/public/AGENTS.md b/apps/website/public/AGENTS.md index 5d54e537b..ffad0b76d 100644 --- a/apps/website/public/AGENTS.md +++ b/apps/website/public/AGENTS.md @@ -1,4 +1,4 @@ -# Threadplane v0.0.55 +# Threadplane v0.0.56 Production-ready chat, durable threads, interrupts, subagents, planning, memory, and generative UI for Angular agent apps. diff --git a/apps/website/public/CLAUDE.md b/apps/website/public/CLAUDE.md index 5d54e537b..ffad0b76d 100644 --- a/apps/website/public/CLAUDE.md +++ b/apps/website/public/CLAUDE.md @@ -1,4 +1,4 @@ -# Threadplane v0.0.55 +# Threadplane v0.0.56 Production-ready chat, durable threads, interrupts, subagents, planning, memory, and generative UI for Angular agent apps. 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..b25092495 --- /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 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. 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** + +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 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}`; + +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`. 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..e6ce45e9b --- /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 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 + +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. diff --git a/examples/chat/angular/e2e/fixtures/streaming-markdown.json b/examples/chat/angular/e2e/fixtures/streaming-markdown.json index aa63f2104..0fcea76d4 100644 --- a/examples/chat/angular/e2e/fixtures/streaming-markdown.json +++ b/examples/chat/angular/e2e/fixtures/streaming-markdown.json @@ -1,5 +1,13 @@ { "fixtures": [ + { + "match": { + "userMessage": "Give me a blockquote with two lines, then a markdown table with columns issue, expected behavior, verification." + }, + "response": { + "content": "> First quoted line\n> Second quoted line\n\n| Issue | Expected behavior | Verification |\n| --- | --- | --- |\n| Blockquote nesting | Both quoted lines render inside one blockquote | Inspect for one `
` with no stray quoted paragraph |\n| Table streaming | Rows remain inside the same table while content streams | Inspect for one `` with body rows |\n| Parser recovery | Final DOM has no raw pipe paragraphs | Confirm no paragraph text contains `|` delimiters |" + } + }, { "match": { "userMessage": "stream a markdown comparison table regression" }, "response": { @@ -23,6 +31,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, + }]; + }); +} diff --git a/examples/chat/angular/e2e/regenerate.spec.ts b/examples/chat/angular/e2e/regenerate.spec.ts index 22c85d28e..89e594891 100644 --- a/examples/chat/angular/e2e/regenerate.spec.ts +++ b/examples/chat/angular/e2e/regenerate.spec.ts @@ -1,41 +1,61 @@ // SPDX-License-Identifier: MIT -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; import { activeThreadIdFromUrl, sendPromptAndWait } from './test-helpers'; -test('regenerate: re-running keeps 1 user / 1 assistant in the conversation', async ({ - page, -}) => { - // Reuse the smoke 'say hi briefly' fixture — aimock returns the same - // response on regenerate; the invariant we care about is the count. - await sendPromptAndWait(page, 'say hi briefly'); - - const userMessages = page.locator('chat-message[data-role="user"]'); - const assistantMessages = page.locator('chat-message[data-role="assistant"]'); - await expect(userMessages).toHaveCount(1); +async function regenerateAndWait(page: Page): Promise { + const assistantMessages = page.locator( + 'chat-message[data-role="assistant"]', + ); await expect(assistantMessages).toHaveCount(1); - // Click Regenerate on the assistant message (aria-label is the durable hook). - await page.getByRole('button', { name: 'Regenerate response' }).first().click(); + // Keep a handle to the exact finalized response that existed before the + // click. A locator alone could silently resolve to that response and let the + // terminal-state assertion pass before regeneration has started. + const originalAssistant = await assistantMessages.elementHandle(); + if (!originalAssistant) { + throw new Error('Expected a finalized assistant response before regenerate'); + } + + await page.getByRole('button', { name: 'Regenerate response' }).click(); - // Wait for the regenerated assistant turn to finalize (data-streaming flips - // back to true then false). We can't reuse sendPromptAndWait here because - // there's no fresh prompt to send — instead poll until exactly one - // finalized assistant is present and the count holds at 1/1. + // Regenerate truncates the old assistant before streaming its replacement. + // Requiring the original DOM node to disconnect ties the wait to this click, + // even when aimock completes too quickly to sample data-streaming="true". await expect .poll( - async () => - page - .locator('chat-message[data-role="assistant"][data-streaming="false"]') - .count(), + () => originalAssistant.evaluate((element) => element.isConnected), { timeout: 45_000 }, ) + .toBe(false); + + const regeneratedAssistant = page.locator( + 'chat-message[data-role="assistant"][data-streaming="false"]', + ); + await expect(regeneratedAssistant).toHaveCount(1, { timeout: 45_000 }); + await expect + .poll(async () => (await regeneratedAssistant.innerText()).trim().length, { + timeout: 30_000, + }) .toBeGreaterThan(0); - // Single-turn invariant: after regenerate, conversation stays at 1u/1a - // (the assistant message is replaced in place, not appended). + await expect(page.locator('chat-message[data-role="user"]')).toHaveCount(1); + await expect(assistantMessages).toHaveCount(1); +} + +test('regenerate: re-running keeps 1 user / 1 assistant in the conversation', async ({ + page, +}) => { + // Reuse the smoke 'say hi briefly' fixture — aimock returns the same + // response on regenerate, so DOM replacement proves a new run occurred. + await sendPromptAndWait(page, 'say hi briefly'); + + const userMessages = page.locator('chat-message[data-role="user"]'); + const assistantMessages = page.locator('chat-message[data-role="assistant"]'); await expect(userMessages).toHaveCount(1); await expect(assistantMessages).toHaveCount(1); + await regenerateAndWait(page); + const threadId = await activeThreadIdFromUrl(page); expect(threadId).toBeTruthy(); const state = await fetch(`http://localhost:2024/threads/${threadId}/state`).then((r) => @@ -51,17 +71,6 @@ test('regenerate: repeated regenerate keeps the same 1 user / 1 assistant shape' await sendPromptAndWait(page, 'say hi briefly'); for (let i = 0; i < 3; i++) { - await page.getByRole('button', { name: 'Regenerate response' }).first().click(); - await expect - .poll( - async () => - page - .locator('chat-message[data-role="assistant"][data-streaming="false"]') - .count(), - { timeout: 45_000 }, - ) - .toBeGreaterThan(0); - await expect(page.locator('chat-message[data-role="user"]')).toHaveCount(1); - await expect(page.locator('chat-message[data-role="assistant"]')).toHaveCount(1); + await regenerateAndWait(page); } }); diff --git a/libs/ag-ui/src/lib/reducer.spec.ts b/libs/ag-ui/src/lib/reducer.spec.ts index 1645209ba..46988114f 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' | 'interrupted' | 'paused'; +} + +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,96 @@ 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.each([ + ['TEXT_MESSAGE_START', { type: 'TEXT_MESSAGE_START', messageId: 'm2' }], + ['REASONING_MESSAGE_START', { type: 'REASONING_MESSAGE_START', messageId: 'm2' }], + ['TOOL_CALL_START', { + type: 'TOOL_CALL_START', toolCallId: 't2', toolCallName: 'search', parentMessageId: 'm2', + }], + ])('%s starts a new assistant step and completes the previous one in the same generation', (_type, event) => { + const store = makeStore(); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'first step' } as any, store); + + reduceEvent(event as any, store); + + expect(store.messages().find(message => message.id === 'm1')?.delivery) + .toEqual(completeDelivery('run-generation-1', 'success')); + expect(store.messages().find(message => message.id === 'm2')?.delivery) + .toEqual(streamingDelivery('run-generation-1')); + expect(store.deliveryRun?.currentAssistantMessageId).toBe('m2'); + }); + + it('ignores stale content for a completed prior assistant step', () => { + const store = makeStore(); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'first step' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm2' } as any, store); + + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: ' stale' } as any, store); + + expect(store.messages().find(message => message.id === 'm1')).toMatchObject({ + content: 'first step', + delivery: completeDelivery('run-generation-1', 'success'), + }); + expect(store.messages().find(message => message.id === 'm2')?.delivery) + .toEqual(streamingDelivery('run-generation-1')); + expect(store.deliveryRun?.currentAssistantMessageId).toBe('m2'); + }); + + it('does not mutate completed prior assistant content from a stale snapshot', () => { + const store = makeStore(); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'first step' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm2' } as any, store); + + reduceEvent({ + type: 'MESSAGES_SNAPSHOT', + messages: [ + { id: 'm1', role: 'assistant', content: 'stale rewrite' }, + { id: 'm2', role: 'assistant', content: 'current snapshot' }, + ], + } as any, store); + + expect(store.messages().find(message => message.id === 'm1')).toMatchObject({ + content: 'first step', + delivery: completeDelivery('run-generation-1', 'success'), + }); + expect(store.messages().find(message => message.id === 'm2')).toMatchObject({ + content: 'current snapshot', + delivery: streamingDelivery('run-generation-1'), + }); + }); + + it('preserves an omitted current assistant so later deltas still apply', () => { + const store = makeStore(); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'first step' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm2' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm2', delta: 'second step' } as any, store); + + reduceEvent({ + type: 'MESSAGES_SNAPSHOT', + messages: [{ id: 'm1', role: 'assistant', content: 'first step' }], + } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm2', delta: ' continued' } as any, store); + + expect(store.messages().find(message => message.id === 'm2')).toMatchObject({ + content: 'second step continued', + delivery: streamingDelivery('run-generation-1'), + }); + expect(store.deliveryRun?.currentAssistantMessageId).toBe('m2'); + }); + + 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 +229,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 +462,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', () => { @@ -302,6 +553,27 @@ describe('reduceEvent — interrupt', () => { expect(ix!.resumable).toBe(true); expect(typeof ix!.id).toBe('string'); }); + it('terminalizes the active generation as paused and ignores a later RUN_FINISHED', () => { + const store = makeStore(); + store.status.set('running'); + store.isLoading.set(true); + reduceEvent({ type: 'RUN_STARTED', runId: 'run-1' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1', runId: 'run-1' } as any, store); + + reduceEvent({ + type: 'CUSTOM', name: 'on_interrupt', value: { kind: 'approval' }, runId: 'run-1', + } as any, store); + + expect(store.messages()[0].delivery).toEqual(completeDelivery('run-generation-1', 'paused')); + expect(store.deliveryRun?.outcome).toBe('paused'); + expect(store.status()).toBe('idle'); + expect(store.isLoading()).toBe(false); + + reduceEvent({ type: 'RUN_FINISHED', runId: 'run-1' } as any, store); + + expect(store.messages()[0].delivery).toEqual(completeDelivery('run-generation-1', 'paused')); + expect(store.deliveryRun?.outcome).toBe('paused'); + }); it('clears interrupt on RUN_STARTED', () => { const store = makeStore(); store.interrupt.set({ id: 'x', value: {}, resumable: true }); @@ -327,6 +599,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 +608,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..a6c3f2bd8 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,13 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { case 'MESSAGES_SNAPSHOT': { const e = event as unknown as { messages: AgUiSnapshotMessage[] }; const raw = e.messages ?? []; + const run = store.deliveryRun?.outcome === undefined ? store.deliveryRun : null; + const canonicalAssistantId = resolveCanonicalAssistantId(raw, run); + const activeCanonicalAssistantId = canonicalAssistantId + && ownAssistantMessage(store, canonicalAssistantId) + ? canonicalAssistantId + : undefined; + const previousById = new Map(store.messages().map(message => [message.id, message])); // 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,22 +338,52 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { // so the data is visible to . const snapshotToolCalls: ToolCall[] = []; const messages: Message[] = raw.map((m) => { + const previous = previousById.get(m.id); + const completedOwnedMessage = run + && run.ownedMessageIds.has(m.id) + && previous?.delivery.generation === run.generation + && previous.delivery.phase === 'complete' + ? previous + : undefined; + let delivery = previous?.delivery ?? staticDelivery(m.id); + let snapshotMessage: Omit; if (m.role !== 'assistant' || !m.toolCalls || m.toolCalls.length === 0) { - return m as unknown as Message; + snapshotMessage = m as unknown as Omit; + } else { + const ids: string[] = []; + for (const tc of m.toolCalls) { + ids.push(tc.id); + snapshotToolCalls.push({ + id: tc.id, + name: tc.function.name, + args: safeParseArgs(tc.function.arguments), + status: 'complete', + }); + } + const { toolCalls: _dropped, ...rest } = m; + snapshotMessage = { ...rest, toolCallIds: ids } as unknown as Omit; } - const ids: string[] = []; - for (const tc of m.toolCalls) { - ids.push(tc.id); - snapshotToolCalls.push({ - id: tc.id, - name: tc.function.name, - args: safeParseArgs(tc.function.arguments), - status: 'complete', - }); + if (completedOwnedMessage) return completedOwnedMessage; + if (run && (run.ownedMessageIds.has(m.id) || m.id === canonicalAssistantId)) { + delivery = delivery.generation === run.generation && delivery.phase === 'complete' + ? delivery + : streamingDelivery(run.generation); + run.ownedMessageIds.add(m.id); + if (m.id === activeCanonicalAssistantId) run.currentAssistantMessageId = m.id; } - const { toolCalls: _dropped, ...rest } = m; - return { ...rest, toolCallIds: ids } as unknown as Message; + return { ...snapshotMessage, delivery } as Message; }); + const currentAssistant = run?.currentAssistantMessageId + ? previousById.get(run.currentAssistantMessageId) + : undefined; + if ( + run + && currentAssistant?.delivery.generation === run.generation + && currentAssistant.delivery.phase === 'streaming' + && !messages.some(message => message.id === currentAssistant.id) + ) { + messages.push(currentAssistant); + } // Re-apply per-message citations from the already-received STATE. A // MESSAGES_SNAPSHOT replaces the streamed messages wholesale — and the // final snapshot message id (str(AIMessage.id), e.g. "resp-…") differs @@ -320,7 +409,13 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { // (e.g. ChatApprovalCardComponent) receive a plain object, not a string. const parsedValue = typeof e.value === 'string' ? safeParseJson(e.value) : e.value; if (e.name === 'on_interrupt') { + const run = currentRunForEvent(event, store); + if (store.deliveryRun && !run) return; store.interrupt.set({ id: randomId(), value: parsedValue, resumable: true }); + if (run && finalizeDeliveryRun(store, run, 'paused')) { + store.status.set('idle'); + store.isLoading.set(false); + } return; } // Surface every other custom event on the customEvents signal so the @@ -341,12 +436,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 +480,69 @@ 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; + const currentId = run.currentAssistantMessageId; + if (currentId && currentId !== id) { + if (run.ownedMessageIds.has(id)) return undefined; + store.messages.update(messages => messages.map(message => + message.id === currentId + && message.delivery.generation === run.generation + && message.delivery.phase === 'streaming' + ? { ...message, delivery: completeDelivery(run.generation, 'success') } + : message, + )); + } + 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/package.json b/libs/chat/package.json index a71d944d1..3025d3ddc 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": "0.5.8" }, "peerDependencies": { "zod": "^3.25.0", 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..45d72148a --- /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. +void 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/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) {