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
5 changes: 5 additions & 0 deletions examples/chat/angular/src/app/modes/welcome-suggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@
</select>
</label>

<label class="palette__group palette__group--model">
<span class="palette__label">Effort</span>
<select [value]="effort()" (change)="pickEffort($event)">
@for (opt of effortOptions(); track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
</label>

<button
type="button"
class="palette__toggle"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ export class ControlPalette {
readonly mode = input.required<DemoMode>();
readonly model = input.required<string>();
readonly modelOptions = input.required<readonly { value: string; label: string }[]>();
readonly effort = input.required<string>();
readonly effortOptions = input.required<readonly { value: string; label: string }[]>();
readonly debugOpen = input.required<boolean>();

readonly modeChange = output<DemoMode>();
readonly modelChange = output<string>();
readonly effortChange = output<string>();
readonly debugOpenChange = output<boolean>();
readonly newConversation = output<void>();

Expand All @@ -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());
}
Expand Down
3 changes: 3 additions & 0 deletions examples/chat/angular/src/app/shell/demo-shell.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -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()"
/>
Expand Down
29 changes: 27 additions & 2 deletions examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(this.persistence.read('model') ?? 'gpt-5-mini');

/** Reasoning effort for the next submit. Persisted across reloads. */
readonly effort = signal<string>(this.persistence.read('effort') ?? 'minimal');

protected readonly debugOpen = signal<boolean>(this.persistence.read('debug') ?? false);

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

protected readonly effortOptions = signal<readonly { value: string; label: string }[]>([
{ 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<string | null>(this.persistence.read('threadId') ?? null);

Expand All @@ -83,7 +96,14 @@ export class DemoShell {
opts?: Parameters<typeof a.submit>[1],
) =>
orig(
{ ...(input ?? {}), state: { ...((input as { state?: Record<string, unknown> })?.state ?? {}), model: this.model() } },
{
...(input ?? {}),
state: {
...((input as { state?: Record<string, unknown> })?.state ?? {}),
model: this.model(),
reasoning_effort: this.effort(),
},
},
opts,
)) as typeof a.submit;
return a;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 9 additions & 4 deletions examples/chat/python/src/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion examples/chat/python/tests/test_graph_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
18 changes: 18 additions & 0 deletions examples/chat/smoke/CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
- [ ] `<chat-reasoning>` 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/<id>/state`)

## Tool calls

## Interrupts / human-in-the-loop
Expand Down
Loading