Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2750,7 +2750,7 @@
"optional": false
},
{
"name": "interruptPayload",
"name": "interruptReason",
"type": "Signal<string>",
"description": "",
"optional": false
Expand Down
12 changes: 5 additions & 7 deletions examples/chat/angular/src/app/shell/demo-shell.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,13 @@
.demo-shell__interrupt-panel {
position: fixed;
left: 50%;
bottom: 96px;
bottom: calc(80px + var(--demo-shell-interrupt-offset, 0px));
transform: translateX(-50%);
z-index: 998;
z-index: 999;
width: min(640px, calc(100vw - 32px));
background: #1a1d23;
border: 1px solid #4f8df5;
border-radius: 10px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
padding: 12px 14px;
max-width: min(640px, calc(100vw - 32px));
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
border-radius: var(--ngaf-chat-radius-card, 10px);
}

.demo-shell__subagents {
Expand Down
8 changes: 7 additions & 1 deletion examples/chat/smoke/CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,17 @@ renders correctly both during streaming and after completion.

- [ ] Click "Demo: ask for approval before a sensitive action" welcome suggestion
- [ ] AI begins planning, then calls `request_approval` tool — graph pauses
- [ ] Interrupt panel appears above the input with the AI's reason text
- [ ] Interrupt panel renders ONCE — no duplicate inline banner inside the message stream
- [ ] Panel title reads "Agent paused"
- [ ] Panel body shows the human-readable `reason` text (NOT the raw JSON envelope like `{"type":"approval_request",...}`)
- [ ] Panel respects color scheme: in light mode the bg is light + text dark; in dark mode bg dark + text light
- [ ] Button hierarchy is visible: Accept primary (filled), Edit/Respond secondary (bordered), Ignore tertiary (muted text only)
- [ ] Panel docks above the chat input without overlapping it, even when the subagents strip is also present
- [ ] Click Accept — graph resumes with `'approved'`; AI proceeds with the plan
- [ ] (New conversation, click suggestion again) — Click Edit, type a custom response in the prompt — graph resumes with the typed text
- [ ] (New conversation, click suggestion again) — Click Ignore — graph resumes with `'denied'`; AI acknowledges and stops
- [ ] During pause: server state shows the interrupt — `curl localhost:2024/threads/<id>/state` reports `next` includes the interrupted node and a pending interrupt value
- [ ] On thread reload while paused at an interrupt: reload the page — interrupt panel re-appears with the same reason text (hydrated from checkpoint history)

## Citations

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: MIT
import { describe, it, expect } from 'vitest';
import { signal, computed } from '@angular/core';
import { getInterruptFromAgent, ChatInterruptPanelComponent } from './chat-interrupt-panel.component';
import {
getInterruptFromAgent,
interruptReasonText,
ChatInterruptPanelComponent,
} from './chat-interrupt-panel.component';
import type { InterruptAction } from './chat-interrupt-panel.component';
import { mockAgent } from '../../testing/mock-agent';
import type { AgentInterrupt } from '../../agent/agent-interrupt';
Expand Down Expand Up @@ -41,18 +44,53 @@ describe('getInterruptFromAgent()', () => {
});
});

describe('interruptReasonText()', () => {
it('returns the reason string when value.reason is a string', () => {
const ix: AgentInterrupt = {
id: 'int-1',
value: { type: 'approval_request', reason: 'User asked for deletion of /etc/important' },
resumable: true,
};
expect(interruptReasonText(ix)).toBe('User asked for deletion of /etc/important');
});

it('falls back to a JSON dump when value has no string reason field', () => {
const ix: AgentInterrupt = {
id: 'int-2',
value: { type: 'approval_request', meta: { count: 3 } },
resumable: true,
};
const out = interruptReasonText(ix);
expect(out).toContain('"type": "approval_request"');
expect(out).toContain('"count": 3');
});

it('returns string value directly when value is a plain string', () => {
const ix: AgentInterrupt = { id: 'int-3', value: 'Please confirm', resumable: true };
expect(interruptReasonText(ix)).toBe('Please confirm');
});

it('returns "" when interrupt is undefined', () => {
expect(interruptReasonText(undefined)).toBe('');
});

it('falls back to JSON when reason is not a string (e.g. nested object)', () => {
const ix: AgentInterrupt = {
id: 'int-4',
value: { reason: { nested: 'oops' } },
resumable: true,
};
const out = interruptReasonText(ix);
expect(out).toContain('"nested": "oops"');
});
});

