Skip to content

Commit 5dbeaaf

Browse files
authored
feat(examples-chat): Phase 2A — reasoning effort dropdown (#216)
* feat(examples-chat-python): honor state.reasoning_effort * feat(examples-chat-angular): PaletteState gains effort key * feat(examples-chat-angular): control-palette gains Effort dropdown * feat(examples-chat-angular): demo-shell wires effort signal + submit injection * feat(examples-chat-angular): welcome suggestion exercising reasoning * docs(examples-chat-smoke): populate Reasoning blocks checklist
1 parent 78b0669 commit 5dbeaaf

10 files changed

Lines changed: 90 additions & 7 deletions

examples/chat/angular/src/app/modes/welcome-suggestions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,9 @@ export const WELCOME_SUGGESTIONS: readonly WelcomeSuggestion[] = [
2424
label: 'Explain promises with code',
2525
value: 'Explain JavaScript promises with a fenced code block in TypeScript.',
2626
},
27+
{
28+
label: 'Solve a multi-step puzzle (try Effort = high)',
29+
value:
30+
'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.',
31+
},
2732
];

examples/chat/angular/src/app/shell/control-palette.component.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@
4242
</select>
4343
</label>
4444

45+
<label class="palette__group palette__group--model">
46+
<span class="palette__label">Effort</span>
47+
<select [value]="effort()" (change)="pickEffort($event)">
48+
@for (opt of effortOptions(); track opt.value) {
49+
<option [value]="opt.value">{{ opt.label }}</option>
50+
}
51+
</select>
52+
</label>
53+
4554
<button
4655
type="button"
4756
class="palette__toggle"

