diff --git a/cockpit/langgraph/interrupts/angular/e2e/interrupts.spec.ts b/cockpit/langgraph/interrupts/angular/e2e/interrupts.spec.ts index 4fa74260a..d53eece24 100644 --- a/cockpit/langgraph/interrupts/angular/e2e/interrupts.spec.ts +++ b/cockpit/langgraph/interrupts/angular/e2e/interrupts.spec.ts @@ -1,10 +1,30 @@ // SPDX-License-Identifier: MIT import { test, expect } from '@playwright/test'; -import { submitAndWaitForResponse } from '@threadplane-internal/e2e-harness'; -test('interrupts: hello prompt produces assistant turn', async ({ page }) => { - const bubble = await submitAndWaitForResponse(page, 'Hello'); - // Smoke: backend booted, aimock replayed fixture, assistant bubble - // finalized (data-streaming="false") and is present in the DOM. - await expect(bubble).toBeVisible(); +test.describe('cockpit interrupts: refund approval', () => { + test('approval card displays structured payload fields', async ({ page }) => { + await page.goto('/'); + await page.getByText('Refund a duplicate charge').click(); + const dialog = page.locator('dialog.chat-approval-card'); + await expect(dialog).toBeVisible({ timeout: 20_000 }); + await expect(dialog).toContainText('Refund approval required'); + }); + + test('Approve issues the refund and the run finishes', async ({ page }) => { + await page.goto('/'); + await page.getByText('Refund a duplicate charge').click(); + const dialog = page.locator('dialog.chat-approval-card'); + await expect(dialog).toBeVisible({ timeout: 20_000 }); + await dialog.getByRole('button', { name: 'Approve' }).click(); + await expect(page.getByText(/Refund of \$/i)).toBeVisible({ timeout: 20_000 }); + }); + + test('Cancel skips the refund and confirms cancellation', async ({ page }) => { + await page.goto('/'); + await page.getByText('Refund a duplicate charge').click(); + const dialog = page.locator('dialog.chat-approval-card'); + await expect(dialog).toBeVisible({ timeout: 20_000 }); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByText(/Refund cancelled by operator/i)).toBeVisible({ timeout: 20_000 }); + }); }); diff --git a/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts b/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts index b10c78dd4..b53efd5db 100644 --- a/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts +++ b/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts @@ -1,36 +1,43 @@ // SPDX-License-Identifier: MIT -import { Component } from '@angular/core'; -import { ChatComponent, ChatInterruptPanelComponent, ChatWelcomeSuggestionComponent, views, type InterruptAction } from '@threadplane/chat'; +import { Component, ChangeDetectionStrategy, signal } from '@angular/core'; +import { ChatComponent, ChatApprovalCardComponent, ChatWelcomeSuggestionComponent, type ChatApprovalAction } from '@threadplane/chat'; import { agent } from '@threadplane/langgraph'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; -import { signalStateStore } from '@threadplane/render'; +import { CurrencyPipe } from '@angular/common'; import { environment } from '../environments/environment'; -import { ApprovalCardComponent } from './views/approval-card.component'; const WELCOME_SUGGESTIONS = [ - { label: 'Approve a tool call', value: 'Book a flight to Paris for next Tuesday.' }, + { label: 'Refund a duplicate charge', value: 'Refund $47.50 to customer cus_a8x2k — they were charged twice for the same order.' }, + { label: 'Refund a chargeback', value: 'Refund $129.00 to customer cus_z19fp who opened a chargeback for unrecognized activity.' }, ] as const; /** - * InterruptsComponent demonstrates human-in-the-loop with `agent()`. + * Refund authorization cockpit example. * - * The LangGraph backend pauses execution when it needs human approval. - * The `stream.interrupt()` signal provides the interrupt data, and - * `stream.submit({ resume })` resumes execution with the human's decision. + * The LangGraph backend acknowledges the refund draft, then pauses at + * `request_approval` with a structured interrupt payload of the form + * `{ kind: 'refund_approval', amount, customer_id, reason }`. * - * Key integration points: - * - `stream.interrupt()` — current pause data (undefined when not interrupted) - * - `ChatInterruptPanelComponent` — renders the approval UI with action buttons - * - `stream.submit({ resume })` — resumes the graph with a payload + * The frontend uses `ChatApprovalCardComponent` to render the native-dialog + * modal and emit a `ChatApprovalAction` ('approve' | 'edit' | 'cancel'). + * The handler maps each action to a structured resume payload back to the + * graph. */ @Component({ selector: 'app-interrupts', standalone: true, - imports: [ChatComponent, ChatInterruptPanelComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], + imports: [ + ChatComponent, + ChatApprovalCardComponent, + ChatWelcomeSuggestionComponent, + ExampleChatLayoutComponent, + CurrencyPipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, template: `
- +
@for (s of suggestions; track s.value) { - @if (agent.interrupt()) { -
- -
- } + + + +
+
Amount{{ payload.amount | currency }}
+
Customer{{ payload.customer_id }}
+ @if (payload.reason) { +
{{ payload.reason }}
+ } + @if (editing()) { +
+ + + +
+ } +
+
+
`, }) export class InterruptsComponent { - readonly ui = views({ 'approval-card': ApprovalCardComponent }); - readonly uiStore = signalStateStore({}); protected readonly suggestions = WELCOME_SUGGESTIONS; + protected readonly editing = signal(false); + protected readonly editAmount = signal(null); - protected send(text: string): void { - void this.agent.submit({ message: text }); - } - - /** - * The streaming resource with interrupt support. - * - * When the LangGraph backend calls `interrupt()`, the `stream.interrupt()` - * signal emits the interrupt payload for display via ChatInterruptPanelComponent. - */ protected readonly agent = agent({ apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }); - /** - * Handle an interrupt action from the panel. - * - * Submitting a resume payload continues the graph. - * - * In a production app, 'edit' would let the user modify the response - * before approval, and 'respond' would send a reply payload. - * For this demo, all actions simply resume the graph. - */ - protected onInterruptAction(action: InterruptAction): void { - // In a production app, 'edit' would let the user modify the response before approval. - // For this demo, all actions simply resume the graph. - void action; // Each branch intentionally does the same thing in this demo - void this.agent.submit({ resume: true }); + protected send(text: string): void { + void this.agent.submit({ message: text }); + } + + protected onAction(action: ChatApprovalAction): void { + if (action === 'approve') { + void this.agent.submit({ resume: { approved: true } }); + this.resetEdit(); + } else if (action === 'cancel') { + void this.agent.submit({ resume: { approved: false } }); + this.resetEdit(); + } else if (action === 'edit') { + this.editing.set(true); + } + } + + protected submitEdit(payload: { amount: number }): void { + const next = this.editAmount() ?? payload.amount; + void this.agent.submit({ resume: { approved: true, amount: next } }); + this.resetEdit(); + } + + private resetEdit(): void { + this.editing.set(false); + this.editAmount.set(null); } } diff --git a/cockpit/langgraph/interrupts/angular/src/app/views/approval-card.component.ts b/cockpit/langgraph/interrupts/angular/src/app/views/approval-card.component.ts deleted file mode 100644 index 87f521b01..000000000 --- a/cockpit/langgraph/interrupts/angular/src/app/views/approval-card.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Component, input } from '@angular/core'; - -@Component({ - selector: 'approval-card', - standalone: true, - template: ` -
- -
- - - - Requires Approval -
- - -
-

{{ description() }}

- -
- - - -
-
-
- `, -}) -export class ApprovalCardComponent { - readonly description = input(''); - readonly emit = input<(event: string) => void>(() => {}); -} diff --git a/cockpit/langgraph/interrupts/python/prompts/interrupts.md b/cockpit/langgraph/interrupts/python/prompts/interrupts.md index 5b370f003..6945d7d93 100644 --- a/cockpit/langgraph/interrupts/python/prompts/interrupts.md +++ b/cockpit/langgraph/interrupts/python/prompts/interrupts.md @@ -1,5 +1,14 @@ -# Interrupt-Aware Assistant +# Refund Authorization Assistant -You are a helpful assistant that demonstrates human-in-the-loop approval. -Before your response is delivered, the user must approve it. -Respond naturally — the approval step happens automatically in the graph. +You help authorize customer refunds. Every refund must be reviewed by a human +operator before any charge is reversed. + +When the user describes a refund situation, acknowledge what you understood: +- The customer identifier they mentioned (or note it's not specified). +- The refund amount in USD (or note it's not specified). +- A short reason — one sentence describing what makes this refund justified. + +Then state that you're pausing for operator approval. Do not claim the refund +has been issued — that only happens after approval, in a later step. + +Keep your response short. The approval card surfaces structured fields. diff --git a/cockpit/langgraph/interrupts/python/src/graph.py b/cockpit/langgraph/interrupts/python/src/graph.py index e349e159c..4f75bf405 100644 --- a/cockpit/langgraph/interrupts/python/src/graph.py +++ b/cockpit/langgraph/interrupts/python/src/graph.py @@ -1,14 +1,19 @@ """ -LangGraph Interrupts Graph +LangGraph Interrupts Graph — Refund Authorization -Demonstrates human-in-the-loop approval using LangGraph's interrupt() -function. The graph generates a response, then pauses for human approval -before delivering it. Resuming with null continues execution; resuming -with { resume: false } rejects the action. +Demonstrates human-in-the-loop approval for high-stakes actions using +LangGraph's interrupt() primitive. The agent drafts a refund (extracting +customer, amount, and reason from the conversation), then pauses at +request_approval. The frontend renders an approval card; resuming with +{ approved: true } issues the refund (optionally with an edited amount), +resuming with { approved: false } skips it. """ from pathlib import Path -from langgraph.graph import StateGraph, MessagesState, END +from typing import TypedDict, Annotated, Optional +from pydantic import BaseModel, Field +from langgraph.graph import StateGraph, START, END +from langgraph.graph.message import add_messages from langgraph.types import interrupt from langchain_openai import ChatOpenAI from langchain_core.messages import SystemMessage, AIMessage @@ -16,37 +21,100 @@ PROMPTS_DIR = Path(__file__).parent.parent / "prompts" -def build_interrupts_graph(): - """ - Constructs a StateGraph with human-in-the-loop approval. +class RefundDraft(BaseModel): + """Structured fields the agent extracts from the refund request.""" + + customer_id: str = Field(description="The customer identifier, e.g. cus_a8x2k. Use 'unknown' if not stated.") + amount: float = Field(description="The refund amount in USD. Use 0 if not stated.") + reason: str = Field(description="One sentence describing why the refund is justified.") + + +class RefundState(TypedDict): + messages: Annotated[list, add_messages] + customer_id: Optional[str] + amount: Optional[float] + reason: Optional[str] + decision_approved: Optional[bool] + refund_id: Optional[str] + - The graph generates a response, then pauses at check_approval - using interrupt(). The frontend displays the interrupt data and - resumes execution when the user approves or rejects. - """ +def build_interrupts_graph(): llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + extractor = ChatOpenAI(model="gpt-5-mini").with_structured_output(RefundDraft) + + async def draft_refund(state: RefundState) -> dict: + """Extract structured refund fields, then acknowledge the draft. - async def generate(state: MessagesState) -> dict: - """Generate a response using the LLM.""" + Two LLM calls: one structured-output extraction that populates + state.customer_id / amount / reason for the approval card, and one + streaming acknowledgement for the chat transcript. + """ system_prompt = (PROMPTS_DIR / "interrupts.md").read_text() - messages = [SystemMessage(content=system_prompt)] + state["messages"] - response = await llm.ainvoke(messages) - return {"messages": [response]} - - async def check_approval(state: MessagesState) -> dict: - """Pause for human approval before proceeding.""" - last_msg = state["messages"][-1] - interrupt(f"The assistant wants to respond: {last_msg.content[:100]}...") - return state - - graph = StateGraph(MessagesState) - graph.add_node("generate", generate) - graph.add_node("check_approval", check_approval) - graph.set_entry_point("generate") - graph.add_edge("generate", "check_approval") - graph.add_edge("check_approval", END) + + draft = await extractor.ainvoke( + [ + SystemMessage(content="Extract the refund fields from the conversation."), + *state["messages"], + ] + ) + + response = await llm.ainvoke([SystemMessage(content=system_prompt)] + state["messages"]) + return { + "messages": [response], + "customer_id": draft.customer_id, + "amount": draft.amount, + "reason": draft.reason, + } + + def request_approval(state: RefundState) -> dict: + """Pause for human approval. Resume value is { approved: bool, amount?: number }.""" + amount = state.get("amount") or 0.0 + customer_id = state.get("customer_id") or "unknown" + reason = state.get("reason") or "" + + decision = interrupt({ + "kind": "refund_approval", + "amount": amount, + "customer_id": customer_id, + "reason": reason, + }) + + if not isinstance(decision, dict) or not decision.get("approved"): + return { + "decision_approved": False, + "messages": [AIMessage(content="Refund cancelled by operator. No charge issued.")], + } + + edited_amount = decision.get("amount") + final_amount = float(edited_amount) if edited_amount is not None else amount + return { + "decision_approved": True, + "amount": final_amount, + } + + def issue_refund(state: RefundState) -> dict: + """Stand-in for the real Stripe call. Logs a fake refund ID.""" + customer_id = state.get("customer_id") or "anon" + refund_id = "re_demo_" + customer_id[-6:] + # Wrap identifiers in backticks so markdown doesn't treat the + # underscores in cus_*/re_* as emphasis delimiters. + msg = f"Refund of ${state['amount']:.2f} issued to `{customer_id}`. Refund ID: `{refund_id}`." + return {"refund_id": refund_id, "messages": [AIMessage(content=msg)]} + + def route_after_approval(state: RefundState) -> str: + return "issue" if state.get("decision_approved") is True else "end" + + graph = StateGraph(RefundState) + graph.add_node("draft", draft_refund) + graph.add_node("request_approval", request_approval) + graph.add_node("issue", issue_refund) + + graph.add_edge(START, "draft") + graph.add_edge("draft", "request_approval") + graph.add_conditional_edges("request_approval", route_after_approval, {"issue": "issue", "end": END}) + graph.add_edge("issue", END) + return graph.compile() -# The graph instance — referenced by langgraph.json graph = build_interrupts_graph() diff --git a/libs/chat/src/lib/compositions/chat-approval-card/chat-approval-card.component.spec.ts b/libs/chat/src/lib/compositions/chat-approval-card/chat-approval-card.component.spec.ts index a6b3670bb..2e9ba87ab 100644 --- a/libs/chat/src/lib/compositions/chat-approval-card/chat-approval-card.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-approval-card/chat-approval-card.component.spec.ts @@ -138,6 +138,29 @@ describe('ChatApprovalCardComponent', () => { expect(host.lastAction).toBe('edit'); }); + it('keeps the dialog open after Edit (non-terminal), closes after Approve', () => { + host.showEdit = true; + host.agent.interrupt!.set({ + id: 'int-edit-open', + value: { kind: 'refund_approval', amount: 10, customer_id: 'cus_a' }, + resumable: true, + }); + fixture.detectChanges(); + const dialog = fixture.nativeElement.querySelector('dialog.chat-approval-card') as HTMLDialogElement; + expect(dialog.open).toBe(true); + + // Edit is non-terminal — dialog must stay open so the caller can reveal + // an inline editor in the body slot. + (fixture.nativeElement.querySelector('.btn-secondary') as HTMLButtonElement).click(); + fixture.detectChanges(); + expect(dialog.open).toBe(true); + + // Approve is terminal — dialog closes. + (fixture.nativeElement.querySelector('.btn-primary') as HTMLButtonElement).click(); + fixture.detectChanges(); + expect(dialog.open).toBe(false); + }); + it('opens the dialog when an interrupt becomes present, closes when it goes away', () => { const dialog = fixture.nativeElement.querySelector('dialog.chat-approval-card') as HTMLDialogElement; expect(dialog.open).toBe(false); diff --git a/libs/chat/src/lib/compositions/chat-approval-card/chat-approval-card.component.ts b/libs/chat/src/lib/compositions/chat-approval-card/chat-approval-card.component.ts index 9e3a6027d..b46502e23 100644 --- a/libs/chat/src/lib/compositions/chat-approval-card/chat-approval-card.component.ts +++ b/libs/chat/src/lib/compositions/chat-approval-card/chat-approval-card.component.ts @@ -29,6 +29,9 @@ export type ChatApprovalAction = 'approve' | 'edit' | 'cancel'; dialog.chat-approval-card { width: 440px; max-width: calc(100vw - 32px); + /* Center in the viewport. The UA stylesheet sets margin:auto on open + modal dialogs, but our reset properties below shadow it. Re-assert. */ + margin: auto; padding: 0; border: 0; border-radius: 12px; @@ -37,7 +40,9 @@ export type ChatApprovalAction = 'approve' | 'edit' | 'cancel'; box-shadow: 0 20px 50px rgba(0,0,0,0.18); } dialog.chat-approval-card::backdrop { - background: rgba(0,0,0,0.32); + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); } .chat-approval-card__header { padding: 14px 16px 12px; @@ -154,7 +159,13 @@ export class ChatApprovalCardComponent { protected emit(action: ChatApprovalAction): void { this.action.emit(action); - this.closeDialog(); + // 'approve' and 'cancel' are terminal — they resolve the interrupt, so the + // dialog closes. 'edit' is non-terminal: the caller reveals an inline + // editor in the body slot and submits the resume itself, so we leave the + // dialog open. + if (action !== 'edit') { + this.closeDialog(); + } } protected onCancelEvent(ev: Event): void {