Skip to content

Commit bc99636

Browse files
committed
fix(chat): preserve composer drafts while interrupt input is blocked
1 parent 2e8d3e6 commit bc99636

10 files changed

Lines changed: 97 additions & 18 deletions

File tree

apps/website/content/docs/ag-ui/api/api-docs.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,12 @@
461461
"description": "Full interrupt batch and its request ownership phase.",
462462
"optional": false
463463
},
464+
{
465+
"name": "isInputBlocked",
466+
"type": "Signal<boolean>",
467+
"description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.",
468+
"optional": true
469+
},
464470
{
465471
"name": "isLoading",
466472
"type": "Signal<boolean>",

apps/website/content/docs/ag-ui/guides/interrupts.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ Atomic storage claims coordinate clients sharing that store. Preventing duplicat
168168

169169
Await `agent.ready` before rendering restored approval controls; actions also wait for hydration. An interrupted in-flight claim restores as uncertain, and an acknowledged attempt requires recovery. Configure `persistence.reconcile(record)` to obtain an authoritative backend outcome and call `agent.reconcileInterrupt()` to apply it. The callback returns `unknown`, or `pending`, `acknowledged`, or `completed` with authoritative `committed` and `session` snapshots. An unknown outcome keeps recovery blocked. The adapter does not provide a backend reconciliation endpoint, and stored client state cannot recreate a checkpoint lost by a restarted `MemorySaver` process.
170170

171+
The optional `isInputBlocked` signal disables ordinary chat input during hydration, reconciliation, and unresolved interrupt phases. The built-in composer preserves drafts while blocked; approval controls still submit through the resume path.
172+
171173
Providers dispose the adapter with their injector. If you create it with `toAgent()` directly, call `agent.dispose()` when finished. Disposal unsubscribes and stops local work; it does not delete or cancel backend checkpoints.
172174

173175
### Migration note

apps/website/content/docs/chat/api/api-docs.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5996,6 +5996,12 @@
59965996
"description": "Optional display projection of the pending interrupt. A runtime may expose\na separate full batch and lifecycle surface. A cleared display projection\ndoes not by itself prove that resumed backend work completed.",
59975997
"optional": true
59985998
},
5999+
{
6000+
"name": "isInputBlocked",
6001+
"type": "Signal<boolean>",
6002+
"description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.",
6003+
"optional": true
6004+
},
59996005
{
60006006
"name": "isLoading",
60016007
"type": "Signal<boolean>",
@@ -6316,6 +6322,12 @@
63166322
"description": "Optional display projection of the pending interrupt. A runtime may expose\na separate full batch and lifecycle surface. A cleared display projection\ndoes not by itself prove that resumed backend work completed.",
63176323
"optional": true
63186324
},
6325+
{
6326+
"name": "isInputBlocked",
6327+
"type": "Signal<boolean>",
6328+
"description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.",
6329+
"optional": true
6330+
},
63196331
{
63206332
"name": "isLoading",
63216333
"type": "Signal<boolean>",
@@ -7496,6 +7508,12 @@
74967508
"description": "Optional display projection of the pending interrupt. A runtime may expose\na separate full batch and lifecycle surface. A cleared display projection\ndoes not by itself prove that resumed backend work completed.",
74977509
"optional": true
74987510
},
7511+
{
7512+
"name": "isInputBlocked",
7513+
"type": "Signal<boolean>",
7514+
"description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.",
7515+
"optional": true
7516+
},
74997517
{
75007518
"name": "isLoading",
75017519
"type": "WritableSignal<boolean>",
@@ -9652,7 +9670,7 @@
96529670
{
96539671
"name": "submitMessage",
96549672
"kind": "function",
9655-
"description": "Submits a trimmed message to the agent.\nReturns the trimmed string on success, or `null` if the input was empty.",
9673+
"description": "Submits a trimmed message to the agent.\nReturns the trimmed string on dispatch, or `null` for empty or blocked input.",
96569674
"signature": "submitMessage(agent: Agent<>, text: string): string | null",
96579675
"params": [
96589676
{

apps/website/content/docs/langgraph/api/api-docs.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,6 +1534,12 @@
15341534
"description": "Current human-in-the-loop pause, or `undefined` when the run is not paused.\n\nNarrowed from the neutral `Agent` contract, where `interrupt` is optional\nbecause a runtime without human-in-the-loop support omits it. The LangGraph\nadapter always provides it, so `injectAgent().interrupt()` type-checks\ndirectly under `strictNullChecks` — no `?.()` needed.",
15351535
"optional": false
15361536
},
1537+
{
1538+
"name": "isInputBlocked",
1539+
"type": "Signal<boolean>",
1540+
"description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.",
1541+
"optional": true
1542+
},
15371543
{
15381544
"name": "isLoading",
15391545
"type": "Signal<boolean>",
@@ -1968,6 +1974,12 @@
19681974
"description": "Current human-in-the-loop pause, or `undefined` when the run is not paused.\n\nNarrowed from the neutral `Agent` contract, where `interrupt` is optional\nbecause a runtime without human-in-the-loop support omits it. The LangGraph\nadapter always provides it, so `injectAgent().interrupt()` type-checks\ndirectly under `strictNullChecks` — no `?.()` needed.",
19691975
"optional": false
19701976
},
1977+
{
1978+
"name": "isInputBlocked",
1979+
"type": "Signal<boolean>",
1980+
"description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.",
1981+
"optional": true
1982+
},
19711983
{
19721984
"name": "isLoading",
19731985
"type": "WritableSignal<boolean>",

libs/ag-ui/src/lib/to-agent.interrupt-restoration.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ function source() {
3232
return { source: new HttpAgent({ url: 'http://test.invalid', threadId: 't1', fetch }), fetch };
3333
}
3434

35+
it('blocks the composer until asynchronous hydration completes', async () => {
36+
const { config } = memory();
37+
let release!: () => void;
38+
config.store.load = async () => {
39+
await new Promise<void>(resolve => { release = resolve; });
40+
return null;
41+
};
42+
const agent = toAgent(source().source, { persistence: config, telemetry: false });
43+
expect(agent.isInputBlocked?.()).toBe(true);
44+
await vi.waitFor(() => expect(release).toBeTypeOf('function'));
45+
release(); await agent.ready;
46+
expect(agent.isInputBlocked?.()).toBe(false);
47+
agent.dispose();
48+
});
49+
3550
it('restores committed messages, state and a complete batch into a new adapter', async () => {
3651
const { config } = memory();
3752
const original = source();

libs/ag-ui/src/lib/to-agent.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,8 @@ function createAgentAdapter(
187187
let disposed = false;
188188
let resumeInput: { state: Record<string, unknown>; messages: typeof source.messages; localMessages?: Message[] } | undefined;
189189
const persistence = options.persistence ? new InterruptPersistence(options.persistence, source.threadId) : undefined;
190-
let hydrated = !persistence;
191-
let reconciling = false;
190+
const hydrated = signal(!persistence);
191+
const reconciling = signal(false);
192192
let persistenceFault: unknown;
193193
let persistenceWrites: Promise<void> = Promise.resolve();
194194
function storageError(error: unknown): void {
@@ -223,7 +223,7 @@ function createAgentAdapter(
223223
publishInterrupt();
224224
}
225225
const ready = persistence
226-
? persistence.load().then(record => { if (record) hydrate(record); hydrated = true; }).catch(error => { storageError(error); throw error; })
226+
? persistence.load().then(record => { if (record) hydrate(record); hydrated.set(true); }).catch(error => { storageError(error); throw error; })
227227
: Promise.resolve();
228228
void ready.catch(() => undefined);
229229
function publishInterrupt(): void {
@@ -242,8 +242,8 @@ function createAgentAdapter(
242242
}
243243
function assertAvailable(): void {
244244
if (disposed) throw new Error('Agent has been disposed');
245-
if (reconciling) throw new Error('Interrupt reconciliation is in progress');
246-
if (!hydrated) throw new Error('Wait for agent.ready before starting a request');
245+
if (reconciling()) throw new Error('Interrupt reconciliation is in progress');
246+
if (!hydrated()) throw new Error('Wait for agent.ready before starting a request');
247247
if (persistenceFault) throw new Error('Interrupt storage recovery requires reconciliation');
248248
}
249249
function assertNoInterrupt(): void {
@@ -642,22 +642,23 @@ function createAgentAdapter(
642642
ready,
643643
reconcileInterrupt: async () => {
644644
if (disposed) throw new Error('Agent has been disposed');
645-
if (reconciling) throw new Error('Interrupt reconciliation is in progress');
645+
if (reconciling()) throw new Error('Interrupt reconciliation is in progress');
646646
if (activeRun && activeRun.outcome === undefined) throw new Error('Stop the active request before reconciliation');
647647
if (!persistence) throw new Error('Interrupt recovery requires a persistence reconciler');
648-
reconciling = true;
648+
reconciling.set(true);
649649
try {
650650
await persistenceWrites.catch(() => undefined);
651651
const record = await persistence.reconcile();
652652
if (disposed) return;
653653
if (record) hydrate(record);
654654
persistenceFault = undefined; persistenceWrites = Promise.resolve();
655-
hydrated = true; store.error.set(undefined); store.status.set('idle');
655+
hydrated.set(true); store.error.set(undefined); store.status.set('idle');
656656
} finally {
657-
reconciling = false;
657+
reconciling.set(false);
658658
}
659659
},
660660
interruptSession: interruptSession.asReadonly(),
661+
isInputBlocked: computed(() => !hydrated() || reconciling() || interruptSession().phase !== 'none'),
661662
dispose: () => {
662663
if (disposed) return;
663664
disposed = true;
@@ -692,7 +693,7 @@ function createAgentAdapter(
692693
clientTools: clientToolsCap,
693694

694695
submit: async (input: AgentSubmitInput, opts?: AgUiSubmitOptions) => {
695-
if (!hydrated) await ready;
696+
if (!hydrated()) await ready;
696697
assertAvailable();
697698
if (input.resume !== undefined) {
698699
if (opts?.interruptGeneration !== undefined && opts.interruptGeneration !== interrupts.snapshot.generation) {
@@ -736,7 +737,7 @@ function createAgentAdapter(
736737
},
737738

738739
retry: async () => {
739-
if (!hydrated) await ready;
740+
if (!hydrated()) await ready;
740741
assertAvailable();
741742
if (interrupts.snapshot.attempt) {
742743
const attempt = interrupts.retry();
@@ -778,7 +779,7 @@ function createAgentAdapter(
778779
},
779780

780781
regenerate: async (assistantMessageIndex: number): Promise<void> => {
781-
if (!hydrated) await ready;
782+
if (!hydrated()) await ready;
782783
assertNoInterrupt();
783784
if (store.isLoading()) {
784785
throw new Error('Cannot regenerate while agent is loading another response');

libs/chat/src/lib/agent/agent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export interface Agent<TState = unknown> {
2828
messages: Signal<Message[]>;
2929
status: Signal<AgentStatus>;
3030
isLoading: Signal<boolean>;
31+
/** Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled. */
32+
isInputBlocked?: Signal<boolean>;
3133
error: Signal<AgentError | undefined>;
3234
toolCalls: Signal<ToolCall[]>;
3335
state: Signal<TState>;

libs/chat/src/lib/compositions/chat/chat.component.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ export function isPinned(
282282
<chat-message-actions
283283
chatMessageControls
284284
[content]="content"
285-
[disabled]="agent().isLoading()"
285+
[disabled]="agent().isLoading() || !!agent().isInputBlocked?.()"
286286
(regenerate)="onRegenerate(i)"
287287
(rate)="onRate(message, $event)"
288288
(contentCopied)="onCopy(message, $event)"
@@ -836,7 +836,7 @@ export class ChatComponent {
836836
*/
837837
submitMessage(text: string): void {
838838
const trimmed = text.trim();
839-
if (!trimmed) return;
839+
if (!trimmed || this.agent().isInputBlocked?.()) return;
840840
void this.agent().submit({ message: trimmed });
841841
this.recordSubmit();
842842
}
@@ -1031,6 +1031,7 @@ export class ChatComponent {
10311031
}
10321032

10331033
onA2uiAction(message: A2uiActionMessage): void {
1034+
if (this.agent().isInputBlocked?.()) return;
10341035
void this.agent().submit({ message: JSON.stringify(message) });
10351036
}
10361037

libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ function setSignalInput<T>(sig: unknown, value: T): void {
2222
}
2323

2424
describe('submitMessage()', () => {
25+
it('does not dispatch ordinary input when the adapter blocks it', () => {
26+
const agent = mockAgent();
27+
const blocked = { ...agent, isInputBlocked: signal(true) };
28+
expect(submitMessage(blocked, 'keep my draft')).toBeNull();
29+
expect(agent.submitCalls).toHaveLength(0);
30+
});
2531
it('calls agent.submit with { message: trimmed text }', async () => {
2632
const agent = mockAgent();
2733

@@ -102,6 +108,22 @@ describe('ChatInputComponent', () => {
102108
fixture.detectChanges();
103109
});
104110

111+
it('disables Send and preserves the draft on Enter until the adapter releases input', () => {
112+
const agent = mockAgent();
113+
const blocked = signal(true);
114+
setSignalInput(fixture.componentInstance.agent, { ...agent, isInputBlocked: blocked });
115+
fixture.componentInstance.messageText.set('keep my draft');
116+
fixture.detectChanges();
117+
const button = fixture.nativeElement.querySelector('button[aria-label="Send message"]') as HTMLButtonElement;
118+
expect(button.disabled).toBe(true);
119+
fixture.componentInstance.onKeydown(new KeyboardEvent('keydown', { key: 'Enter' }));
120+
expect(agent.submitCalls).toHaveLength(0);
121+
expect(fixture.componentInstance.messageText()).toBe('keep my draft');
122+
blocked.set(false);
123+
fixture.detectChanges();
124+
expect(button.disabled).toBe(false);
125+
});
126+
105127
it('renders the pill with full border-radius', () => {
106128
const pill = (fixture.nativeElement as HTMLElement).querySelector('.chat-input__pill') as HTMLElement;
107129
expect(pill).not.toBeNull();

libs/chat/src/lib/primitives/chat-input/chat-input.component.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@ import { CHAT_INPUT_STYLES } from '../../styles/chat-input.styles';
1616

1717
/**
1818
* Submits a trimmed message to the agent.
19-
* Returns the trimmed string on success, or `null` if the input was empty.
19+
* Returns the trimmed string on dispatch, or `null` for empty or blocked input.
2020
*/
2121
export function submitMessage(
2222
agent: Agent,
2323
text: string,
2424
): string | null {
2525
const trimmed = text.trim();
26-
if (!trimmed) return null;
26+
if (!trimmed || agent.isInputBlocked?.()) return null;
2727
void agent.submit({ message: trimmed });
2828
return trimmed;
2929
}
@@ -106,7 +106,7 @@ export class ChatInputComponent {
106106

107107
/** Submit is allowed only when not loading and there's non-whitespace text. */
108108
readonly canSubmit = computed(() => {
109-
if (this.isLoading()) return false;
109+
if (this.isLoading() || this.agent().isInputBlocked?.()) return false;
110110
return this.messageText().trim().length > 0;
111111
});
112112

0 commit comments

Comments
 (0)