Skip to content

Commit 356c014

Browse files
gaoyu06claude
andcommitted
fix(claude): initial model display, plan/auto approval modes + yolo respawn, subagent tool cards
Bug 1 — new claude session showed the "model" placeholder until the first message. The onStart list_models prefetch now seeds the model button from the default/recommended alias's resolvedModel and emits an initial model_status. Bug 2 — approval modes. Claude natively has `auto` (model auto-approves tool calls) and `plan` (read-only planning); the desktop only mapped 3 modes and collapsed plan to read-only. Add a claude-only 5-option picker (ask/plan/auto/edits/all → default/plan/auto/acceptEdits/bypassPermissions), gated by a new `extendedApprovalModes` cap. bypassPermissions ("yolo") isn't honored by a live set_permission_mode, so switching to full-auto now respawns the child with `--permission-mode bypassPermissions --resume <sid>` (context preserved), reusing the restart/switch template; other modes keep the live path. Bug 3 — claude tool cards stuck "running" forever with empty summaries. The translate guard dropped every frame with a non-null parent_tool_use_id, so a Task subagent's inner tool frames (authoritative input-fill + tool_result) were discarded, leaving empty, perpetually-running cards. Route those frames through a subagentFrame() helper that surfaces only the subagent's tool activity (cards fill and complete) while hiding its chatter; plus a reducer safety net that sweeps any still-running tool card to done at turn end. svelte-check 0/0, vitest 288, cargo test 73. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent da8b5ea commit 356c014

16 files changed

Lines changed: 325 additions & 25 deletions

File tree

src-tauri/src/backend.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ const MAX_CUSTOM_ENV_VARS: usize = 50;
122122
const MAX_CUSTOM_ENV_VALUE_LEN: usize = 4096;
123123

124124
/// Claude Code permission modes the desktop is allowed to request.
125-
const CLAUDE_PERMISSION_MODES: &[&str] = &["default", "plan", "acceptEdits", "bypassPermissions"];
125+
const CLAUDE_PERMISSION_MODES: &[&str] =
126+
&["default", "plan", "auto", "acceptEdits", "bypassPermissions"];
126127

127128
fn expect_string(key: &str, v: &serde_json::Value) -> Result<String, String> {
128129
v.as_str()

src/lib/Composer.svelte

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,23 @@
170170
if (document.activeElement === el) caretToEnd();
171171
});
172172
173-
const APPROVAL = $derived([
174-
{ value: 'ask', label: t('chat.approvalAsk') },
175-
{ value: 'edits', label: t('chat.approvalEdits') },
176-
{ value: 'all', label: t('chat.approvalAll') }
177-
]);
173+
// Claude exposes two extra native modes (plan / auto) between ask and edits;
174+
// other backends keep the shared three (gated by extendedApprovalModes).
175+
const APPROVAL = $derived(
176+
bcaps.extendedApprovalModes
177+
? [
178+
{ value: 'ask', label: t('chat.approvalAsk') },
179+
{ value: 'plan', label: t('chat.approvalPlan') },
180+
{ value: 'auto', label: t('chat.approvalAuto') },
181+
{ value: 'edits', label: t('chat.approvalEdits') },
182+
{ value: 'all', label: t('chat.approvalAll') }
183+
]
184+
: [
185+
{ value: 'ask', label: t('chat.approvalAsk') },
186+
{ value: 'edits', label: t('chat.approvalEdits') },
187+
{ value: 'all', label: t('chat.approvalAll') }
188+
]
189+
);
178190
const approvalLabel = $derived(APPROVAL.find((a) => a.value === chat.approvalMode)?.label ?? t('chat.approvalAsk'));
179191
// Persisting + pushing the mode to the engine lives with the page (it owns
180192
// the session id); the picker only reports the choice.

