diff --git a/examples/chat/angular/src/app/modes/welcome-suggestions.ts b/examples/chat/angular/src/app/modes/welcome-suggestions.ts
index de60a7073..2dfbc0407 100644
--- a/examples/chat/angular/src/app/modes/welcome-suggestions.ts
+++ b/examples/chat/angular/src/app/modes/welcome-suggestions.ts
@@ -24,4 +24,9 @@ export const WELCOME_SUGGESTIONS: readonly WelcomeSuggestion[] = [
label: 'Explain promises with code',
value: 'Explain JavaScript promises with a fenced code block in TypeScript.',
},
+ {
+ label: 'Solve a multi-step puzzle (try Effort = high)',
+ value:
+ 'Three friends start with 14 apples. They share them so each gets a different prime number of apples and one gets exactly twice as many as another. How many does each get? Walk through your reasoning step by step.',
+ },
];
diff --git a/examples/chat/angular/src/app/shell/control-palette.component.html b/examples/chat/angular/src/app/shell/control-palette.component.html
index 62fdcf0ee..961bc0952 100644
--- a/examples/chat/angular/src/app/shell/control-palette.component.html
+++ b/examples/chat/angular/src/app/shell/control-palette.component.html
@@ -42,6 +42,15 @@
+
+ Effort
+
+ @for (opt of effortOptions(); track opt.value) {
+ {{ opt.label }}
+ }
+
+
+
();
readonly model = input.required();
readonly modelOptions = input.required();
+ readonly effort = input.required();
+ readonly effortOptions = input.required();
readonly debugOpen = input.required();
readonly modeChange = output();
readonly modelChange = output();
+ readonly effortChange = output();
readonly debugOpenChange = output();
readonly newConversation = output();
@@ -52,6 +55,11 @@ export class ControlPalette {
this.modelChange.emit(value);
}
+ protected pickEffort(event: Event): void {
+ const value = (event.target as HTMLSelectElement).value;
+ this.effortChange.emit(value);
+ }
+
protected toggleDebug(): void {
this.debugOpenChange.emit(!this.debugOpen());
}
diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.html b/examples/chat/angular/src/app/shell/demo-shell.component.html
index 78f59b413..2195efc74 100644
--- a/examples/chat/angular/src/app/shell/demo-shell.component.html
+++ b/examples/chat/angular/src/app/shell/demo-shell.component.html
@@ -5,9 +5,12 @@
[mode]="mode()"
[model]="model()"
[modelOptions]="modelOptions()"
+ [effort]="effort()"
+ [effortOptions]="effortOptions()"
[debugOpen]="debugOpen()"
(modeChange)="onModeChange($event)"
(modelChange)="onModelChange($event)"
+ (effortChange)="onEffortChange($event)"
(debugOpenChange)="onDebugChange($event)"
(newConversation)="onNewConversation()"
/>
diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts
index 4146535d2..660b0e27d 100644
--- a/examples/chat/angular/src/app/shell/demo-shell.component.ts
+++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts
@@ -48,9 +48,15 @@ export class DemoShell {
{ initialValue: modeFromUrl(this.router.url) },
);
- /** Source of truth for the model picker. Injected into submit() via the patched agent. */
+ /**
+ * Source of truth for the model picker. The shell owns it; the
+ * patched submit injects it into state on every send.
+ */
readonly model = signal(this.persistence.read('model') ?? 'gpt-5-mini');
+ /** Reasoning effort for the next submit. Persisted across reloads. */
+ readonly effort = signal(this.persistence.read('effort') ?? 'minimal');
+
protected readonly debugOpen = signal(this.persistence.read('debug') ?? false);
protected readonly modelOptions = signal([
@@ -59,6 +65,13 @@ export class DemoShell {
{ value: 'gpt-5-nano', label: 'gpt-5-nano' },
]);
+ protected readonly effortOptions = signal([
+ { value: 'minimal', label: 'minimal (fast)' },
+ { value: 'low', label: 'low' },
+ { value: 'medium', label: 'medium' },
+ { value: 'high', label: 'high (visible reasoning)' },
+ ]);
+
/** Persisted thread id (null on first run). Reactive so reload reconnects to the same thread. */
private readonly threadIdSignal = signal(this.persistence.read('threadId') ?? null);
@@ -83,7 +96,14 @@ export class DemoShell {
opts?: Parameters[1],
) =>
orig(
- { ...(input ?? {}), state: { ...((input as { state?: Record })?.state ?? {}), model: this.model() } },
+ {
+ ...(input ?? {}),
+ state: {
+ ...((input as { state?: Record })?.state ?? {}),
+ model: this.model(),
+ reasoning_effort: this.effort(),
+ },
+ },
opts,
)) as typeof a.submit;
return a;
@@ -98,6 +118,11 @@ export class DemoShell {
this.persistence.write('model', next);
}
+ protected onEffortChange(next: string): void {
+ this.effort.set(next);
+ this.persistence.write('effort', next);
+ }
+
protected onDebugChange(next: boolean): void {
this.debugOpen.set(next);
this.persistence.write('debug', next);
diff --git a/examples/chat/angular/src/app/shell/palette-persistence.service.spec.ts b/examples/chat/angular/src/app/shell/palette-persistence.service.spec.ts
index 4fb5ea684..940925ee0 100644
--- a/examples/chat/angular/src/app/shell/palette-persistence.service.spec.ts
+++ b/examples/chat/angular/src/app/shell/palette-persistence.service.spec.ts
@@ -12,6 +12,7 @@ describe('PalettePersistence', () => {
it('returns null when nothing is stored', () => {
const svc = TestBed.runInInjectionContext(() => new PalettePersistence());
expect(svc.read('model')).toBeNull();
+ expect(svc.read('effort')).toBeNull();
expect(svc.read('debug')).toBeNull();
expect(svc.read('threadId')).toBeNull();
expect(svc.read('collapsed')).toBeNull();
@@ -23,6 +24,12 @@ describe('PalettePersistence', () => {
expect(svc.read('model')).toBe('gpt-5-mini');
});
+ it('round-trips effort', () => {
+ const svc = TestBed.runInInjectionContext(() => new PalettePersistence());
+ svc.write('effort', 'high');
+ expect(svc.read('effort')).toBe('high');
+ });
+
it('round-trips a boolean value', () => {
const svc = TestBed.runInInjectionContext(() => new PalettePersistence());
svc.write('debug', true);
diff --git a/examples/chat/angular/src/app/shell/palette-persistence.service.ts b/examples/chat/angular/src/app/shell/palette-persistence.service.ts
index b3e69c398..3aea291aa 100644
--- a/examples/chat/angular/src/app/shell/palette-persistence.service.ts
+++ b/examples/chat/angular/src/app/shell/palette-persistence.service.ts
@@ -5,6 +5,7 @@ const KEY = 'ngaf-chat-demo:palette';
interface PaletteState {
model?: string | null;
+ effort?: string | null;
debug?: boolean | null;
threadId?: string | null;
collapsed?: boolean | null;
diff --git a/examples/chat/python/src/graph.py b/examples/chat/python/src/graph.py
index 7abda520b..784d0b8e8 100644
--- a/examples/chat/python/src/graph.py
+++ b/examples/chat/python/src/graph.py
@@ -3,6 +3,10 @@
State the client may send via the LangGraph ``submit``'s ``state`` field:
- ``model`` — OpenAI model name. Default: ``gpt-5-mini``.
+ - ``reasoning_effort`` — 'minimal' | 'low' | 'medium' | 'high'.
+ Default: 'minimal' so first-token latency
+ stays low. Demos surface this as a palette
+ dropdown so users can dial in visible reasoning.
The graph is intentionally minimal: ``__start__ → generate → __end__``.
This is the surface the demo's regenerate path exercises and the
@@ -35,16 +39,17 @@ def _is_reasoning_model(name: str) -> bool:
class State(TypedDict):
messages: Annotated[list, add_messages]
model: Optional[str]
+ reasoning_effort: Optional[str]
async def generate(state: State) -> dict:
model_name = state.get("model") or "gpt-5-mini"
kwargs = {"model": model_name, "streaming": True}
if _is_reasoning_model(model_name):
- # Force minimal effort so first-token latency stays low and
- # streaming is visible out of the box. Reasoning-effort tuning
- # is deferred to the reasoning-phase demo.
- kwargs["reasoning"] = {"effort": "minimal"}
+ # Honor the client's effort selection when present; default to
+ # 'minimal' so first-token latency stays low for unconfigured callers.
+ effort = state.get("reasoning_effort") or "minimal"
+ kwargs["reasoning"] = {"effort": effort}
llm = ChatOpenAI(**kwargs)
messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
response = await llm.ainvoke(messages)
diff --git a/examples/chat/python/tests/test_graph_smoke.py b/examples/chat/python/tests/test_graph_smoke.py
index 4b346500d..128ed48ad 100644
--- a/examples/chat/python/tests/test_graph_smoke.py
+++ b/examples/chat/python/tests/test_graph_smoke.py
@@ -15,8 +15,10 @@ def test_graph_imports():
@pytest.mark.smoke
-def test_state_shape_includes_messages_and_model():
+def test_state_shape_includes_required_channels():
from src.graph import State
annotations = State.__annotations__
assert "messages" in annotations, "State must have a `messages` channel"
assert "model" in annotations, "State must have a `model` channel"
+ assert "reasoning_effort" in annotations, \
+ "State must have a `reasoning_effort` channel (Phase 2A)"
diff --git a/examples/chat/smoke/CHECKLIST.md b/examples/chat/smoke/CHECKLIST.md
index 404602333..05cda9ba9 100644
--- a/examples/chat/smoke/CHECKLIST.md
+++ b/examples/chat/smoke/CHECKLIST.md
@@ -187,6 +187,24 @@ renders correctly both during streaming and after completion.
## Reasoning blocks
+- [ ] Palette "Effort" dropdown lists 4 options
+ (minimal (fast) / low / medium / high (visible reasoning))
+- [ ] Default value is `minimal` on first load
+- [ ] Effort selection persists across reload
+- [ ] With model = gpt-5-mini and effort = high, send the puzzle prompt
+ ("Solve a multi-step puzzle (try Effort = high)" welcome suggestion):
+ - [ ] `` pill appears with "Thinking…" + pulsing dot during streaming
+ - [ ] Reasoning body auto-expands during streaming (markdown rendered)
+ - [ ] After completion, pill collapses to "Thought for {duration}"
+ - [ ] Click pill — body expands; click again — collapses
+- [ ] With effort = minimal, same prompt — pill appears briefly or not at all
+ (first-token latency low)
+- [ ] Switch effort mid-conversation, send again — new message reflects new effort
+- [ ] Cross-mode: send in /embed with effort=high, navigate to /popup,
+ open popup — reasoning pill on the prior message still renders
+- [ ] Server state shows `values.reasoning_effort` matches palette selection
+ (`curl localhost:2024/threads//state`)
+
## Tool calls
## Interrupts / human-in-the-loop