feat: MCP {env:VAR} key substitution + coder model/tool-result fixes + docs refactor (v2.7.9)

- MCP secrets: substituteEnvVars recursively resolves {env:NAME} in mcp.json string values from process.env before Zod (opencode-compatible); unset -> '' + boot warning, and invalid-config log names the unset vars (an empty {env:VAR} in a strict url/command field invalidates the whole config)
- data/mcp.json now untracked (.gitignore flips !data/mcp.json -> !data/mcp.example.json); tracked template data/mcp.example.json carries "{env:CONTEXT7_API_KEY}"; .env.example documents the key (9 mcp-config tests)
- Coder fix: message_complete frame model widened string -> string|null (server+web ws-frames parity); dispatcher publishes model: task.model at all 4 external completion points — a null model otherwise fail-closed in publishFrame and dropped the whole frame incl. status:'complete' (regression test)
- Coder fix: claude-sdk mapUserToolResults maps user-message tool_result blocks -> terminal tool_update events (completed/failed w/ output) so tool snapshots resolve instead of spinning forever
- Composer: AgentComposerBar drops §9b resumed/history/new chip + token readout, loses flex-wrap so the row stays one line; CoderPane gains a per-chat localStorage agent-config cache (restores last model on reopen) + threads model into the timeline/chip
- Docs: root CLAUDE.md slimmed (~190 lines), per-app refs split to apps/{coder,server,web}/CLAUDE.md; new docs/coder-backends.md, docs/project-discovery.md, docs/coding-standards/ (cross-app-contract-parity); ARCHITECTURE.md links the backends doc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 17:01:03 +00:00
parent 7ca4a6b344
commit afaca9e426
23 changed files with 1284 additions and 256 deletions

View File

@@ -124,7 +124,11 @@ export const MessageCompleteFrame = z.object({
ctx_max: z.number().int().positive().nullable().optional(),
started_at: IsoTimestamp.nullable().optional(),
finished_at: IsoTimestamp.nullable().optional(),
model: z.string().optional(),
// nullable: external-coder turns carry task.model, which is null when no
// model was selected. This frame is published through the same fail-closed
// publishFrame, so null MUST validate or the entire frame (incl. the
// status:'complete' transition) is dropped.
model: z.string().nullable().optional(),
metadata: OpaqueObject.nullable().optional(),
});

View File