examples/chat/angular/src/app/shell/control-palette.component.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@ export class ControlPalette {
2424
readonly mode = input.required<DemoMode>();
2525
readonly model = input.required<string>();
2626
readonly modelOptions = input.required<readonly { value: string; label: string }[]>();
27+
readonly effort = input.required<string>();
28+
readonly effortOptions = input.required<readonly { value: string; label: string }[]>();
2729
readonly debugOpen = input.required<boolean>();
2830

2931
readonly modeChange = output<DemoMode>();
3032
readonly modelChange = output<string>();
33+
readonly effortChange = output<string>();
3134
readonly debugOpenChange = output<boolean>();
3235
readonly newConversation = output<void>();
3336

@@ -52,6 +55,11 @@ export class ControlPalette {
5255
this.modelChange.emit(value);
5356
}
5457

58+
protected pickEffort(event: Event): void {
59+
const value = (event.target as HTMLSelectElement).value;
60+
this.effortChange.emit(value);
61+
}
62+
5563
protected toggleDebug(): void {
5664
this.debugOpenChange.emit(!this.debugOpen());
5765
}

examples/chat/angular/src/app/shell/demo-shell.component.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
[mode]="mode()"
66
[model]="model()"
77
[modelOptions]="modelOptions()"
8+
[effort]="effort()"
9+
[effortOptions]="effortOptions()"
810
[debugOpen]="debugOpen()"
911
(modeChange)="onModeChange($event)"
1012
(modelChange)="onModelChange($event)"
13+
(effortChange)="onEffortChange($event)"
1114
(debugOpenChange)="onDebugChange($event)"
1215
(newConversation)="onNewConversation()"
1316
/>

examples/chat/angular/src/app/shell/demo-shell.component.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,15 @@ export class DemoShell {
4848
{ initialValue: modeFromUrl(this.router.url) },
4949
);
5050

51-
/** Source of truth for the model picker. Injected into submit() via the patched agent. */
51+
/**
52+
* Source of truth for the model picker. The shell owns it; the
53+
* patched submit injects it into state on every send.
54+
*/
5255
readonly model = signal<string>(this.persistence.read('model') ?? 'gpt-5-mini');
5356

57+
/** Reasoning effort for the next submit. Persisted across reloads. */
58+
readonly effort = signal<string>(this.persistence.read('effort') ?? 'minimal');
59+
5460
protected readonly debugOpen = signal<boolean>(this.persistence.read('debug') ?? false);
5561

5662
protected readonly modelOptions = signal<readonly { value: string; label: string }[]>([
@@ -59,6 +65,13 @@ export class DemoShell {
5965
{ value: 'gpt-5-nano', label: 'gpt-5-nano' },
6066
]);
6167

68+
protected readonly effortOptions = signal<readonly { value: string; label: string }[]>([
69+
{ value: 'minimal', label: 'minimal (fast)' },
70+
{ value: 'low', label: 'low' },
71+
{ value: 'medium', label: 'medium' },
72+
{ value: 'high', label: 'high (visible reasoning)' },
73+
]);
74+
6275
/** Persisted thread id (null on first run). Reactive so reload reconnects to the same thread. */
6376
private readonly threadIdSignal = signal<string | null>(this.persistence.read('threadId') ?? null);
6477

@@ -83,7 +96,14 @@ export class DemoShell {
8396
opts?: Parameters<typeof a.submit>[1],
8497
) =>
8598
orig(
86-
{ ...(input ?? {}), state: { ...((input as { state?: Record<string, unknown> })?.state ?? {}), model: this.model() } },
99+
{
100+
...(input ?? {}),
101+
state: {
102+
...((input as { state?: Record<string, unknown> })?.state ?? {}),
103+
model: this.model(),
104+
reasoning_effort: this.effort(),
105+
},
106+
},
87107
opts,
88108
)) as typeof a.submit;
89109
return a;
@@ -98,6 +118,11 @@ export class DemoShell {
98118
this.persistence.write('model', next);
99119
}
100120

121+
protected onEffortChange(next: string): void {
122+
this.effort.set(next);
123+
this.persistence.write('effort', next);
124+
}
125+
101126
protected onDebugChange(next: boolean): void {
102127
this.debugOpen.set(next);
103128
this.persistence.write('debug', next);

examples/chat/angular/src/app/shell/palette-persistence.service.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ describe('PalettePersistence', () => {
1212
it('returns null when nothing is stored', () => {
1313
const svc = TestBed.runInInjectionContext(() => new PalettePersistence());
1414
expect(svc.read('model')).toBeNull();
15+
expect(svc.read('effort')).toBeNull();
1516
expect(svc.read('debug')).toBeNull();
1617
expect(svc.read('threadId')).toBeNull();
1718
expect(svc.read('collapsed')).toBeNull();
@@ -23,6 +24,12 @@ describe('PalettePersistence', () => {
2324
expect(svc.read('model')).toBe('gpt-5-mini');
2425
});
2526

27+
it('round-trips effort', () => {
28+
const svc = TestBed.runInInjectionContext(() => new PalettePersistence());
29+
svc.write('effort', 'high');
30+
expect(svc.read('effort')).toBe('high');
31+
});
32+
2633
it('round-trips a boolean value', () => {
2734
const svc = TestBed.runInInjectionContext(() => new PalettePersistence());
2835
svc.write('debug', true);

examples/chat/angular/src/app/shell/palette-persistence.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const KEY = 'ngaf-chat-demo:palette';
55

66
interface PaletteState {
77
model?: string | null;
8+
effort?: string | null;
89
debug?: boolean | null;
910
threadId?: string | null;
1011
collapsed?: boolean | null;

examples/chat/python/src/graph.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
State the client may send via the LangGraph ``submit``'s ``state`` field:
44
55
- ``model`` — OpenAI model name. Default: ``gpt-5-mini``.
6+
- ``reasoning_effort`` — 'minimal' | 'low' | 'medium' | 'high'.
7+
Default: 'minimal' so first-token latency
8+
stays low. Demos surface this as a palette
9+
dropdown so users can dial in visible reasoning.
610
711
The graph is intentionally minimal: ``__start__ → generate → __end__``.
812
This is the surface the demo's regenerate path exercises and the
@@ -35,16 +39,17 @@ def _is_reasoning_model(name: str) -> bool:
3539
class State(TypedDict):
3640
messages: Annotated[list, add_messages]
3741
model: Optional[str]
42+
reasoning_effort: Optional[str]
3843

3944

4045
async def generate(state: State) -> dict:
4146
model_name = state.get("model") or "gpt-5-mini"
4247
kwargs = {"model": model_name, "streaming": True}
4348
if _is_reasoning_model(model_name):
44-
# Force minimal effort so first-token latency stays low and
45-
# streaming is visible out of the box. Reasoning-effort tuning
46-
# is deferred to the reasoning-phase demo.
47-
kwargs["reasoning"] = {"effort": "minimal"}
49+
# Honor the client's effort selection when present; default to
50+
# 'minimal' so first-token latency stays low for unconfigured callers.
51+
effort = state.get("reasoning_effort") or "minimal"
52+
kwargs["reasoning"] = {"effort": effort}
4853
llm = ChatOpenAI(**kwargs)
4954
messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
5055
response = await llm.ainvoke(messages)

examples/chat/python/tests/test_graph_smoke.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ def test_graph_imports():
1515

1616

1717
@pytest.mark.smoke
18-
def test_state_shape_includes_messages_and_model():
18+
def test_state_shape_includes_required_channels():
1919
from src.graph import State
2020
annotations = State.__annotations__
2121
assert "messages" in annotations, "State must have a `messages` channel"
2222
assert "model" in annotations, "State must have a `model` channel"
23+
assert "reasoning_effort" in annotations, \
24+
"State must have a `reasoning_effort` channel (Phase 2A)"

examples/chat/smoke/CHECKLIST.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,24 @@ renders correctly both during streaming and after completion.
187187

188188
## Reasoning blocks
189189

190+
- [ ] Palette "Effort" dropdown lists 4 options
191+
(minimal (fast) / low / medium / high (visible reasoning))
192+
- [ ] Default value is `minimal` on first load
193+
- [ ] Effort selection persists across reload
194+
- [ ] With model = gpt-5-mini and effort = high, send the puzzle prompt
195+
("Solve a multi-step puzzle (try Effort = high)" welcome suggestion):
196+
- [ ] `<chat-reasoning>` pill appears with "Thinking…" + pulsing dot during streaming
197+
- [ ] Reasoning body auto-expands during streaming (markdown rendered)
198+
- [ ] After completion, pill collapses to "Thought for {duration}"
199+
- [ ] Click pill — body expands; click again — collapses
200+
- [ ] With effort = minimal, same prompt — pill appears briefly or not at all
201+
(first-token latency low)
202+
- [ ] Switch effort mid-conversation, send again — new message reflects new effort
203+
- [ ] Cross-mode: send in /embed with effort=high, navigate to /popup,
204+
open popup — reasoning pill on the prior message still renders
205+
- [ ] Server state shows `values.reasoning_effort` matches palette selection
206+
(`curl localhost:2024/threads/<id>/state`)
207+
190208
## Tool calls
191209

192210
## Interrupts / human-in-the-loop

0 commit comments

Comments
 (0)