src/lib/approval.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildApproveOp,
55
buildSetApprovalModeOp,
66
fromEngineMode,
7+
needsClaudeYoloRespawn,
78
parseHunks,
89
reconcileMode,
910
selectionState,
@@ -21,13 +22,26 @@ const HUNKS: ApprovalHunk[] = [
2122

2223
describe('approval mode mapping', () => {
2324
it('maps each desktop mode to its engine mode and back (round trip)', () => {
24-
const modes: ApprovalMode[] = ['ask', 'edits', 'all'];
25+
const modes: ApprovalMode[] = ['ask', 'plan', 'auto', 'edits', 'all'];
2526
for (const m of modes) expect(fromEngineMode(toEngineMode(m))).toBe(m);
2627
expect(toEngineMode('ask')).toBe('read-only');
28+
expect(toEngineMode('plan')).toBe('plan');
29+
expect(toEngineMode('auto')).toBe('auto');
2730
expect(toEngineMode('edits')).toBe('auto-edit');
2831
expect(toEngineMode('all')).toBe('full-auto');
2932
});
3033

34+
it('routes only a claude switch to full-auto through a respawn', () => {
35+
// claude's runtime bypassPermissions switch is ignored → respawn.
36+
expect(needsClaudeYoloRespawn('claude', 'full-auto')).toBe(true);
37+
// Every other claude mode switches live.
38+
for (const m of ['read-only', 'plan', 'auto', 'auto-edit'] as const)
39+
expect(needsClaudeYoloRespawn('claude', m)).toBe(false);
40+
// Other backends never respawn.
41+
expect(needsClaudeYoloRespawn('jucode', 'full-auto')).toBe(false);
42+
expect(needsClaudeYoloRespawn('codex', 'full-auto')).toBe(false);
43+
});
44+
3145
it('returns null for an unknown engine mode', () => {
3246
expect(fromEngineMode('yolo')).toBeNull();
3347
expect(fromEngineMode('')).toBeNull();

src/lib/approval.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,28 @@
44
// engine's, reconciles from `approval_mode` events, and builds structured
55
// `approve` ops — including per-hunk partial approvals for edit tools.
66

7-
export type ApprovalMode = 'ask' | 'edits' | 'all';
8-
export type EngineApprovalMode = 'read-only' | 'auto-edit' | 'full-auto';
7+
// The shared 3-mode enum drives jucode/codex; claude additionally exposes
8+
// 'plan' (read-only planning) and 'auto' (model auto-approves tool calls),
9+
// gated behind BackendCaps.extendedApprovalModes so only the claude picker
10+
// offers them. plan/auto map 1:1 between the UI and engine layers.
11+
export type ApprovalMode = 'ask' | 'plan' | 'auto' | 'edits' | 'all';
12+
export type EngineApprovalMode = 'read-only' | 'plan' | 'auto' | 'auto-edit' | 'full-auto';
913

1014
// File-mutating tools the engine gates (the rest it gates are shell tools).
1115
// Still used by ChatState to feed the Changes panel from tool_output events.
1216
export const EDIT_TOOLS = ['write', 'edit', 'str_replace', 'hashline_edit', 'apply_patch'];
1317

1418
const UI_TO_ENGINE: Record<ApprovalMode, EngineApprovalMode> = {
1519
ask: 'read-only',
20+
plan: 'plan',
21+
auto: 'auto',
1622
edits: 'auto-edit',
1723
all: 'full-auto'
1824
};
1925
const ENGINE_TO_UI: Record<EngineApprovalMode, ApprovalMode> = {
2026
'read-only': 'ask',
27+
plan: 'plan',
28+
auto: 'auto',
2129
'auto-edit': 'edits',
2230
'full-auto': 'all'
2331
};
@@ -45,6 +53,16 @@ export function buildSetApprovalModeOp(mode: ApprovalMode): {
4553
return { op: 'set_approval_mode', mode: toEngineMode(mode) };
4654
}
4755

56+
/** Whether a claude approval-mode change to `mode` must go through an engine
57+
* respawn (spawned with `--permission-mode bypassPermissions` + `--resume`)
58+
* rather than a live `set_permission_mode` control frame: claude does not honor
59+
* a runtime switch INTO bypassPermissions (no system/status follow-up, so the
60+
* UI never reconciles). Every other mode switches live. Non-claude backends
61+
* never respawn. */
62+
export function needsClaudeYoloRespawn(backendId: string, mode: EngineApprovalMode): boolean {
63+
return backendId === 'claude' && mode === 'full-auto';
64+
}
65+
4866
// --- per-hunk approval ------------------------------------------------------
4967

5068
/** One selectable hunk of a pending edit, from `approval_request.hunks`. */

src/lib/backends/claude.test.ts

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { createClaudeAdapter, CLAUDE_CAPS } from './claude';
2+
import { createClaudeAdapter, CLAUDE_CAPS, toClaudeMode, fromClaudeMode } from './claude';
33
import type { EngineAdapter, SessionCtx } from './types';
44

55
// Fixtures below are condensed from live `claude --print --input-format
@@ -128,6 +128,41 @@ describe('claude adapter: startup', () => {
128128
}
129129
});
130130

131+
it('the list_models prefetch ack seeds the model button before the first turn', () => {
132+
// Bug: a new claude session showed the "model" placeholder until the first
133+
// message, because the real model only arrives with the first system/init.
134+
// The onStart list_models prefetch now seeds it from the default alias.
135+
const { lines, io } = makeIo();
136+
const adapter = createClaudeAdapter();
137+
adapter.onStart(io, CTX);
138+
adapter.translate({
139+
type: 'control_response',
140+
response: { subtype: 'success', request_id: parse(lines[0]).request_id, response: { mode: 'default' } }
141+
});
142+
const seeded = adapter.translate({
143+
type: 'control_response',
144+
response: { subtype: 'success', request_id: parse(lines[1]).request_id, response: { models: CATALOG } }
145+
});
146+
expect(seeded).toEqual([
147+
{
148+
type: 'model_status',
149+
provider: 'anthropic',
150+
// The default/recommended alias's concrete resolvedModel + compact label.
151+
model: 'claude-opus-4-8[1m]',
152+
model_label: 'Opus 4.8 (1M)',
153+
reasoning_effort: 'medium',
154+
reasoning_efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
155+
context_window: 0,
156+
context_limit: 0
157+
}
158+
]);
159+
// The first init reports the same resolved model → no duplicate model_status
160+
// (init still emits the full startup sequence regardless).
161+
const init = adapter.translate(initFrame());
162+
expect(init).toHaveLength(5);
163+
expect(init[0]).toMatchObject({ type: 'startup', model: 'claude-opus-4-8[1m]' });
164+
});
165+
131166
it('the bootstrap ack doubles as the readiness signal (init only arrives with the first turn)', () => {
132167
const { lines, io } = makeIo();
133168
const adapter = createClaudeAdapter();
@@ -448,10 +483,11 @@ describe('claude adapter: turns', () => {
448483
expect(JSON.parse(String(denied[0].output))).toEqual({ path: '/etc/x', error: 'probe denies this' });
449484
});
450485

451-
it('drops Task-subagent frames (parent_tool_use_id) and synthetic user notices', () => {
486+
it('drops Task-subagent chatter (text/reasoning) and synthetic user notices', () => {
452487
const { lines } = makeIo();
453488
const adapter = createClaudeAdapter();
454489
boot(adapter, lines);
490+
// Subagent text stream is dropped (we don't render subagent chatter).
455491
expect(
456492
adapter.translate(
457493
{ ...streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'inner' } }), parent_tool_use_id: 'toolu_task' }
@@ -466,6 +502,56 @@ describe('claude adapter: turns', () => {
466502
})
467503
).toEqual([]);
468504
});
505+
506+
it('surfaces a Task-subagent tool so its card fills and completes (bug: stuck running)', () => {
507+
// A subagent's inner tool frames carry a non-null parent_tool_use_id. The
508+
// old guard dropped EVERY such frame, so the card never filled its input and
509+
// never completed — empty + spinning forever. Now the tool activity is
510+
// surfaced (its assistant text / reasoning / user echoes stay dropped).
511+
const { lines } = makeIo();
512+
const adapter = createClaudeAdapter();
513+
boot(adapter, lines);
514+
// The subagent's streamed tool_use start (non-authoritative, empty input).
515+
const start = adapter.translate({
516+
...streamEvent({
517+
type: 'content_block_start',
518+
index: 0,
519+
content_block: { type: 'tool_use', id: 'toolu_sub', name: 'Read', input: {} }
520+
}),
521+
parent_tool_use_id: 'toolu_task'
522+
});
523+
expect(start).toEqual([
524+
{ type: 'tool_start', call_id: 'toolu_sub', name: 'read' },
525+
{ type: 'tool_update', call_id: 'toolu_sub', output: JSON.stringify({ path: '' }) }
526+
]);
527+
// The authoritative assistant tool_use frame fills the input.
528+
const filled = adapter.translate({
529+
type: 'assistant',
530+
message: {
531+
id: 'm',
532+
model: 'x',
533+
content: [{ type: 'tool_use', id: 'toolu_sub', name: 'Read', input: { file_path: '/proj/x.ts' } }]
534+
},
535+
session_id: SID,
536+
parent_tool_use_id: 'toolu_task'
537+
});
538+
expect(filled).toEqual([
539+
{ type: 'tool_update', call_id: 'toolu_sub', output: JSON.stringify({ path: '/proj/x.ts' }) }
540+
]);
541+
// The subagent's tool_result completes the card.
542+
const done = adapter.translate({
543+
type: 'user',
544+
message: {
545+
role: 'user',
546+
content: [{ type: 'tool_result', tool_use_id: 'toolu_sub', content: 'file body', is_error: false }]
547+
},
548+
session_id: SID,
549+
parent_tool_use_id: 'toolu_task'
550+
});
551+
expect(done).toEqual([
552+
{ type: 'tool_output', call_id: 'toolu_sub', name: 'read', output: JSON.stringify({ path: '/proj/x.ts' }), is_error: false }
553+
]);
554+
});
469555
});
470556

471557
describe('claude adapter: approvals', () => {
@@ -636,6 +722,41 @@ describe('claude adapter: interrupt / modes / results', () => {
636722
).toEqual([{ type: 'approval_mode', mode: 'full-auto' }]);
637723
});
638724

725+
it('maps all five approval modes to claude permission modes and back', () => {
726+
expect(toClaudeMode('read-only')).toBe('default');
727+
expect(toClaudeMode('plan')).toBe('plan');
728+
expect(toClaudeMode('auto')).toBe('auto');
729+
expect(toClaudeMode('auto-edit')).toBe('acceptEdits');
730+
expect(toClaudeMode('full-auto')).toBe('bypassPermissions');
731+
for (const m of ['read-only', 'plan', 'auto', 'auto-edit', 'full-auto'] as const)
732+
expect(fromClaudeMode(toClaudeMode(m))).toBe(m);
733+
// Aliases the CLI may report.
734+
expect(fromClaudeMode('manual')).toBe('read-only');
735+
expect(fromClaudeMode('dontAsk')).toBe('full-auto');
736+
// plan no longer collapses to read-only (the old bug).
737+
expect(fromClaudeMode('plan')).toBe('plan');
738+
});
739+
740+
it('plan and auto switch live via set_permission_mode + status ack', () => {
741+
for (const [engineMode, claudeMode] of [
742+
['plan', 'plan'],
743+
['auto', 'auto']
744+
] as const) {
745+
const { lines } = makeIo();
746+
const adapter = createClaudeAdapter();
747+
boot(adapter, lines);
748+
const frames = adapter.encodeOp({ op: 'set_approval_mode', mode: engineMode });
749+
expect(parse(frames![0]).request).toEqual({ subtype: 'set_permission_mode', mode: claudeMode });
750+
expect(
751+
adapter.translate({ type: 'system', subtype: 'status', status: null, permissionMode: claudeMode, session_id: SID })
752+
).toEqual([{ type: 'approval_mode', mode: engineMode }]);
753+
}
754+
});
755+
756+
it('extendedApprovalModes cap gates the claude-only plan/auto picker options', () => {
757+
expect(CLAUDE_CAPS.extendedApprovalModes).toBe(true);
758+
});
759+
639760
it('surfaces failed results as error + ready, with login guidance on auth failures', () => {
640761
const { lines } = makeIo();
641762
const adapter = createClaudeAdapter();

0 commit comments

Comments
 (0)