@@ -5,7 +5,6 @@ import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
import type { AgentStatusEntry } from '@/hooks/useAgentStatus';
import { providerIcon } from '@/components/coder/providerIcons';
import { useAgentSessions } from '@/hooks/useAgentSessions';
import {
DropdownMenu,
DropdownMenuContent,
@@ -174,16 +173,6 @@ interface Props {
onChange: (next: AgentSessionConfig) => void;
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
connected?: boolean;
// v2.6 Phase 1-UX §9b: chat id for the resumed/new-session chip. Optional so
// BooChat and any other AgentComposerBar caller renders no chip and is
// otherwise unaffected. When present + connected + the chat has ≥1 prior
// turn, a chip right of the Provider picker reports whether switching to the
// current provider resumes an agent session, replays history (boocode), or
// starts fresh.
sessionId?: string;
// True once the chat has at least one prior turn — gates the chip so it stays
// hidden on a brand-new chat. Defaults to false (no chip).
hasPriorTurn?: boolean;
// #10: normalized status (working|blocked|idle|error) for the active external
// agent in this chat, or null for native boocode / before any frame. Renders
// a status dot DISTINCT from the WS-liveness `connected` dot. Undefined for
@@ -191,31 +180,6 @@ interface Props {
agentStatus?: AgentStatusEntry | null;
}
// Condensed token count: 950 → "950", 12_400 → "12.4K", 3_200_000 → "3.2M".
// Sub-1000 stays exact; thousands/millions get one decimal, trailing .0 trimmed.
function abbrevTokens(n: number): string {
if (!Number.isFinite(n) || n < 1000) return String(Math.max(0, Math.round(n)));
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}K`;
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
}
// Relative-time formatter for the resumed-chip title (e.g. "3m ago").
function relativeTime(iso: string | null): string {
if (!iso) return 'unknown';
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return 'unknown';
const diffMs = Date.now() - then;
if (diffMs < 0) return 'just now';
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return 'just now';
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
// #10: normalized external-agent status dot. Mirrors StatusDot's visual
// language but on the four normalized buckets (working|blocked|idle|error),
// and is DISTINCT from the WS-liveness `connected` dot beside it:
@@ -251,7 +215,7 @@ function AgentStatusDot({ entry, agent }: { entry: AgentStatusEntry; agent: stri
);
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn, agentStatus }: Props) {
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, agentStatus }: Props) {
const allEntries = useProviderSnapshot(projectPath);
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
// still loading). Disabled (enabled:false) and unavailable/error providers are
@@ -263,13 +227,6 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
);
const [refreshing, setRefreshing] = useState(false);
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows for the resumed/new
// chip. Hook is unconditional (hooks rule); it self-no-ops when sessionId is
// undefined or the chat has no prior turn, so BooChat callers cost nothing.
const { sessions: agentSessions } = useAgentSessions(
sessionId && hasPriorTurn ? sessionId : undefined,
);
const hydratedRef = useRef(false);
useEffect(() => {
@@ -383,42 +340,8 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
// v2.6 Phase 1-UX §9b: resumed / history / new-session chip. Only meaningful
// when this is a real chat (sessionId), the WS is connected, and the chat has
// ≥1 prior turn — otherwise render nothing so fresh chats and non-coder
// callers stay clean.
const sessionRow = agentSessions.find((s) => s.agent === value.provider);
const sessionChip: { label: string; title: string } | null =
sessionId && hasPriorTurn && connected
? value.provider === 'boocode'
? // Native boocode never holds an agent_sessions row — it reconstructs
// the conversation from the chat transcript each turn.
{ label: 'history', title: 'BooCode replays the chat transcript each turn' }
: sessionRow?.has_session
? {
label: 'resumed',
title: `Resuming ${value.provider} · last active ${relativeTime(sessionRow.last_active_at)}`,
}
: { label: 'new session', title: `${value.provider} starts a fresh session this turn` }
: null;
// sampling-streamjson-tokens #8: condensed per-(chat,agent) token/cost readout
// beside the session chip. Coerce — input/output are BIGINT (string over wire).
// Hidden when no session row or all totals are zero (e.g. native boocode, which
// holds no agent_sessions row, or a provider that hasn't run yet).
const usageReadout = (() => {
if (!sessionChip || !sessionRow) return null;
const inTok = Number(sessionRow.input_tokens) || 0;
const outTok = Number(sessionRow.output_tokens) || 0;
const cost = Number(sessionRow.cost) || 0;
if (inTok <= 0 && outTok <= 0 && cost <= 0) return null;
const parts = [`${abbrevTokens(inTok)} in`, `${abbrevTokens(outTok)} out`];
if (cost > 0) parts.push(`$${cost.toFixed(2)}`);
return parts.join(' · ');
})();
return (
<div className="flex flex-wrap items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
<CompactPicker
label="Provider"
value={value.provider}
@@ -430,22 +353,6 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
: providerIcon(value.provider)
}
/>
{sessionChip && (
<span
title={sessionChip.title}
className="inline-flex items-center rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shrink-0"
>
{sessionChip.label}
</span>
)}
{usageReadout && (
<span
className="text-[10px] text-muted-foreground tabular-nums whitespace-nowrap shrink-0"
title="Tokens in · out · cost for this agent session"
>
{usageReadout}
</span>
)}
<CompactPicker
label="Mode"
value={value.modeId ?? ''}
@@ -472,8 +379,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
icon={<Brain className="size-3 shrink-0" />}
/>
)}
{/* Status dot + refresh as one right-aligned unit so the refresh button
stays on the top line instead of wrapping past the edge-pinned dot. */}
{/* Status dot + refresh — pinned right (ml-auto), never on its own line. */}
<div className="ml-auto flex items-center gap-1 shrink-0">
{/* #10: normalized agent status — only for an external agent with a
live status frame. Distinct from the WS-liveness dot that follows. */}

View File

@@ -11,6 +11,7 @@ export interface CoderMessageWire {
role: 'user' | 'assistant' | 'system';
content: string;
status?: 'streaming' | 'complete' | 'failed';
model?: string | null;
reasoning_text?: string;
tool_calls?: CoderToolCallWire[];
}

View File

@@ -30,6 +30,8 @@ interface CoderMessage {
role: 'user' | 'assistant' | 'system';
content: string;
status?: 'streaming' | 'complete' | 'failed';
// model-attribution: which model produced this assistant message (chip).
model?: string | null;
reasoning_text?: string;
tool_calls?: Array<{
id: string;
@@ -52,6 +54,46 @@ interface CoderToolMessage {
type CoderTimelineMessage = CoderMessage | CoderToolMessage;
// Per-chat agent-config cache (provider/model/mode/thinking). Keyed by chat id
// so reopening or switching back to a chat restores the model that was loaded
// last there. Per-device (localStorage) — a UI convenience, not authoritative.
const DEFAULT_AGENT_CONFIG: AgentSessionConfig = {
provider: 'boocode',
model: '',
modeId: null,
thinkingOptionId: null,
};
function agentConfigKey(chatId: string | undefined): string | null {
return chatId ? `boocode.coder.config.${chatId}` : null;
}
function readCachedAgentConfig(chatId: string | undefined): AgentSessionConfig | null {
const key = agentConfigKey(chatId);
if (!key || typeof localStorage === 'undefined') return null;
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const c = JSON.parse(raw) as Partial<AgentSessionConfig>;
if (typeof c?.provider !== 'string') return null;
return {
provider: c.provider,
model: typeof c.model === 'string' ? c.model : '',
modeId: c.modeId ?? null,
thinkingOptionId: c.thinkingOptionId ?? null,
};
} catch {
return null;
}
}
function writeCachedAgentConfig(chatId: string | undefined, config: AgentSessionConfig): void {
const key = agentConfigKey(chatId);
if (!key || typeof localStorage === 'undefined') return;
try {
localStorage.setItem(key, JSON.stringify(config));
} catch {
/* quota / disabled storage — non-fatal */
}
}
interface PendingChange {
id: string;
file_path: string;
@@ -97,6 +139,7 @@ type RawCoderMessage = {
chat_id?: string;
content?: string | null;
status?: string | null;
model?: string | null;
reasoning_text?: string;
reasoning_parts?: Array<{ text?: string }> | null;
tool_results?: {
@@ -144,6 +187,7 @@ function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null
role: raw.role as CoderMessage['role'],
content: raw.content ?? '',
status: (raw.status ?? 'complete') as CoderMessage['status'],
...(raw.model ? { model: raw.model } : {}),
...(reasoning_text ? { reasoning_text } : {}),
...(tool_calls?.length ? { tool_calls } : {}),
ctx_used: raw.ctx_used ?? null,
@@ -253,6 +297,7 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
? {
...m,
status: 'complete' as const,
model: (frame as any).model ?? (m as any).model ?? null,
ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null,
ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null,
}
@@ -586,12 +631,37 @@ export function CoderPane({
onConnectedChange,
onAgentLabelChange,
}: Props) {
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
provider: 'boocode',
model: '',
modeId: null,
thinkingOptionId: null,
});
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>(
() => readCachedAgentConfig(chatId) ?? DEFAULT_AGENT_CONFIG,
);
// Restore the per-chat cached config when the chat changes. The ref guard
// skips the initial mount (lazy init already loaded it) + StrictMode double-runs.
const lastLoadedChatRef = useRef<string | undefined>(chatId);
useEffect(() => {
const prev = lastLoadedChatRef.current;
if (prev === chatId) return;
lastLoadedChatRef.current = chatId;
// undefined → real id: the pane just resolved its chat. A selection made
// while chatId was undefined could not be persisted (the key was null), so
// carry the current in-memory config into the new chat — and persist it —
// rather than clobbering the user's pick with DEFAULT on the cache miss.
if (prev === undefined && chatId) {
const cached = readCachedAgentConfig(chatId);
if (cached) setAgentConfig(cached);
else writeCachedAgentConfig(chatId, agentConfig);
return;
}
setAgentConfig(readCachedAgentConfig(chatId) ?? DEFAULT_AGENT_CONFIG);
}, [chatId, agentConfig]);
// Persist on user-driven changes only (not on the restore above), so switching
// chats never clobbers the new chat's cached config with the old one.
const handleAgentConfigChange = useCallback(
(next: AgentSessionConfig) => {
setAgentConfig(next);
writeCachedAgentConfig(chatId, next);
},
[chatId],
);
useEffect(() => {
const parts = [agentConfig.provider || 'boocode'];
@@ -727,13 +797,6 @@ export function CoderPane({
}
}, [messages, refresh, refreshCheckpoints, sessionId]);
// The §9b chip only shows once the chat has ≥1 prior turn (a completed
// assistant message). Hidden on a brand-new chat.
const hasPriorTurn = useMemo(
() => messages.some((m) => m.role === 'assistant' && (m as CoderMessage).status === 'complete'),
[messages],
);
// Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth)
useEffect(() => {
if (!activeTaskId || connected) return;
@@ -1001,11 +1064,9 @@ export function CoderPane({
<AgentComposerBar
projectPath={projectPath}
value={agentConfig}
onChange={setAgentConfig}
onChange={handleAgentConfigChange}
onProviderCommandsChange={handleProviderCommandsChange}
connected={connected}
sessionId={sessionId}
hasPriorTurn={hasPriorTurn}
agentStatus={currentAgentStatus}
/>
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}