From 5ff461e95c5dc9858afe0ce998f17b8b6da1e45f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 7 May 2026 13:36:17 -0700 Subject: [PATCH 1/3] fix(langgraph): reposition thread via as_node='__start__' before regenerate re-run (0.0.29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the original run, the LangGraph thread is at `__end__` with `next: []`. The 0.0.28 regenerate flow called `updateState` to apply the RemoveMessage rollback and then `submit(null)` — but submit-with-null is a no-op against a terminal thread, so the new generation never fired. The visible regression in the live cockpit smoke (and `~/tmp/ngaf`) was 1u/1a → 1u/0a: the assistant disappeared and never came back. Fix: extend `transport.updateState` and the stream-manager bridge with an optional `asNode` parameter, and have `regenerate()` pass `asNode: '__start__'`. LangGraph treats the update as if the start node had just produced the values, so `next` flips to `['generate']` and the subsequent `submit(null)` resumes at the entry node and produces a fresh assistant message against the rolled-back state. Also broadens `@ngaf/chat`'s `@cacheplane/partial-json` dep range to `>=0.1.1 <0.3.0` so consumers can pull the recently-published 0.2.x (StreamStatus rename — non-breaking for the surface @ngaf/chat actually uses). Bumped all 16 @ngaf packages to 0.0.29. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/a2ui/package.json | 2 +- libs/ag-ui/package.json | 2 +- libs/chat/package.json | 4 +- libs/cockpit-docs/package.json | 2 +- libs/cockpit-registry/package.json | 2 +- libs/cockpit-shell/package.json | 2 +- libs/cockpit-testing/package.json | 2 +- libs/cockpit-ui/package.json | 2 +- libs/db/package.json | 2 +- libs/design-tokens/package.json | 2 +- libs/example-layouts/package.json | 2 +- libs/langgraph/package.json | 2 +- libs/langgraph/src/lib/agent.fn.spec.ts | 54 +++++++++++++++++++ libs/langgraph/src/lib/agent.fn.ts | 29 ++++++---- libs/langgraph/src/lib/agent.types.ts | 7 +++ .../lib/internals/stream-manager.bridge.ts | 14 +++-- .../lib/transport/fetch-stream.transport.ts | 13 ++++- libs/licensing/package.json | 2 +- libs/partial-json/package.json | 2 +- libs/render/package.json | 2 +- libs/ui-react/package.json | 2 +- 21 files changed, 120 insertions(+), 31 deletions(-) diff --git a/libs/a2ui/package.json b/libs/a2ui/package.json index 31e230662..bbbf1c403 100644 --- a/libs/a2ui/package.json +++ b/libs/a2ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/a2ui", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/ag-ui/package.json b/libs/ag-ui/package.json index 64b5f7949..a10552f95 100644 --- a/libs/ag-ui/package.json +++ b/libs/ag-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/ag-ui", - "version": "0.0.28", + "version": "0.0.29", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/chat/package.json b/libs/chat/package.json index bb9b96d5a..aecf0b931 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.28", + "version": "0.0.29", "exports": { ".": { "types": "./index.d.ts", @@ -13,7 +13,7 @@ "./chat.css": "./chat.css" }, "dependencies": { - "@cacheplane/partial-json": "^0.1.1", + "@cacheplane/partial-json": ">=0.1.1 <0.3.0", "@cacheplane/partial-markdown": "^0.3.0" }, "peerDependencies": { diff --git a/libs/cockpit-docs/package.json b/libs/cockpit-docs/package.json index cd39d37a5..877ad5a4c 100644 --- a/libs/cockpit-docs/package.json +++ b/libs/cockpit-docs/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-docs", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-registry/package.json b/libs/cockpit-registry/package.json index fad777c8d..09822ed8a 100644 --- a/libs/cockpit-registry/package.json +++ b/libs/cockpit-registry/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-registry", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-shell/package.json b/libs/cockpit-shell/package.json index 97d7642d9..3d4ef53e8 100644 --- a/libs/cockpit-shell/package.json +++ b/libs/cockpit-shell/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-shell", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-testing/package.json b/libs/cockpit-testing/package.json index 0e63ff33f..9a6137bbe 100644 --- a/libs/cockpit-testing/package.json +++ b/libs/cockpit-testing/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-testing", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-ui/package.json b/libs/cockpit-ui/package.json index 0126022f7..4adb275c4 100644 --- a/libs/cockpit-ui/package.json +++ b/libs/cockpit-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-ui", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/db/package.json b/libs/db/package.json index bedfe3045..625e5298e 100644 --- a/libs/db/package.json +++ b/libs/db/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/db", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/design-tokens/package.json b/libs/design-tokens/package.json index 4d5a6a17d..fdfd1fb22 100644 --- a/libs/design-tokens/package.json +++ b/libs/design-tokens/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/design-tokens", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/example-layouts/package.json b/libs/example-layouts/package.json index 6e6c4bd9c..db1bb6675 100644 --- a/libs/example-layouts/package.json +++ b/libs/example-layouts/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/example-layouts", - "version": "0.0.28", + "version": "0.0.29", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0" diff --git a/libs/langgraph/package.json b/libs/langgraph/package.json index a7a6ef5d4..1c3c7d7bb 100644 --- a/libs/langgraph/package.json +++ b/libs/langgraph/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/langgraph", - "version": "0.0.28", + "version": "0.0.29", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/langgraph/src/lib/agent.fn.spec.ts b/libs/langgraph/src/lib/agent.fn.spec.ts index da35764b3..f7d20e801 100644 --- a/libs/langgraph/src/lib/agent.fn.spec.ts +++ b/libs/langgraph/src/lib/agent.fn.spec.ts @@ -747,6 +747,60 @@ describe('agent', () => { expect(seen[1]?.payload).toBeNull(); }); + it("forwards asNode: '__start__' to transport.updateState so the next submit(null) can resume", async () => { + // After the original run the LangGraph thread sits at __end__ with + // `next: []`. submit(null) is a no-op in that position. The regenerate + // flow has to reposition the thread via `as_node='__start__'` before + // re-running, so the transport must receive that option. + const updateCalls: Array<{ + threadId: string; + values: Record; + options?: { asNode?: string }; + }> = []; + const transport = new MockAgentTransport(); + // Augment the mock with an updateState capture. + (transport as unknown as { + updateState: ( + threadId: string, + values: Record, + signal: AbortSignal, + options?: { asNode?: string }, + ) => Promise; + }).updateState = async (threadId, values, _signal, options) => { + updateCalls.push({ threadId, values, options }); + }; + + const ref = withInjectionContext(() => + agent({ apiUrl: '', assistantId: 'a', threadId: 't-1', transport, throttle: false }) + ); + + ref.submit({ message: 'hello' }); + transport.emit([{ + type: 'messages', + messages: [ + { id: 'u-1', type: 'human', content: 'hello' }, + { id: 'a-1', type: 'ai', content: 'hi there' }, + ], + }]); + transport.close(); + await new Promise(r => setTimeout(r, 30)); + + const regeneratePromise = ref.regenerate(1); + transport.close(); + await regeneratePromise; + + // updateState invoked exactly once — with the RemoveMessage payload + // AND asNode set to '__start__'. Without asNode, regenerate would + // submit(null) against a terminal thread and produce no new assistant. + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0]?.threadId).toBe('t-1'); + expect(updateCalls[0]?.options?.asNode).toBe('__start__'); + const removeMessages = (updateCalls[0]?.values?.['messages'] as Array>) ?? []; + expect(removeMessages).toHaveLength(1); + expect(removeMessages[0]?.['type']).toBe('remove'); + expect(removeMessages[0]?.['id']).toBe('a-1'); + }); + it('throws when target index is not an assistant message', async () => { const transport = new MockAgentTransport(); const ref = withInjectionContext(() => diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index b173cb447..cd466f76f 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -310,15 +310,26 @@ export function agent< }) .filter((rm): rm is RemoveMessageInstruction => rm !== null); - if (removeList.length > 0) { - // updateState is a no-op when the transport doesn't support it - // (e.g. mock transport in unit tests) — safe to call unconditionally. - await manager.updateState({ messages: removeList }); - } - - // Re-run the graph with no new input so LangGraph executes from the - // current (trimmed) thread state. The trailing user message becomes the - // active prompt without being re-appended. + // RemoveMessage rollback + reposition the graph to the entry node + // via `as_node: '__start__'`. After the original run, the thread is + // at `__end__` with `next: []` — submitting `null` would be a no-op + // because there is nothing pending to execute. Setting `asNode` to + // the start node tells LangGraph to treat the update as if `__start__` + // had just produced the values, so the next pull resumes at the entry + // node and runs `generate` against the rolled-back state. + // + // We always pass `asNode` even when removeList is empty (rare, but + // possible if the assistant message had no id) so the regenerate + // submit below still runs the graph. + await manager.updateState( + { messages: removeList }, + { asNode: '__start__' }, + ); + + // Re-run the graph with no new input. With the thread now repositioned + // at `__start__`, this resumes at the entry node and produces a fresh + // assistant message — the trailing user message becomes the active + // prompt without being re-appended. await manager.submit(null, undefined); }, diff --git a/libs/langgraph/src/lib/agent.types.ts b/libs/langgraph/src/lib/agent.types.ts index 6d1c02412..da3910b3f 100644 --- a/libs/langgraph/src/lib/agent.types.ts +++ b/libs/langgraph/src/lib/agent.types.ts @@ -212,11 +212,18 @@ export interface AgentTransport { * Optional: update server-side thread state (e.g. to emit RemoveMessage * entries for regenerate rollback). Forwards to the LangGraph * `threads.updateState` API. + * + * `options.asNode` corresponds to LangGraph's `as_node` parameter — the + * server treats the update as if that node had just produced the values, + * which determines what the next pull resumes. `regenerate()` passes + * `asNode: '__start__'` so the next `submit(null)` resumes at the entry + * node and re-runs `generate` against the rolled-back state. */ updateState?( threadId: string, values: Record, signal: AbortSignal, + options?: { asNode?: string }, ): Promise; } diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index f79d37417..5ec8a9674 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -55,7 +55,7 @@ export interface StreamManagerBridge { resubmitLast: () => Promise; getReasoningDurationMs:(id: string) => number | undefined; /** Update server-side thread state (e.g. RemoveMessage for regenerate rollback). */ - updateState: (values: Record) => Promise; + updateState: (values: Record, opts?: { asNode?: string }) => Promise; /** The current thread ID tracked by the bridge (null if not yet known). */ readonly currentThreadId: string | null; } @@ -665,13 +665,21 @@ export function createStreamManagerBridge): Promise => { + updateState: async ( + values: Record, + opts?: { asNode?: string }, + ): Promise => { // No-op when there is no thread yet or the transport doesn't support // updateState (e.g. MockAgentTransport in unit tests without a threadId). if (!currentThreadId || !transport.updateState) { return; } - await transport.updateState(currentThreadId, values, new AbortController().signal); + await transport.updateState( + currentThreadId, + values, + new AbortController().signal, + opts, + ); }, get currentThreadId(): string | null { diff --git a/libs/langgraph/src/lib/transport/fetch-stream.transport.ts b/libs/langgraph/src/lib/transport/fetch-stream.transport.ts index 369c697cf..506701849 100644 --- a/libs/langgraph/src/lib/transport/fetch-stream.transport.ts +++ b/libs/langgraph/src/lib/transport/fetch-stream.transport.ts @@ -115,8 +115,17 @@ export class FetchStreamTransport implements AgentTransport { } /** Update server-side thread state, e.g. to remove messages for regenerate rollback. */ - async updateState(threadId: string, values: Record, _signal: AbortSignal): Promise { - await this.client.threads.updateState(threadId, { values }); + async updateState( + threadId: string, + values: Record, + _signal: AbortSignal, + options?: { asNode?: string }, + ): Promise { + const body: { values: Record; asNode?: string } = { values }; + if (options?.asNode !== undefined) { + body.asNode = options.asNode; + } + await this.client.threads.updateState(threadId, body); } } diff --git a/libs/licensing/package.json b/libs/licensing/package.json index 0ff1f7436..f8cc97b81 100644 --- a/libs/licensing/package.json +++ b/libs/licensing/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/licensing", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/partial-json/package.json b/libs/partial-json/package.json index c92d32fbb..79a4e3468 100644 --- a/libs/partial-json/package.json +++ b/libs/partial-json/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/partial-json", - "version": "0.0.28", + "version": "0.0.29", "deprecated": "Replaced by @cacheplane/partial-json. No further versions will be published from this package.", "license": "MIT", "repository": { diff --git a/libs/render/package.json b/libs/render/package.json index f0f6a1b9d..3a54da47a 100644 --- a/libs/render/package.json +++ b/libs/render/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/render", - "version": "0.0.28", + "version": "0.0.29", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", diff --git a/libs/ui-react/package.json b/libs/ui-react/package.json index 047438adb..4ad87381e 100644 --- a/libs/ui-react/package.json +++ b/libs/ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/ui-react", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", From 319f01a3cecf147d2d4bd6b730ca3587b9c105a3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 7 May 2026 14:33:02 -0700 Subject: [PATCH 2/3] chore(docs): regenerate api-docs for asNode option Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/content/docs/agent/api/api-docs.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/website/content/docs/agent/api/api-docs.json b/apps/website/content/docs/agent/api/api-docs.json index 7974a5992..ecbcf295f 100644 --- a/apps/website/content/docs/agent/api/api-docs.json +++ b/apps/website/content/docs/agent/api/api-docs.json @@ -173,7 +173,7 @@ }, { "name": "updateState", - "signature": "updateState(threadId: string, values: Record, _signal: AbortSignal)", + "signature": "updateState(threadId: string, values: Record, _signal: AbortSignal, options: object)", "description": "Update server-side thread state, e.g. to remove messages for regenerate rollback.", "params": [ { @@ -193,6 +193,12 @@ "type": "AbortSignal", "description": "", "optional": false + }, + { + "name": "options", + "type": "object", + "description": "", + "optional": true } ] } @@ -937,7 +943,7 @@ { "name": "regenerate", "type": "object", - "description": "Discards the assistant message at the given index AND all messages after\nit, then re-runs the agent against the trimmed conversation tail. The\npreceding user message (at index - 1) is preserved and re-submitted as\nthe agent's input. No new user message is added to the history.\n\nThrows if the message at `index` is not 'assistant' role, or if the\nagent is currently loading another response.", + "description": "Truncate the thread at the given assistant-message index and re-submit the\npreceding user message. Used by ``'s regenerate action and any\ncustom UI that wants the same semantics. Rejects if the agent is currently\nloading, if the index does not point at an assistant message, or if no\npreceding user message exists.", "optional": false }, { @@ -1301,7 +1307,7 @@ { "name": "regenerate", "type": "object", - "description": "Discards the assistant message at the given index AND all messages after\nit, then re-runs the agent against the trimmed conversation tail. The\npreceding user message (at index - 1) is preserved and re-submitted as\nthe agent's input. No new user message is added to the history.\n\nThrows if the message at `index` is not 'assistant' role, or if the\nagent is currently loading another response.", + "description": "Truncate the thread at the given assistant-message index and re-submit the\npreceding user message. Used by ``'s regenerate action and any\ncustom UI that wants the same semantics. Rejects if the agent is currently\nloading, if the index does not point at an assistant message, or if no\npreceding user message exists.", "optional": false }, { From fa4dee88a718e3b3586d20988dec86df5c53ff7a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 7 May 2026 16:43:35 -0700 Subject: [PATCH 3/3] chore(langgraph): align regenerate JSDoc with @ngaf/chat canonical text The api-docs gate failed because libs/langgraph/src/lib/agent.types.ts still carried the older "Truncate the thread..." description while the canonical version in libs/chat/src/lib/agent/agent.ts reads "Discards the assistant message...". Aligning both keeps the generator output deterministic. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/content/docs/agent/api/api-docs.json | 4 ++-- libs/langgraph/src/lib/agent.types.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/website/content/docs/agent/api/api-docs.json b/apps/website/content/docs/agent/api/api-docs.json index ecbcf295f..cd67f0c94 100644 --- a/apps/website/content/docs/agent/api/api-docs.json +++ b/apps/website/content/docs/agent/api/api-docs.json @@ -943,7 +943,7 @@ { "name": "regenerate", "type": "object", - "description": "Truncate the thread at the given assistant-message index and re-submit the\npreceding user message. Used by ``'s regenerate action and any\ncustom UI that wants the same semantics. Rejects if the agent is currently\nloading, if the index does not point at an assistant message, or if no\npreceding user message exists.", + "description": "Discards the assistant message at the given index AND all messages after\nit, then re-runs the agent against the trimmed conversation tail. The\npreceding user message (at index - 1) is preserved and re-submitted as\nthe agent's input. No new user message is added to the history.\n\nThrows if the message at `index` is not 'assistant' role, or if the\nagent is currently loading another response.", "optional": false }, { @@ -1307,7 +1307,7 @@ { "name": "regenerate", "type": "object", - "description": "Truncate the thread at the given assistant-message index and re-submit the\npreceding user message. Used by ``'s regenerate action and any\ncustom UI that wants the same semantics. Rejects if the agent is currently\nloading, if the index does not point at an assistant message, or if no\npreceding user message exists.", + "description": "Discards the assistant message at the given index AND all messages after\nit, then re-runs the agent against the trimmed conversation tail. The\npreceding user message (at index - 1) is preserved and re-submitted as\nthe agent's input. No new user message is added to the history.\n\nThrows if the message at `index` is not 'assistant' role, or if the\nagent is currently loading another response.", "optional": false }, { diff --git a/libs/langgraph/src/lib/agent.types.ts b/libs/langgraph/src/lib/agent.types.ts index da3910b3f..c45a605f8 100644 --- a/libs/langgraph/src/lib/agent.types.ts +++ b/libs/langgraph/src/lib/agent.types.ts @@ -319,11 +319,13 @@ export interface LangGraphAgent void; /** - * Truncate the thread at the given assistant-message index and re-submit the - * preceding user message. Used by ``'s regenerate action and any - * custom UI that wants the same semantics. Rejects if the agent is currently - * loading, if the index does not point at an assistant message, or if no - * preceding user message exists. + * Discards the assistant message at the given index AND all messages after + * it, then re-runs the agent against the trimmed conversation tail. The + * preceding user message (at index - 1) is preserved and re-submitted as + * the agent's input. No new user message is added to the history. + * + * Throws if the message at `index` is not 'assistant' role, or if the + * agent is currently loading another response. */ regenerate: (assistantMessageIndex: number) => Promise;