Skip to content

Commit ef0abd6

Browse files
authored
feat(cockpit): refund-authorization interrupts example (#553)
* feat(cockpit): refund-authorization LangGraph graph with structured interrupt * feat(cockpit): refund-authorization system prompt * feat(cockpit): refund Angular flow using ChatApprovalCard composition * test(cockpit): e2e for structured refund approval card * fix(chat,cockpit): manual-review fixes for refund approval flow Surfaced by dogfooding the cockpit refund example in a real browser: - ChatApprovalCard: center the dialog (margin:auto was cleared by reset rules, pinning it top-left) and add backdrop blur + 50% dim so the modal reads as modal against dark chat surfaces. - ChatApprovalCard: Edit is now non-terminal — emit('edit') no longer closes the dialog, so callers can reveal an inline editor in the body slot. Approve/Cancel still close. Added a unit test. - Cockpit graph: add a Pydantic with_structured_output extraction step so the approval card shows real amount/customer/reason instead of $0.00/unknown. Wrap identifiers in backticks so markdown doesn't eat the underscores in cus_*/re_* IDs. Verified end-to-end in browser: approve issues at original amount, edit adjusts the amount then issues, cancel skips the refund.
1 parent 00d493f commit ef0abd6

7 files changed

Lines changed: 249 additions & 142 deletions

File tree

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
11
// SPDX-License-Identifier: MIT
22
import { test, expect } from '@playwright/test';
3-
import { submitAndWaitForResponse } from '@threadplane-internal/e2e-harness';
43

5-
test('interrupts: hello prompt produces assistant turn', async ({ page }) => {
6-
const bubble = await submitAndWaitForResponse(page, 'Hello');
7-
// Smoke: backend booted, aimock replayed fixture, assistant bubble
8-
// finalized (data-streaming="false") and is present in the DOM.
9-
await expect(bubble).toBeVisible();
4+
test.describe('cockpit interrupts: refund approval', () => {
5+
test('approval card displays structured payload fields', async ({ page }) => {
6+
await page.goto('/');
7+
await page.getByText('Refund a duplicate charge').click();
8+
const dialog = page.locator('dialog.chat-approval-card');
9+
await expect(dialog).toBeVisible({ timeout: 20_000 });
10+
await expect(dialog).toContainText('Refund approval required');
11+
});
12+
13+
test('Approve issues the refund and the run finishes', async ({ page }) => {
14+
await page.goto('/');
15+
await page.getByText('Refund a duplicate charge').click();
16+
const dialog = page.locator('dialog.chat-approval-card');
17+
await expect(dialog).toBeVisible({ timeout: 20_000 });
18+
await dialog.getByRole('button', { name: 'Approve' }).click();
19+
await expect(page.getByText(/Refund of \$/i)).toBeVisible({ timeout: 20_000 });
20+
});
21+
22+
test('Cancel skips the refund and confirms cancellation', async ({ page }) => {
23+
await page.goto('/');
24+
await page.getByText('Refund a duplicate charge').click();
25+
const dialog = page.locator('dialog.chat-approval-card');
26+
await expect(dialog).toBeVisible({ timeout: 20_000 });
27+
await dialog.getByRole('button', { name: 'Cancel' }).click();
28+
await expect(page.getByText(/Refund cancelled by operator/i)).toBeVisible({ timeout: 20_000 });
29+
});
1030
});
Lines changed: 74 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,43 @@
11
// SPDX-License-Identifier: MIT
2-
import { Component } from '@angular/core';
3-
import { ChatComponent, ChatInterruptPanelComponent, ChatWelcomeSuggestionComponent, views, type InterruptAction } from '@threadplane/chat';
2+
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
3+
import { ChatComponent, ChatApprovalCardComponent, ChatWelcomeSuggestionComponent, type ChatApprovalAction } from '@threadplane/chat';
44
import { agent } from '@threadplane/langgraph';
55
import { ExampleChatLayoutComponent } from '@threadplane/example-layouts';
6-
import { signalStateStore } from '@threadplane/render';
6+
import { CurrencyPipe } from '@angular/common';
77
import { environment } from '../environments/environment';
8-
import { ApprovalCardComponent } from './views/approval-card.component';
98

109
const WELCOME_SUGGESTIONS = [
11-
{ label: 'Approve a tool call', value: 'Book a flight to Paris for next Tuesday.' },
10+
{ label: 'Refund a duplicate charge', value: 'Refund $47.50 to customer cus_a8x2k — they were charged twice for the same order.' },
11+
{ label: 'Refund a chargeback', value: 'Refund $129.00 to customer cus_z19fp who opened a chargeback for unrecognized activity.' },
1212
] as const;
1313

1414
/**
15-
* InterruptsComponent demonstrates human-in-the-loop with `agent()`.
15+
* Refund authorization cockpit example.
1616
*
17-
* The LangGraph backend pauses execution when it needs human approval.
18-
* The `stream.interrupt()` signal provides the interrupt data, and
19-
* `stream.submit({ resume })` resumes execution with the human's decision.
17+
* The LangGraph backend acknowledges the refund draft, then pauses at
18+
* `request_approval` with a structured interrupt payload of the form
19+
* `{ kind: 'refund_approval', amount, customer_id, reason }`.
2020
*
21-
* Key integration points:
22-
* - `stream.interrupt()` — current pause data (undefined when not interrupted)
23-
* - `ChatInterruptPanelComponent` — renders the approval UI with action buttons
24-
* - `stream.submit({ resume })` — resumes the graph with a payload
21+
* The frontend uses `ChatApprovalCardComponent` to render the native-dialog
22+
* modal and emit a `ChatApprovalAction` ('approve' | 'edit' | 'cancel').
23+
* The handler maps each action to a structured resume payload back to the
24+
* graph.
2525
*/
2626
@Component({
2727
selector: 'app-interrupts',
2828
standalone: true,
29-
imports: [ChatComponent, ChatInterruptPanelComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent],
29+
imports: [
30+
ChatComponent,
31+
ChatApprovalCardComponent,
32+
ChatWelcomeSuggestionComponent,
33+
ExampleChatLayoutComponent,
34+
CurrencyPipe,
35+
],
36+
changeDetection: ChangeDetectionStrategy.OnPush,
3037
template: `
3138
<example-chat-layout>
3239
<div main class="flex flex-col h-full">
33-
<chat [agent]="agent" [views]="ui" [store]="uiStore" class="flex-1 min-w-0">
40+
<chat [agent]="agent" class="flex-1 min-w-0">
3441
<div chatWelcomeSuggestions>
3542
@for (s of suggestions; track s.value) {
3643
<chat-welcome-suggestion
@@ -41,48 +48,69 @@ const WELCOME_SUGGESTIONS = [
4148
}
4249
</div>
4350
</chat>
44-
@if (agent.interrupt()) {
45-
<div class="p-4" style="border-top: 1px solid var(--ngaf-chat-separator);">
46-
<chat-interrupt-panel [agent]="agent" (action)="onInterruptAction($event)" />
47-
</div>
48-
}
51+
52+
<chat-approval-card
53+
[agent]="agent"
54+
matchKind="refund_approval"
55+
title="Refund approval required"
56+
[showEdit]="true"
57+
(action)="onAction($event)"
58+
>
59+
<ng-template #body let-payload>
60+
<div style="display:flex; flex-direction:column; gap:6px;">
61+
<div><span style="color:var(--ngaf-chat-text-muted); margin-right:6px;">Amount</span><strong>{{ payload.amount | currency }}</strong></div>
62+
<div><span style="color:var(--ngaf-chat-text-muted); margin-right:6px;">Customer</span><code>{{ payload.customer_id }}</code></div>
63+
@if (payload.reason) {
64+
<div style="font-style:italic; color:var(--ngaf-chat-text-muted); margin-top:4px;">{{ payload.reason }}</div>
65+
}
66+
@if (editing()) {
67+
<div style="margin-top:10px; display:flex; gap:6px; align-items:center;">
68+
<label style="color:var(--ngaf-chat-text-muted); font-size:12px;">Edit amount</label>
69+
<input type="number" step="0.01" [value]="editAmount() ?? payload.amount" (input)="editAmount.set(+($any($event.target).value))" style="padding:4px 8px; border:1px solid var(--ngaf-chat-separator); border-radius:6px; width:120px;" />
70+
<button type="button" (click)="submitEdit(payload)" style="padding:4px 10px; background:var(--ngaf-chat-primary); color:var(--ngaf-chat-on-primary); border:0; border-radius:6px; font-size:12px; cursor:pointer;">Save</button>
71+
</div>
72+
}
73+
</div>
74+
</ng-template>
75+
</chat-approval-card>
4976
</div>
5077
</example-chat-layout>
5178
`,
5279
})
5380
export class InterruptsComponent {
54-
readonly ui = views({ 'approval-card': ApprovalCardComponent });
55-
readonly uiStore = signalStateStore({});
5681
protected readonly suggestions = WELCOME_SUGGESTIONS;
82+
protected readonly editing = signal(false);
83+
protected readonly editAmount = signal<number | null>(null);
5784

58-
protected send(text: string): void {
59-
void this.agent.submit({ message: text });
60-
}
61-
62-
/**
63-
* The streaming resource with interrupt support.
64-
*
65-
* When the LangGraph backend calls `interrupt()`, the `stream.interrupt()`
66-
* signal emits the interrupt payload for display via ChatInterruptPanelComponent.
67-
*/
6885
protected readonly agent = agent({
6986
apiUrl: environment.langGraphApiUrl,
7087
assistantId: environment.streamingAssistantId,
7188
});
7289

73-
/**
74-
* Handle an interrupt action from the panel.
75-
*
76-
* Submitting a resume payload continues the graph.
77-
*
78-
* In a production app, 'edit' would let the user modify the response
79-
* before approval, and 'respond' would send a reply payload.
80-
* For this demo, all actions simply resume the graph.
81-
*/
82-
protected onInterruptAction(action: InterruptAction): void {
83-
// In a production app, 'edit' would let the user modify the response before approval.
84-
// For this demo, all actions simply resume the graph.
85-
void action; // Each branch intentionally does the same thing in this demo
86-
void this.agent.submit({ resume: true });
90+
protected send(text: string): void {
91+
void this.agent.submit({ message: text });
92+
}
93+
94+
protected onAction(action: ChatApprovalAction): void {
95+
if (action === 'approve') {
96+
void this.agent.submit({ resume: { approved: true } });
97+
this.resetEdit();
98+
} else if (action === 'cancel') {
99+
void this.agent.submit({ resume: { approved: false } });
100+
this.resetEdit();
101+
} else if (action === 'edit') {
102+
this.editing.set(true);
103+
}
104+
}
105+
106+
protected submitEdit(payload: { amount: number }): void {
107+
const next = this.editAmount() ?? payload.amount;
108+
void this.agent.submit({ resume: { approved: true, amount: next } });
109+
this.resetEdit();
110+
}
111+
112+
private resetEdit(): void {
113+
this.editing.set(false);
114+
this.editAmount.set(null);
87115
}
88116
}

cockpit/langgraph/interrupts/angular/src/app/views/approval-card.component.ts

Lines changed: 0 additions & 52 deletions
This file was deleted.
Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
# Interrupt-Aware Assistant
1+
# Refund Authorization Assistant
22

3-
You are a helpful assistant that demonstrates human-in-the-loop approval.
4-
Before your response is delivered, the user must approve it.
5-
Respond naturally — the approval step happens automatically in the graph.
3+
You help authorize customer refunds. Every refund must be reviewed by a human
4+
operator before any charge is reversed.
5+
6+
When the user describes a refund situation, acknowledge what you understood:
7+
- The customer identifier they mentioned (or note it's not specified).
8+
- The refund amount in USD (or note it's not specified).
9+
- A short reason — one sentence describing what makes this refund justified.
10+
11+
Then state that you're pausing for operator approval. Do not claim the refund
12+
has been issued — that only happens after approval, in a later step.
13+
14+
Keep your response short. The approval card surfaces structured fields.

0 commit comments

Comments
 (0)