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

@@ -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) */}