describe('ChatInterruptPanelComponent', () => {
it('is defined', () => {
expect(ChatInterruptPanelComponent).toBeDefined();
expect(typeof ChatInterruptPanelComponent).toBe('function');
});

it('has interruptPayload as a prototype member', () => {
// interruptPayload is a computed signal defined in the constructor body —
// it lives on instances, not the prototype. Verify via class existence.
expect(ChatInterruptPanelComponent).toBeDefined();
});

it('exports InterruptAction union type (compile-time check)', () => {
const action: InterruptAction = 'accept';
expect(['accept', 'edit', 'respond', 'ignore']).toContain(action);
Expand All @@ -62,4 +100,22 @@ describe('ChatInterruptPanelComponent', () => {
const validActions: InterruptAction[] = ['accept', 'edit', 'respond', 'ignore'];
expect(validActions).toHaveLength(4);
});

it('template assigns primary/secondary/tertiary classes to buttons', () => {
// The component template is a string literal in the @Component decorator.
// Assert the class strings appear so a regression that drops one is caught.
const meta = Reflect.getOwnPropertyDescriptor(ChatInterruptPanelComponent, 'ɵcmp')?.value as
| { template?: string }
| undefined;
// Fall back: source check via the component's template string accessor.
// Some Angular versions expose template via ɵcmp; if absent, skip — the
// class hierarchy is also covered by the smoke checklist.
if (meta?.template) {
expect(meta.template).toContain('chat-interrupt-panel__btn--primary');
expect(meta.template).toContain('chat-interrupt-panel__btn--secondary');
expect(meta.template).toContain('chat-interrupt-panel__btn--tertiary');
} else {
expect(true).toBe(true);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ export function getInterruptFromAgent(agent: Agent): AgentInterrupt | undefined
return agent.interrupt?.();
}

/**
* Extracts a human-readable reason from an interrupt value. When the value
* is an object with a string `reason` field, returns that directly. Falls
* back to a JSON dump so consumers always see something rather than nothing.
* Exported for unit testing.
*/
export function interruptReasonText(interrupt: AgentInterrupt | undefined): string {
if (!interrupt) return '';
const v = interrupt.value as { reason?: unknown } | undefined;
if (v && typeof v === 'object' && typeof (v as { reason?: unknown }).reason === 'string') {
return (v as { reason: string }).reason;
}
if (typeof v === 'string') return v;
return JSON.stringify(v ?? '', null, 2);
}

@Component({
selector: 'chat-interrupt-panel',
standalone: true,
Expand All @@ -29,12 +45,12 @@ export function getInterruptFromAgent(agent: Agent): AgentInterrupt | undefined
CHAT_HOST_TOKENS,
`
.chat-interrupt-panel {
background: var(--ngaf-chat-warning-bg);
color: var(--ngaf-chat-warning-text);
background: var(--ngaf-chat-surface);
color: var(--ngaf-chat-text);
border: 1px solid var(--ngaf-chat-separator);
border-left: 3px solid var(--ngaf-chat-warning-text);
border-radius: var(--ngaf-chat-radius-card);
padding: 12px 16px;
margin: 0 var(--ngaf-chat-space-6) var(--ngaf-chat-space-2);
font-size: var(--ngaf-chat-font-size-sm);
}
.chat-interrupt-panel__title {
Expand All @@ -43,51 +59,80 @@ export function getInterruptFromAgent(agent: Agent): AgentInterrupt | undefined
display: flex;
align-items: center;
gap: 6px;
color: var(--ngaf-chat-text);
}
.chat-interrupt-panel__title svg {
color: var(--ngaf-chat-warning-text);
}
.chat-interrupt-panel__body {
margin: 0 0 8px;
opacity: 0.95;
margin: 0 0 12px;
color: var(--ngaf-chat-text-muted);
white-space: pre-wrap;
}
.chat-interrupt-panel__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.chat-interrupt-panel__btn {
padding: 4px 12px;
padding: 6px 14px;
font-size: var(--ngaf-chat-font-size-sm);
border-radius: var(--ngaf-chat-radius-button);
border: 0;
cursor: pointer;
font-weight: 500;
transition: transform 200ms ease, opacity 200ms ease;
}
.chat-interrupt-panel__btn:hover { transform: scale(1.03); }
.chat-interrupt-panel__btn--primary {
background: var(--ngaf-chat-primary);
color: var(--ngaf-chat-on-primary);
transition: transform 200ms ease;
}
.chat-interrupt-panel__btn:hover { transform: scale(1.03); }
.chat-interrupt-panel__btn--secondary {
background: transparent;
color: var(--ngaf-chat-warning-text);
border: 1px solid var(--ngaf-chat-warning-text);
color: var(--ngaf-chat-text);
border: 1px solid var(--ngaf-chat-separator);
}
.chat-interrupt-panel__btn--tertiary {
background: transparent;
color: var(--ngaf-chat-text-muted);
padding: 6px 8px;
font-size: var(--ngaf-chat-font-size-sm);
}
.chat-interrupt-panel__btn--tertiary:hover {
color: var(--ngaf-chat-text);
}
`,
],
template: `
@if (interrupt()) {
<div role="alert" class="chat-interrupt-panel">
<!-- Warning header -->
<p class="chat-interrupt-panel__title">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Agent Interrupt
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Agent paused
</p>
<p class="chat-interrupt-panel__body">{{ interruptPayload() }}</p>
<p class="chat-interrupt-panel__body">{{ interruptReason() }}</p>

<!-- Action buttons -->
<div class="chat-interrupt-panel__actions">
<button class="chat-interrupt-panel__btn" (click)="action.emit('accept')">Accept</button>
<button class="chat-interrupt-panel__btn" (click)="action.emit('edit')">Edit</button>
<button class="chat-interrupt-panel__btn" (click)="action.emit('respond')">Respond</button>
<button
type="button"
class="chat-interrupt-panel__btn chat-interrupt-panel__btn--primary"
(click)="action.emit('accept')"
>Accept</button>
<button
type="button"
class="chat-interrupt-panel__btn chat-interrupt-panel__btn--secondary"
(click)="action.emit('edit')"
>Edit</button>
<button
type="button"
class="chat-interrupt-panel__btn chat-interrupt-panel__btn--secondary"
(click)="action.emit('respond')"
>Respond</button>
<button
type="button"
class="chat-interrupt-panel__btn chat-interrupt-panel__btn--tertiary"
(click)="action.emit('ignore')"
>Ignore</button>
</div>
Expand All @@ -102,11 +147,5 @@ export class ChatInterruptPanelComponent {

readonly interrupt = computed(() => getInterruptFromAgent(this.agent()));

readonly interruptPayload = computed(() => {
const interrupt = this.interrupt();
if (!interrupt) return '';
const val = interrupt.value;
if (typeof val === 'string') return val;
return JSON.stringify(val);
});
readonly interruptReason = computed(() => interruptReasonText(this.interrupt()));
}
4 changes: 1 addition & 3 deletions libs/chat/src/lib/compositions/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import { ChatMessageComponent, type ChatMessageRole } from '../../primitives/cha
import { ChatInputComponent } from '../../primitives/chat-input/chat-input.component';
import { ChatTypingIndicatorComponent } from '../../primitives/chat-typing-indicator/chat-typing-indicator.component';
import { ChatErrorComponent } from '../../primitives/chat-error/chat-error.component';
import { ChatInterruptComponent } from '../../primitives/chat-interrupt/chat-interrupt.component';
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 { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component';
Expand Down Expand Up @@ -56,7 +55,7 @@ export function isPinned(
imports: [
KeyValuePipe,
ChatWindowComponent, ChatMessageListComponent, MessageTemplateDirective, ChatMessageComponent,
ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent, ChatInterruptComponent,
ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent,
ChatThreadListComponent, ChatGenerativeUiComponent,
ChatStreamingMdComponent, ChatToolCallsComponent, ChatSubagentsComponent, A2uiSurfaceComponent,
ChatMessageActionsComponent, ChatWelcomeComponent, ChatSelectComponent, ChatReasoningComponent,
Expand Down Expand Up @@ -235,7 +234,6 @@ export function isPinned(
/>
}
<chat-error [agent]="agent()" />
<chat-interrupt [agent]="agent()" />
<chat-input [agent]="agent()" [submitOnEnter]="true" placeholder="Type a message..." (submitted)="onUserSubmitted()">
@if (modelOptions().length > 0) {
<chat-select
Expand Down
44 changes: 44 additions & 0 deletions libs/langgraph/src/lib/internals/stream-manager.bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = B
// panel keeps showing the streamed pre-mutation content.
syncToolCallsFromMessages();
}

// Hydrate pending interrupts from the latest checkpoint. When a
// thread is reloaded mid-pause (paused at an interrupt), the
// streaming events won't replay — interrupts live on the
// checkpoint's tasks[i].interrupts and must be projected manually
// so the interrupt panel re-renders on page reload.
hydrateInterruptsFromHistory(history as ThreadState<T>[], subjects);
}
} catch (err) {
if (!controller.signal.aborted && (err as Error)?.name !== 'AbortError') {
Expand Down Expand Up @@ -760,6 +767,43 @@ function extractInterrupts<T, B extends BagTemplate>(
}
}

/**
* Projects pending interrupts from the latest history checkpoint onto the
* interrupt$ / interrupts$ subjects. ThreadState exposes interrupts under
* `tasks[i].interrupts` (per the LangGraph SDK schema). When the latest
* checkpoint contains any pending interrupts, mirror them so consumers can
* react via `agent.interrupt()` on thread reload without needing a fresh
* stream event.
*
* If no interrupts are present, this is a no-op so existing streamed state
* (mid-run) isn't clobbered by a stale history refresh.
*/
function hydrateInterruptsFromHistory<T, B extends BagTemplate>(
history: ThreadState<T>[],
subjects: StreamSubjects<T, B>,
): void {
const latest = history[0];
if (!latest || !Array.isArray(latest.tasks)) return;
const collected: Interrupt[] = [];
for (const task of latest.tasks) {
if (task && Array.isArray(task.interrupts) && task.interrupts.length > 0) {
for (const ix of task.interrupts as Interrupt[]) {
collected.push(ix);
}
}
}
if (collected.length > 0) {
subjects.interrupts$.next(
collected as unknown as Parameters<typeof subjects.interrupts$.next>[0],
);
subjects.interrupt$.next(
collected[collected.length - 1] as unknown as Parameters<
typeof subjects.interrupt$.next
>[0],
);
}
}

function extractEventData(event: StreamEvent): unknown {
// Try event['data'] first (SDK format from normalizeSdkEvent)
const d = event['data'];
Expand Down
Loading