Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
20027fc
docs(roadmap): mark the reasoning-effort model-UX follow-up complete …
cemililik Jul 7, 2026
0a0e368
feat(core,shared): 2.5.H step 1 — agent:reasoning stream event (EA6)
cemililik Jul 7, 2026
d383fcc
fix(core,cli,db): 2.5.H step 1 Opus-review fixes — run-path emit + st…
cemililik Jul 7, 2026
2be8c3d
test,docs(cli,db): 2.5.H step 1 Sonnet-review fixes — store coverage …
cemililik Jul 7, 2026
c11fd0f
feat(cli): 2.5.H step 2a — live-turn feedback, elision markers, model…
cemililik Jul 7, 2026
a955fda
fix(cli): 2.5.H step 2a Opus-review fixes — live timer in Home + busy…
cemililik Jul 7, 2026
e4b40ee
fix(cli): 2.5.H step 2a Sonnet-review fixes — persist elision marker …
cemililik Jul 7, 2026
da2d914
feat(cli): 2.5.H step 2b — reasoning render (collapsible panel + /thi…
cemililik Jul 7, 2026
e9451c3
fix(cli): 2.5.H step 2b Opus-review fixes — Thinking label + test gaps
cemililik Jul 7, 2026
8b09c65
refactor,test(cli): 2.5.H step 2b Sonnet-review fixes — extract+test …
cemililik Jul 7, 2026
5b9b8ce
feat(cli): 2.5.H step 3 — actionable error recovery hints (session-su…
cemililik Jul 7, 2026
c1279f8
fix(cli): 2.5.H step 3 Opus-review fixes — hint accuracy + heuristic …
cemililik Jul 7, 2026
83fe49c
fix(cli): 2.5.H step 3 Sonnet-review fixes — one-shot hint leak + too…
cemililik Jul 7, 2026
aa6063c
docs(roadmap): mark 2.5.H done — reasoning render + errors (EA6); M2.…
cemililik Jul 7, 2026
9c62ae9
fix(cli): 2.5.H — drop redundant `| undefined` on errorRecoveryHint's…
cemililik Jul 7, 2026
7d3f935
style,docs(cli): 2.5.H — SonarCloud/review nits (nested template, ass…
cemililik Jul 7, 2026
f261c54
docs(adr-0036): EA6 note supersedes the stale "four reused" count (no…
cemililik Jul 7, 2026
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: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ compaction. **2.5.G is now underway** — its scope expanded to **Option A** (a
model-pricing story that governs cost) behind three new ADRs ([ADR-0063](docs/decisions/0063-cli-config-write-contract.md)
config-write, [ADR-0064](docs/decisions/0064-live-model-catalog.md) live catalog,
[ADR-0065](docs/decisions/0065-provider-economics-and-extensibility.md) provider economics), across 12 reviewed
steps; the additive lanes 2.5.H / I / J run in parallel.
steps. The additive lane **2.5.H** (reasoning render + live-turn feedback + an actionable error taxonomy — behind
**EA6**, a dual-envelope `agent:reasoning` stream event that *amends* [ADR-0036](docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md);
no new top-level ADR) is ✅ **Done (2026-07-07)**, reaching milestone **M2.5-3** with 2.5.E; the remaining additive
lanes 2.5.I / J run in parallel.
For live status, per-PR history, milestone dates, and open obligations, see the canonical home
[docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the public overview.

Expand Down
26 changes: 26 additions & 0 deletions apps/cli/src/commands/agent-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,32 @@ describe('agentRunCommand (2.Q)', () => {
expect(existsSync(join(cwd, 'pwned.txt'))).toBe(false); // denied BEFORE any write — no file on disk
});

it('a PLAIN-mode failed turn shows the bare code, NOT a session-continuity hint (2.5.H — one-shot)', async () => {
// The one-shot cancels its session in `finally`, so the chat recovery hints ("the session is still active",
// `/compact`, resend) would be FALSE here — the plain printer must suppress them (recoveryHints=false).
writeFileSync(
join(cwd, 'writer.agent.yaml'),
`${AGENT_YAML.replace(' - read_file', ' - write_file')}`,
);
const writeCall: StreamChunk[] = [
{ type: 'tool_call_start', id: 'c1', name: 'write_file' },
{
type: 'tool_call_delta',
id: 'c1',
argsJsonDelta: JSON.stringify({ path: 'pwned.txt', content: 'x' }),
},
{ type: 'tool_call_end', id: 'c1' },
{ type: 'stop', stopReason: 'tool_use', usage: { inputTokens: 1, outputTokens: 1 } },
];
const { d, out } = deps('write a file', {
json: false, // PLAIN mode — the surface that shares makePlainPrinter
providers: scriptedResolver([writeCall]),
});
await agentRunCommand({ agent: join(cwd, 'writer.agent.yaml'), input: [] }, d);
expect(out()).toContain('[turn failed: tool_denied]'); // the code IS shown
expect(out()).not.toContain('session is still active'); // …but no session-continuity hint on a one-shot
});

it('an MCP-declaring agent: surfaces dropped tools to stderr and tears the connection down after the turn (2.R)', async () => {
// The one-shot's OWN command-level MCP wiring: surfaceMcpSkipped (→ stderr, not the --json stdout) + the
// closeMcp teardown in the finally. Drives the REAL buildChatSession over a fake connection (no spawn).
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/commands/agent-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ async function runOneShotTurn(
let turnErrorCode: string | undefined;
const renderer: (event: SessionStreamHandleEvent) => void = deps.global.json
? (event) => deps.io.writeOut(`${JSON.stringify(event)}\n`)
: makePlainPrinter(deps.io);
: // A ONE-SHOT: the session is cancelled in `finally` right after, so suppress the session-continuity recovery
// hint (2.5.H) — "the session is still active / resend / `/compact`" would be false with no live REPL.
makePlainPrinter(deps.io, false);
let unsubscribe: () => void = () => {};
try {
surfaceMcpSkipped(deps.io, built.mcpSkipped);
Expand Down
65 changes: 63 additions & 2 deletions apps/cli/src/commands/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,20 @@ describe('chatCommand', () => {
expect(store.loadFull(sessionId)?.messages).toHaveLength(2);
});

it('/thinking toggles the reasoning panel and reports the resulting state (2.5.H)', async () => {
const { d, err, store, sessionId } = deps(
['/thinking', '/thinking', '/exit'],
[textTurn('hi')],
);
await chatCommand({ agent: undefined }, d);
const out = err();
expect(out).toContain('reasoning panel: shown'); // first /thinking: collapsed (default) → shown
expect(out).toContain('reasoning panel: hidden'); // second /thinking: shown → hidden
// /thinking is read-only (a pure view flip): the session continued, so the 'hello' turn is not persisted here
// (no user turn was sent — only slash commands ran + /exit); the row exists with no messages.
expect(store.loadFull(sessionId)?.messages ?? []).toHaveLength(0);
});

it('rejects an invalid /mode value at the dispatch, LISTING the valid names (a positional not in the mode set)', async () => {
const { d, err } = deps(['/mode bogus', '/exit'], [textTurn('hi')]);
await chatCommand({ agent: undefined }, d);
Expand Down Expand Up @@ -1299,7 +1313,7 @@ describe('makePlainPrinter', () => {
expect(out()).toBe('\n');
});

it('marks a failed turn with its error code, secret-free', () => {
it('marks a failed turn with its error code + an actionable recovery hint, secret-free (2.5.H)', () => {
const { io, out } = captureIo();
const print = makePlainPrinter(io);
print({
Expand All @@ -1310,7 +1324,54 @@ describe('makePlainPrinter', () => {
error: { code: 'turn_limit', message: 'secret-ish detail', retryable: false },
});
expect(out()).toContain('turn_limit');
expect(out()).not.toContain('secret-ish detail'); // only the code, never the message
expect(out()).toContain('session is still active'); // the recovery hint reassures the session survives
expect(out()).not.toContain('secret-ish detail'); // only the code + a static hint, never the message
});

it('renders the /compact·/trim hint for a context-overflow validation, never echoing the message (2.5.H)', () => {
const { io, out } = captureIo();
makePlainPrinter(io)({
type: 'session:turn_completed',
...STAMP,
stopReason: 'error',
tokensUsed: { input: 0, output: 0 },
error: {
code: 'validation',
message: 'maximum context length is 8192; key=sk-LEAK',
retryable: false,
},
});
expect(out()).toContain('/compact'); // the context-overflow heuristic matched the message
expect(out()).not.toContain('sk-LEAK'); // …but the raw message (secret-ish substring) is NOT echoed
});

it('emits ONLY the bare code line for a code with no hint (no stray hint text/newline)', () => {
const { io, out } = captureIo();
makePlainPrinter(io)({
type: 'session:turn_completed',
...STAMP,
stopReason: 'error',
tokensUsed: { input: 0, output: 0 },
error: { code: 'sandbox_error', message: 'unused', retryable: false },
});
expect(out()).toBe('\n[turn failed: sandbox_error]\n'); // no trailing recovery-hint line
});

it('SUPPRESSES the session-continuity hint when recoveryHints=false (the one-shot agent-run path, 2.5.H)', () => {
const { io, out } = captureIo();
makePlainPrinter(
io,
false,
)({
type: 'session:turn_completed',
...STAMP,
stopReason: 'error',
tokensUsed: { input: 0, output: 0 },
error: { code: 'turn_limit', message: 'session cap', retryable: false }, // a code that WOULD hint in a REPL
});
// A one-shot's session ends immediately after, so "the session is still active" would be false — only the code.
expect(out()).toBe('\n[turn failed: turn_limit]\n');
expect(out()).not.toContain('session is still active');
});
});

Expand Down
34 changes: 31 additions & 3 deletions apps/cli/src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import type { CliIo } from '../process/io.js';
import type { GlobalOptions } from '../process/options.js';
import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js';
import {
errorRecoveryHint,
formatToolCall,
sanitizeInline,
stripTerminalControls,
Expand Down Expand Up @@ -973,6 +974,13 @@ export function createChatLineHandler(
: `reasoning effort: ${tier} set, but ${built.agent.model} has no reasoning control — it will be ignored.`,
);
},
// `/thinking` (2.5.H): toggle the collapsible reasoning panel — a pure store-view flip (no session/engine
// effect), mirroring the Ctrl+T keybind. Report the resulting state so the toggle is confirmed (the panel only
// renders while the model is actually streaming reasoning).
toggleReasoning: () => {
store.toggleReasoning();
emitOutput(`reasoning panel: ${store.getSnapshot().reasoningVisible ? 'shown' : 'hidden'}`);
},
// `/compact` (ADR-0062): model-summarise the working context. An LLM call — announce the moment, then
// await, then report the deltas. The engine emits session:compacted (→ the persister writes the boundary
// marker); this notice is the user-facing report. Never crashes the REPL — a failure is reported as output.
Expand Down Expand Up @@ -1676,7 +1684,14 @@ export async function driveJson(ctx: ChatDriveContext): Promise<ChatDriveOutcome
* A plain event printer for the non-TTY surface — streams the assistant tokens and annotates tool calls, both
* SECRET-FREE (only the token text the model produced + the namespaced tool id, never tool arguments).
*/
export function makePlainPrinter(io: CliIo): (event: SessionStreamHandleEvent) => void {
export function makePlainPrinter(
io: CliIo,
// Whether to append the session-continuity recovery hint on a failed turn (2.5.H). TRUE for the plain CHAT REPL
// (`drivePlain`) — the session survives, so "the session is still active; resend / `/compact` / …" is accurate.
// FALSE for the ONE-SHOT `agent run`, which cancels the session in its `finally` right after: those hints would
// be false (no live session, no slash REPL to resend into), so a one-shot prints only `[turn failed: <code>]`.
recoveryHints = true,
): (event: SessionStreamHandleEvent) => void {
return (event) => {
switch (event.type) {
case 'agent:token':
Expand All @@ -1692,9 +1707,22 @@ export function makePlainPrinter(io: CliIo): (event: SessionStreamHandleEvent) =
io.writeOut(`\n${annotation}\n`);
return;
}
case 'session:turn_completed':
io.writeOut(event.error === undefined ? '\n' : `\n[turn failed: ${event.error.code}]\n`);
case 'session:turn_completed': {
if (event.error === undefined) {
io.writeOut('\n');
return;
}
// A failed turn: the code + (in a continuing session only) an actionable, secret-free recovery hint (2.5.H)
// making explicit the session is still active. A one-shot `agent run` sets `recoveryHints = false` — its
// session is cancelled immediately after, so a session-continuity hint would be false there.
const hint = recoveryHints
? errorRecoveryHint(event.error.code, event.error.message)
: undefined;
// Build the optional hint LINE separately (no nested template literal) before composing the output.
const hintLine = hint === undefined ? '' : `${hint}\n`;
io.writeOut(`\n[turn failed: ${event.error.code}]\n${hintLine}`);
return;
}
default:
return;
}
Expand Down
11 changes: 10 additions & 1 deletion apps/cli/src/commands/repl-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface CapabilityCalls {
readonly runDoctor: number;
readonly setMode: number;
readonly setReasoningEffort: number;
readonly toggleReasoning: number;
readonly compactHistory: number;
readonly trimHistory: number;
readonly clearSession: number;
Expand All @@ -39,6 +40,7 @@ function spyContext(): { ctx: ReplCommandContext; calls: () => CapabilityCalls }
runDoctor: vi.fn(),
setMode: vi.fn(),
setReasoningEffort: vi.fn(),
toggleReasoning: vi.fn(),
compactHistory: vi.fn(),
trimHistory: vi.fn(),
clearSession: vi.fn(),
Expand All @@ -55,6 +57,7 @@ function spyContext(): { ctx: ReplCommandContext; calls: () => CapabilityCalls }
showCost: spies.showCost.mock.calls.length,
setMode: spies.setMode.mock.calls.length,
setReasoningEffort: spies.setReasoningEffort.mock.calls.length,
toggleReasoning: spies.toggleReasoning.mock.calls.length,
runDoctor: spies.runDoctor.mock.calls.length,
compactHistory: spies.compactHistory.mock.calls.length,
trimHistory: spies.trimHistory.mock.calls.length,
Expand All @@ -79,6 +82,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => {
'doctor',
'mode',
'effort',
'thinking',
'compact',
'trim',
'clear',
Expand All @@ -97,6 +101,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => {
['doctor', 'runDoctor'],
['mode', 'setMode'],
['effort', 'setReasoningEffort'],
['thinking', 'toggleReasoning'],
['compact', 'compactHistory'],
['trim', 'trimHistory'],
['clear', 'clearSession'],
Expand All @@ -117,6 +122,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => {
counts.runDoctor +
counts.setMode +
counts.setReasoningEffort +
counts.toggleReasoning +
counts.compactHistory +
counts.trimHistory +
counts.clearSession +
Expand All @@ -136,7 +142,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => {

it('replCommandList renders the slash hint, formatReplHelp lists every command', () => {
expect(replCommandList()).toBe(
'/help, /exit, /cancel, /export, /workflows, /cost, /doctor, /mode, /effort, /compact, /trim, /clear, /models',
'/help, /exit, /cancel, /export, /workflows, /cost, /doctor, /mode, /effort, /thinking, /compact, /trim, /clear, /models',
);
const help = formatReplHelp();
for (const command of REPL_COMMANDS) {
Expand All @@ -158,6 +164,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => {
'doctor',
'mode',
'effort',
'thinking',
'trim',
'models',
]) {
Expand All @@ -179,6 +186,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => {
'doctor',
'mode',
'effort',
'thinking',
'compact',
'trim',
'clear',
Expand All @@ -194,6 +202,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => {
'doctor',
'mode',
'effort',
'thinking',
'compact',
'trim',
'clear',
Expand Down
13 changes: 13 additions & 0 deletions apps/cli/src/commands/repl-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export interface ReplCommandContext {
/** Set the reasoning-effort tier (ADR-0066). Receives the raw tier token (empty ⇒ show the current tier + options).
* Pushes the session override (no reseat); chat-only, like `/mode`. */
readonly setReasoningEffort: (effortArg: string) => void | Promise<void>;
/** `/thinking` (2.5.H) — toggle the collapsible reasoning ("thinking") panel's visibility. A pure UI-view flip
* (no session/engine effect); chat-only, like `/mode`/`/effort`. The keyboard `Ctrl+T` does the same. */
readonly toggleReasoning: () => void | Promise<void>;
/** Switch the chat mode (ADR-0057). Receives the raw mode-name token (empty ⇒ show the current mode + options).
* The surface parses + applies it (re-applying the turn policy on the same session) and reports the result. */
readonly setMode: (modeArg: string) => void | Promise<void>;
Expand Down Expand Up @@ -200,6 +203,16 @@ const RAW_REPL_COMMANDS: readonly ReplCommand[] = [
run: (ctx, args) => ctx.setReasoningEffort(args[0] ?? ''),
availableIn: ['chat'],
},
{
name: 'thinking',
label: 'Thinking',
description: 'Show or hide the reasoning ("thinking") panel (or Ctrl+T to toggle).',
// Zero-arg toggle: a pure UI-view flip of the reasoning panel (2.5.H), no session effect. Chat-only (reasoning
// streams only in a live turn), like `/mode` / `/effort`.
effect: 'read',
run: (ctx) => ctx.toggleReasoning(),
availableIn: ['chat'],
},
{
name: 'compact',
label: 'Compact',
Expand Down
Loading
Loading