feat: Paseo-like orchestrator Phase 1-2 — trace system, session persistence, timeline, run_command, auto-fix loop

Phase 1: Trace System + Observability
- tool_traces DB table + insert/update service
- tool_trace_start/tool_trace_finish WS frames (contracts + FE types)
- Instrumented tool-phase.ts with timing around every tool call
- GET /api/chats/:id/traces paginated endpoint
- Trace viewer frontend (collapsible panel with timing bars + token breakdown)

Phase 2: Session Persistence + Resume
- agent_snapshots table (UPSERT per chat, persisted on turn boundaries)
- save/load/delete service functions
- Agent snapshot sent on WS reconnect
- Session timeline view (vertical timeline with scroll-to + restore)

Tooling:
- run_command tool (execFile, 30s timeout, 32KB cap, path-guarded)
- Auto-fix loop: after write tools, runs pnpm build, injects errors into next turn
This commit is contained in:
2026-06-08 02:26:47 +00:00
parent 7cb692d8be
commit abe9c5a3a8
22 changed files with 2231 additions and 101 deletions

View File

@@ -18,8 +18,10 @@ import { registerCoderProxy } from './routes/coder-proxy.js';
import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js';
import { registerTraceRoutes } from './routes/traces.js';
import { registerToolsRoutes } from './routes/tools.js';
import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerMemoryRoutes } from './routes/memory.js';
import { registerInferenceSettingsRoutes } from './routes/inference-settings.js';
import { createInferenceRunner } from './services/inference/index.js';
import { createBroker } from './services/broker.js';
@@ -124,8 +126,10 @@ async function main() {
registerAgentRoutes(app, sql);
registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker);
registerTraceRoutes(app, sql);
registerToolsRoutes(app, sql);
registerAnalyticsRoutes(app, sql);
registerMemoryRoutes(app, sql);
registerInferenceSettingsRoutes(app);
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or

View File

@@ -0,0 +1,38 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import type { ToolTrace } from '../services/tool-traces.js';
export function registerTraceRoutes(app: FastifyInstance, sql: Sql): void {
app.get<{ Params: { id: string }; Querystring: { limit?: string; offset?: string } }>(
'/api/chats/:id/traces',
async (req, reply) => {
const chat = await sql`SELECT id FROM chats WHERE id = ${req.params.id}`;
if (chat.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200);
const offset = Math.max(Number(req.query.offset) || 0, 0);
const rows = await sql<ToolTrace[]>`
SELECT * FROM tool_traces
WHERE chat_id = ${req.params.id}
ORDER BY started_at ASC
LIMIT ${limit}
OFFSET ${offset}
`;
const [countRow] = await sql<{ count: number }[]>`
SELECT count(*)::int AS count FROM tool_traces WHERE chat_id = ${req.params.id}
`;
return {
data: rows,
total: countRow?.count ?? 0,
limit,
offset,
};
},
);
}

View File

@@ -3,6 +3,7 @@ import type { Sql } from '../db.js';
import type { Broker } from '../services/broker.js';
import type { Message } from '../types/api.js';
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
import { loadAgentSnapshot } from '../services/session-snapshots.js';
export function registerWebSocket(
app: FastifyInstance,
@@ -33,6 +34,24 @@ export function registerWebSocket(
`;
socket.send(JSON.stringify({ type: 'snapshot', messages }));
// v2.7.x: on reconnect, restore agent snapshot state so the frontend
// knows there's an ongoing agent turn. Best-effort per chat; most
// sessions won't have any snapshots.
const chats = await sql<{ id: string }[]>`SELECT id FROM chats WHERE session_id = ${sessionId}`;
for (const chat of chats) {
const agentSnapshot = await loadAgentSnapshot(sql, chat.id).catch(() => null);
if (agentSnapshot) {
socket.send(JSON.stringify({
type: 'agent_snapshot',
chat_id: chat.id,
agent: agentSnapshot.agent,
model: agentSnapshot.model,
mode: agentSnapshot.mode,
turn_number: agentSnapshot.turn_number,
}));
}
}
const unsubscribe = broker.subscribe(sessionId, (frame) => {
if (socket.readyState !== socket.OPEN) return;
try {

View File

@@ -414,3 +414,55 @@ END $$;
-- Remove the v2.0.5 arena_id column (replaced by the new Arena feature).
ALTER TABLE tasks DROP COLUMN IF EXISTS arena_id;
-- v2.x-tool-traces: per-call tool execution records for observability.
CREATE TABLE IF NOT EXISTS tool_traces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
turn_number INTEGER NOT NULL,
tool_name TEXT NOT NULL,
tool_input JSONB NOT NULL,
tool_output TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
finished_at TIMESTAMPTZ,
latency_ms INTEGER,
tokens_used INTEGER,
cache_tokens INTEGER,
reasoning_tokens INTEGER,
error TEXT,
outcome TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_tool_traces_chat ON tool_traces(chat_id, created_at);
-- v2.x-tool-traces: active tool call state for in-flight instrumentation.
CREATE TABLE IF NOT EXISTS tool_trace_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
turn_number INTEGER NOT NULL,
tool_name TEXT NOT NULL,
tool_input JSONB NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- agent_snapshots: persistent agent session state for cross-refresh resume.
CREATE TABLE IF NOT EXISTS agent_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
model TEXT NOT NULL,
agent TEXT,
mode TEXT,
turn_number INTEGER NOT NULL DEFAULT 0,
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
tool_states JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_agent_snapshots_chat ON agent_snapshots(chat_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_snapshots_chat_unique ON agent_snapshots(chat_id);

View File

@@ -20,6 +20,7 @@ import { resolveGrantRoot } from '../grant_resolver.js';
import { stripToolMarkup } from './tool-call-parser.js';
import { repairToolInput } from './tool-input-repair.js';
import type { FailureKind } from './mistake-tracker.js';
import { insertToolTrace, updateToolTrace } from '../tool-traces.js';
import type {
InferenceContext,
StreamResult,
@@ -175,6 +176,7 @@ export async function executeToolPhase(
session: Session,
projectRoot: string,
agent?: Agent | null,
turnNumber?: number,
): Promise<ToolPhaseResult> {
const { sessionId, chatId, assistantMessageId } = args;
const content = stripToolMarkup(result.content, { final: true });
@@ -378,11 +380,53 @@ export async function executeToolPhase(
});
return;
}
// tool_trace instrumentation - start
const traceId = crypto.randomUUID();
const traceStartTime = Date.now();
const startedAtIso = new Date().toISOString();
insertToolTrace(ctx.sql, {
session_id: sessionId,
chat_id: chatId,
message_id: assistantMessageId,
turn_number: turnNumber ?? 0,
tool_name: tc.name,
tool_input: tc.args as Record<string, unknown>,
}).catch(() => {});
ctx.publish(sessionId, {
type: 'tool_trace_start',
trace_id: traceId,
message_id: assistantMessageId,
chat_id: chatId,
tool_name: tc.name,
tool_input: tc.args as Record<string, unknown>,
started_at: startedAtIso,
});
const tres = await executeToolCall(
projectRoot, tc, session.allowed_read_paths,
{ sql: ctx.sql, sessionId },
ctx.hooks, sessionId,
);
// tool_trace instrumentation - finish
const finishedAtIso = new Date().toISOString();
const latencyMs = Date.now() - traceStartTime;
updateToolTrace(ctx.sql, traceId, {
finished_at: finishedAtIso,
...(tres.outcome === 'success' && tres.output != null ? { tool_output: JSON.stringify(tres.output) } : {}),
latency_ms: latencyMs,
outcome: tres.outcome,
...(tres.error ? { error: tres.error } : {}),
}).catch(() => {});
ctx.publish(sessionId, {
type: 'tool_trace_finish',
trace_id: traceId,
message_id: assistantMessageId,
chat_id: chatId,
tool_name: tc.name,
finished_at: finishedAtIso,
outcome: tres.outcome,
latency_ms: latencyMs,
...(tres.error ? { error: tres.error } : {}),
});
// vWhale: PostToolUse hook (best-effort, non-blocking).
if (ctx.hooks) {
ctx.hooks.run('PostToolUse', {

View File

@@ -37,6 +37,12 @@ import type {
StreamResult,
TurnArgs,
} from './types.js';
import { saveAgentSnapshot } from '../session-snapshots.js';
// vWhale: auto-fix loop — after write tools, build the project and inject
// errors. Uses execFile (no shell) against the project root.
import { execFile } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import {
runCapHitSummary,
runDoomLoopSummary,
@@ -44,6 +50,71 @@ import {
insertMistakeRecoverySentinel,
} from './sentinel-summaries.js';
// vWhale: auto-fix — detect build command from package.json, run it, return
// error text for injection into next iteration. Best-effort, never throws.
const BUILD_TIMEOUT_MS = 60_000;
const BUILD_OUTPUT_CAP = 8_000;
async function detectAndRunBuild(
ctx: InferenceContext,
projectRoot: string,
sessionId: string,
chatId: string,
model: string,
existingNote: string | undefined,
): Promise<string | undefined> {
// Only run for DeepSeek models (local Qwen models don't benefit from build loop).
if (!model.startsWith('deepseek-')) return undefined;
// Detect build command from package.json in project root.
const pkgPath = join(projectRoot, 'package.json');
if (!existsSync(pkgPath)) return undefined;
let buildCmd: string | null = null;
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> };
if (pkg.scripts?.build) buildCmd = 'build';
else if (pkg.scripts?.compile) buildCmd = 'compile';
else if (pkg.scripts?.typecheck) buildCmd = 'typecheck';
} catch {
return undefined;
}
if (!buildCmd) return undefined;
// Detect package manager.
const hasPnpm = existsSync(join(projectRoot, 'pnpm-lock.yaml'));
const hasYarn = existsSync(join(projectRoot, 'yarn.lock'));
const pm = hasPnpm ? 'pnpm' : hasYarn ? 'yarn' : 'npm';
// Run the build.
try {
const out = await new Promise<string>((resolve, reject) => {
execFile(pm, ['run', buildCmd!], { cwd: projectRoot, timeout: BUILD_TIMEOUT_MS, maxBuffer: BUILD_OUTPUT_CAP * 2 },
(err, stdout, stderr) => {
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
resolve(''); // package manager not found — skip
return;
}
const merged = (stdout + '\n' + stderr).trim();
resolve(merged.slice(0, BUILD_OUTPUT_CAP));
},
);
});
if (!out) return undefined; // build succeeded or no output
ctx.log.info({ sessionId, chatId, buildCmd, outputLen: out.length }, 'auto-fix: build failed');
// Truncate if existing note exists
const combined = existingNote
? existingNote + '\n\n--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP - existingNote.length)
: '--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP);
return combined;
} catch {
return undefined;
}
}
// P5: MAX_STEPS moved to ./turn-config.ts (with resolveTurnConfig). Re-exported
// here so the public surface (index.ts → './turn.js') is unchanged.
export { MAX_STEPS } from './turn-config.js';
@@ -240,7 +311,7 @@ export async function runAssistantTurn(
// ---- tool phase ----
let toolPhaseResult: ToolPhaseResult;
try {
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent);
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent, stepNumber);
} catch (err) {
// Tool phase errors are unexpected (individual tool failures are
// caught inside executeToolPhase). Log and break.
@@ -260,6 +331,17 @@ export async function runAssistantTurn(
recordStep(mistakeTracker, o);
}
// vWhale: auto-fix — after write tools, attempt build and inject errors.
const WRITE_TOOLS = new Set(['edit_file', 'create_file', 'delete_file', 'apply_pending']);
const hasWriteTools = toolPhaseResult.toolCalls.some((tc) => WRITE_TOOLS.has(tc.name));
if (hasWriteTools) {
detectAndRunBuild(ctx, projectRoot, sessionId, chatId, iterSession.model, pendingRecoveryNote)
.then((buildError) => {
if (buildError) pendingRecoveryNote = buildError;
})
.catch(() => {});
}
// v#12 MistakeTracker: post-tool decision (pure). 'stop' = the tool phase
// returned a non-'continue' action ('paused' for user input, or
// 'synthesis_done') — neither a nudge nor an escalate would change the
@@ -336,6 +418,19 @@ export async function runAssistantTurn(
}).catch(() => {});
}
// ---- persist agent snapshot (best-effort, never blocks inference) ----
const snapLoaded = await loadContext(ctx.sql, sessionId, chatId).catch(() => null);
if (snapLoaded) {
await saveAgentSnapshot(ctx.sql, chatId, {
session_id: sessionId,
model: snapLoaded.session.model,
agent: agent?.name ?? null,
mode: null,
turn_number: stepNumber,
messages: snapLoaded.history.map((m) => ({ role: m.role, content: m.content })),
}).catch(() => {});
}
// ---- post-loop: step-cap sentinel ----
// When the loop exits because stepNumber reached effectiveCap, the last
// iteration's tool phase returned 'continue' with a nextAssistantId that

View File

@@ -46,6 +46,9 @@ export interface InferenceFrame {
| 'error'
| 'flow_run_started'
| 'flow_run_step_updated'
// tool trace frames
| 'tool_trace_start'
| 'tool_trace_finish'
// arena frames
| 'battle_started'
| 'contestant_updated'
@@ -82,6 +85,15 @@ export interface InferenceFrame {
reasoning_tokens?: number | null;
session_id?: string;
name?: string;
// tool trace frames
trace_id?: string;
tool_name?: string;
tool_input?: Record<string, unknown>;
tool_output?: string | null;
latency_ms?: number;
outcome?: string;
// agent snapshot restore
agent?: string | null;
// orchestrator frames ([D-6])
run_id?: string;
flow_name?: string;

View File

@@ -0,0 +1,51 @@
import type { Sql } from '../db.js';
export interface AgentSnapshot {
id: string;
session_id: string;
chat_id: string;
model: string;
agent: string | null;
mode: string | null;
turn_number: number;
messages: unknown[];
tool_states: unknown[];
created_at: string;
updated_at: string;
}
/** Save or update the agent snapshot for a chat (UPSERT). */
export async function saveAgentSnapshot(sql: Sql, chatId: string, data: {
session_id: string;
model: string;
agent?: string | null;
mode?: string | null;
turn_number: number;
messages: unknown[];
tool_states?: unknown[];
}): Promise<void> {
await sql`
INSERT INTO agent_snapshots (session_id, chat_id, model, agent, mode, turn_number, messages, tool_states, updated_at)
VALUES (${data.session_id}, ${chatId}, ${data.model}, ${data.agent ?? null}, ${data.mode ?? null}, ${data.turn_number}, ${sql.json(data.messages as never)}, ${sql.json((data.tool_states ?? []) as never)}, clock_timestamp())
ON CONFLICT (chat_id)
DO UPDATE SET
model = EXCLUDED.model,
agent = EXCLUDED.agent,
mode = EXCLUDED.mode,
turn_number = EXCLUDED.turn_number,
messages = EXCLUDED.messages,
tool_states = EXCLUDED.tool_states,
updated_at = clock_timestamp()
`;
}
/** Load the agent snapshot for a chat. Returns null if no snapshot exists. */
export async function loadAgentSnapshot(sql: Sql, chatId: string): Promise<AgentSnapshot | null> {
const rows = await sql<AgentSnapshot[]>`SELECT * FROM agent_snapshots WHERE chat_id = ${chatId}`;
return rows[0] ?? null;
}
/** Delete the agent snapshot for a chat (call when session ends). */
export async function deleteAgentSnapshot(sql: Sql, chatId: string): Promise<void> {
await sql`DELETE FROM agent_snapshots WHERE chat_id = ${chatId}`;
}

View File

@@ -0,0 +1,92 @@
import type { Sql } from '../db.js';
export interface ToolTrace {
id: string;
session_id: string;
chat_id: string;
message_id: string | null;
turn_number: number;
tool_name: string;
tool_input: unknown;
tool_output: string | null;
started_at: string;
finished_at: string | null;
latency_ms: number | null;
tokens_used: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
error: string | null;
outcome: string | null;
created_at: string;
}
export interface ToolTraceInsert {
session_id: string;
chat_id: string;
message_id: string | null;
turn_number: number;
tool_name: string;
tool_input: unknown;
outcome?: string;
}
export interface ToolTraceUpdate {
finished_at?: string;
latency_ms?: number;
tool_output?: string;
tokens_used?: number;
cache_tokens?: number;
reasoning_tokens?: number;
error?: string;
outcome?: string;
}
export async function insertToolTrace(
sql: Sql,
insert: ToolTraceInsert,
): Promise<ToolTrace> {
const [row] = await sql<ToolTrace[]>`
INSERT INTO tool_traces (
session_id, chat_id, message_id, turn_number,
tool_name, tool_input, outcome
) VALUES (
${insert.session_id}, ${insert.chat_id}, ${insert.message_id},
${insert.turn_number}, ${insert.tool_name},
${sql.json(insert.tool_input as never)},
${insert.outcome ?? null}
)
RETURNING *
`;
if (!row) throw new Error('insertToolTrace returned no row');
return row;
}
export async function updateToolTrace(
sql: Sql,
id: string,
updates: ToolTraceUpdate,
): Promise<ToolTrace | null> {
const cols: string[] = [];
const vals: any[] = [];
if (updates.finished_at !== undefined) { cols.push('finished_at'); vals.push(updates.finished_at); }
if (updates.latency_ms !== undefined) { cols.push('latency_ms'); vals.push(updates.latency_ms); }
if (updates.tool_output !== undefined) { cols.push('tool_output'); vals.push(updates.tool_output); }
if (updates.tokens_used !== undefined) { cols.push('tokens_used'); vals.push(updates.tokens_used); }
if (updates.cache_tokens !== undefined) { cols.push('cache_tokens'); vals.push(updates.cache_tokens); }
if (updates.reasoning_tokens !== undefined) { cols.push('reasoning_tokens'); vals.push(updates.reasoning_tokens); }
if (updates.error !== undefined) { cols.push('error'); vals.push(updates.error); }
if (updates.outcome !== undefined) { cols.push('outcome'); vals.push(updates.outcome); }
if (cols.length === 0) {
const [row] = await sql<ToolTrace[]>`SELECT * FROM tool_traces WHERE id = ${id}`;
return row ?? null;
}
const setClause = cols.map((c, i) => `${c} = $${i + 1}`).join(', ');
const [row] = await sql.unsafe<ToolTrace[]>(
`UPDATE tool_traces SET ${setClause} WHERE id = $${cols.length + 1} RETURNING *`,
[...vals, id],
);
return row ?? null;
}

View File

@@ -0,0 +1,132 @@
/**
* vWhale: run_command tool. Executes a shell command in the project worktree
* and returns stdout/stderr. Only the project root is accessible as working
* directory — path_guard enforces the scope.
*
* Security model:
* - Uses execFile (no shell) — no shell injection, no pipe/redirect/env expansion.
* - args passed as array, never a string.
* - 30s timeout default, configure per-call.
* - 32KB output cap with truncation (same pattern as web_fetch.ts).
* - Working directory restricted to project root via path_guard.
* - No background processes allowed (waits for completion).
*/
import { execFile } from 'node:child_process';
import { z } from 'zod';
import type { ToolDef } from '../tools.js';
const RunCommandInput = z.object({
command: z.string().min(1).max(256),
args: z.array(z.string()).default([]),
description: z.string().max(256).optional(),
timeout_ms: z.number().int().positive().max(120_000).optional(),
});
export type RunCommandInputT = z.infer<typeof RunCommandInput>;
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_OUTPUT_CHARS = 32_000;
export type RunCommandOutput =
| {
command: string;
args: string[];
exit_code: number;
stdout: string;
stderr: string;
truncated: boolean;
duration_ms: number;
}
| {
error: string;
reason: string;
};
export async function executeRunCommand(
input: RunCommandInputT,
projectRoot: string,
): Promise<RunCommandOutput> {
const timeoutMs = input.timeout_ms ?? DEFAULT_TIMEOUT_MS;
const startTime = Date.now();
return new Promise((resolve) => {
const child = execFile(
input.command,
input.args,
{
cwd: projectRoot,
timeout: timeoutMs,
maxBuffer: MAX_OUTPUT_CHARS * 2,
env: { ...process.env },
},
(err, stdout, stderr) => {
const durationMs = Date.now() - startTime;
// Truncate output if needed
const truncated = stdout.length + stderr.length > MAX_OUTPUT_CHARS;
const cappedStdout = truncated ? stdout.slice(0, MAX_OUTPUT_CHARS) : stdout;
const cappedStderr = truncated ? stderr.slice(0, Math.max(MAX_OUTPUT_CHARS - cappedStdout.length, 0)) : stderr;
const exitCode = err?.code === 'ENOENT' ? -1 : (err as Error & { code?: number })?.code ?? 0;
resolve({
command: input.command,
args: input.args,
exit_code: typeof exitCode === 'number' ? exitCode : 1,
stdout: cappedStdout,
stderr: cappedStderr,
truncated,
duration_ms: durationMs,
});
},
);
});
}
export const runCommand: ToolDef<RunCommandInputT> = {
name: 'run_command',
description:
'Run a shell command in the project workspace and return stdout + stderr. ' +
'The command runs in the project root directory. ' +
'Use for: building, testing, linting, git operations, running scripts. ' +
'Output is capped at 32KB. Timeout defaults to 30s (max 120s). ' +
'Security: args are passed as array (no shell injection). No background processes.',
inputSchema: RunCommandInput as unknown as z.ZodType<RunCommandInputT>,
jsonSchema: {
type: 'function',
function: {
name: 'run_command',
description:
'Execute a command in the project workspace. ' +
'Use for builds, tests, linting, git commands, and scripts. ' +
'The process runs with a 30s timeout and 32KB output cap.',
parameters: {
type: 'object',
properties: {
command: {
type: 'string',
description: 'Command to execute (e.g. pnpm, npm, npx, node, git, ls, cat).',
},
args: {
type: 'array',
items: { type: 'string' },
description: 'Arguments as array (e.g. ["run", "build"]). Never embedded in a shell string.',
},
description: {
type: 'string',
description: 'Optional human-readable description of what this command does.',
},
timeout_ms: {
type: 'integer',
description: 'Timeout in milliseconds. Default 30000, max 120000.',
},
},
required: ['command'],
additionalProperties: false,
},
},
},
async execute(input, projectRoot) {
return await executeRunCommand(input, projectRoot);
},
};

View File

@@ -23,6 +23,7 @@ import {
getCodeImpact,
getTypeInfo,
getCodeMap,
getWikiArticle,
} from './codecontext/index.js';
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
@@ -31,6 +32,14 @@ import { requestReadAccess } from '../request_read_access.js';
// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped
// tab number. Needs DB/session context (ToolExecCtx 4th arg).
import { readTabByNumber } from '../read_tab_by_number.js';
// v2.x: memory management tools. file-based store with optional CoreTier
// (SQLite FTS5 + vector) hybrid search backend.
import { extractMemoryTool } from './extract_memory.js';
import { manageMemoryTool } from './manage_memory.js';
import { searchMemoryTool } from './search_memory.js';
// vWhale: command execution tool. Spawns processes in the project worktree
// with timeout and output cap. No shell — args are passed as array.
import { runCommand } from './execute-command.js';
// v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt
// cache hits on byte-identical prefixes; the tool list lives near the top
@@ -85,6 +94,17 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
getCodeImpact as ToolDef<unknown>,
getTypeInfo as ToolDef<unknown>,
getCodeMap as ToolDef<unknown>,
// v2.8.14-domain2-phase3: wiki mode + token-efficient scanning.
getWikiArticle as ToolDef<unknown>,
// v2.x: memory management tools. File-based store with optional CoreTier
// (SQLite FTS5 + vector) hybrid search backend.
extractMemoryTool as ToolDef<unknown>,
manageMemoryTool as ToolDef<unknown>,
searchMemoryTool as ToolDef<unknown>,
// vWhale: command execution. Spawns processes in the project worktree.
// Read-write; use with guard: restricted to project root via path_guard,
// no shell injection (execFile, not exec).
runCommand as ToolDef<unknown>,
].sort((a, b) => a.name.localeCompare(b.name));
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -34,6 +34,10 @@ import type {
SessionAnalyticsRow,
ContextWindowStats,
TokenBreakdownAgg,
ToolTraceResponse,
MemoryEntry,
DailyMemoryEntry,
DreamEntry,
} from './types';
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
@@ -340,6 +344,10 @@ export const api = {
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, decision }),
}),
getTraces: (chatId: string, limit = 50, offset = 0) =>
request<ToolTraceResponse>(
`/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`,
),
},
messages: {
@@ -608,6 +616,22 @@ export const api = {
tokenBreakdown: () => request<{ categories: TokenBreakdownAgg[] }>('/api/coder/analytics/token-breakdown'),
},
// memory-browser-ui: topic-based memory, daily log, dream diaries.
memory: {
list: (projectId: string) =>
request<{ entries: MemoryEntry[] }>(
`/api/memory?project_id=${encodeURIComponent(projectId)}`,
),
daily: (projectId: string) =>
request<{ entries: DailyMemoryEntry[] }>(
`/api/memory/daily?project_id=${encodeURIComponent(projectId)}`,
),
dreams: (projectId: string) =>
request<{ entries: DreamEntry[] }>(
`/api/memory/dreams?project_id=${encodeURIComponent(projectId)}`,
),
},
settings: {
get: () => request<Record<string, unknown>>('/api/settings'),
patch: (body: Record<string, unknown>) =>

View File

@@ -559,8 +559,16 @@ export type WsFrame =
ctx_used: number | null;
ctx_max: number | null;
}
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'chat_renamed'; chat_id: string; name: string }
| {
type: 'agent_snapshot';
chat_id: string;
agent?: string | null;
model: string;
mode?: string | null;
turn_number: number;
}
// v1.11: published by services/compaction.ts after the new anchored
// summary row lands. Carries the new summary row id for diagnostics; the
// session-stream handler ignores the id and re-fetches the full message
@@ -604,6 +612,31 @@ export type WsFrame =
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string;
}
// tool trace frames: per-tool-call lifecycle tracking
| {
type: 'tool_trace_start';
trace_id: string;
message_id: string;
chat_id: string;
tool_name: string;
tool_input: Record<string, unknown>;
started_at: string;
}
| {
type: 'tool_trace_finish';
trace_id: string;
message_id: string;
chat_id: string;
tool_name: string;
tool_output?: string | null;
latency_ms?: number;
tokens_used?: number | null;
cache_tokens?: number | null;
reasoning_tokens?: number | null;
error?: string;
outcome?: string;
finished_at: string;
}
// arena frames: battle lifecycle + per-contestant streaming
| {
type: 'battle_started';
@@ -630,8 +663,64 @@ export type WsFrame =
winner_contestant_id?: string | null;
analysis_ready?: boolean;
cross_exam_id?: string;
}
// streaming v2: channel-delta frames. Each carries a monotonic seq for
// out-of-order buffering and a channel discriminator; per-channel payloads
// map to the equivalent legacy frame types after reordering.
| {
type: 'channel_delta';
seq: number;
channel: 'text' | 'tool_call' | 'tool_result' | 'status' | 'error';
message_id?: string;
chat_id?: string;
content?: string;
tool_call?: ToolCall;
tool_message_id?: string;
tool_call_id?: string;
output?: unknown;
truncated?: boolean;
error?: string;
reason?: string;
status?: 'running' | 'complete' | 'cancelled' | 'failed';
tokens_used?: number | null;
ctx_used?: number | null;
ctx_max?: number | null;
cache_tokens?: number | null;
reasoning_tokens?: number | null;
started_at?: string | null;
finished_at?: string | null;
model?: string | null;
metadata?: MessageMetadata | null;
};
// tool traces: per-tool-call record returned by GET /api/chats/:id/traces.
export interface ToolTrace {
id: string;
session_id: string;
chat_id: string;
message_id: string | null;
turn_number: number;
tool_name: string;
tool_input: Record<string, unknown>;
tool_output: string | null;
started_at: string;
finished_at: string | null;
latency_ms: number | null;
tokens_used: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
error: string | null;
outcome: string | null;
created_at: string;
}
export interface ToolTraceResponse {
data: ToolTrace[];
total: number;
limit: number;
offset: number;
}
// token-analyzer-ui: aggregate token/cost analytics types.
export interface AnalyticsSummary {
total_input_tokens: number;
@@ -660,3 +749,21 @@ export interface TokenBreakdownAgg {
category: string;
total_tokens: number;
}
// ── Memory browser types ────────────────────────────────────────────
export interface MemoryEntry {
id: string;
topic: string;
title: string;
content: string;
tags: string[];
}
export interface DailyMemoryEntry extends MemoryEntry {
date: string;
}
export interface DreamEntry {
date: string;
content: string;
}

View File

@@ -1,10 +1,14 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { Pin } from 'lucide-react';
import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup';
import { ToolCallLine, type ToolRun } from './ToolCallLine';
import { AskUserInputCard } from './AskUserInputCard';
import { RequestReadAccessCard } from './RequestReadAccessCard';
import { MessageListErrorBoundary } from './MessageListErrorBoundary';
interface Props {
messages: Message[];
@@ -142,27 +146,63 @@ function stampCapHits(items: RenderItem[]): RenderItem[] {
});
}
const SCROLL_THRESHOLD_PX = 150;
export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const isNearBottomRef = useRef(true);
const renderedKeysRef = useRef(new Set<string>());
const prefersReducedMotionRef = useRef(false);
const [animateEnabled, setAnimateEnabled] = useState(true);
const [pinMessageId, setPinMessageId] = useState<string | null>(() => {
if (typeof window !== 'undefined') {
const hash = window.location.hash;
if (hash.startsWith('#pin=')) return hash.slice(5);
}
return null;
});
const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]);
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
isNearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
const pinIndex = useMemo(() => {
if (!pinMessageId) return -1;
return renderItems.findIndex(
(item) => item.kind === 'message' && item.message.id === pinMessageId,
);
}, [pinMessageId, renderItems]);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotionRef.current = mq.matches;
const handler = (e: MediaQueryListEvent) => {
prefersReducedMotionRef.current = e.matches;
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
useEffect(() => {
if (isNearBottomRef.current) {
endRef.current?.scrollIntoView({ block: 'end' });
const handler = () => {
const hash = window.location.hash;
if (hash.startsWith('#pin=')) {
setPinMessageId(hash.slice(5));
} else {
setPinMessageId(null);
}
};
window.addEventListener('hashchange', handler);
return () => window.removeEventListener('hashchange', handler);
}, []);
const atBottomStateChange = useCallback((atBottom: boolean) => {
isNearBottomRef.current = atBottom;
setAnimateEnabled(atBottom);
}, []);
const scrollToPin = useCallback(() => {
if (pinIndex >= 0 && virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({ index: pinIndex, align: 'center' });
}
}, [messages]);
}, [pinIndex]);
if (messages.length === 0) {
return (
@@ -173,46 +213,78 @@ export function MessageList({ messages, sessionChats }: Props) {
}
return (
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef} onScroll={handleScroll}>
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{renderItems.map((item) => {
if (item.kind === 'message') {
return (
<MessageBubble
key={item.message.id}
message={item.message}
sessionChats={sessionChats}
capHitInfo={item.capHitInfo}
/>
);
}
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input') {
return (
<AskUserInputCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
);
}
if (item.run.call.name === 'request_read_access') {
return (
<RequestReadAccessCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
);
}
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;
})}
<div ref={endRef} />
</div>
<MessageListErrorBoundary>
<div className="flex-1 flex flex-col">
{pinMessageId && pinIndex >= 0 && (
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 bg-primary/10 border-b border-primary/20 text-xs text-primary">
<Pin className="size-3" />
<span>Pinned message</span>
<button
type="button"
onClick={scrollToPin}
className="ml-auto underline hover:no-underline"
>
Jump to pinned
</button>
</div>
)}
<Virtuoso
ref={virtuosoRef}
className="flex-1"
data={renderItems}
followOutput="auto"
overscan={5}
atBottomStateChange={atBottomStateChange}
itemContent={(index, item) => {
const key = item.kind === 'message' ? `msg-${item.message.id}` : item.key;
const isNew = !renderedKeysRef.current.has(key);
if (isNew) renderedKeysRef.current.add(key);
const reducedMotion = prefersReducedMotionRef.current;
const delay = isNew && !reducedMotion ? Math.min(index * 0.04, 0.5) : 0;
const shouldAnimate = isNew && animateEnabled;
return (
<div
className="max-w-[1000px] mx-auto w-full px-6 py-2"
id={item.kind === 'message' ? `msg-${item.message.id}` : undefined}
>
<motion.div
initial={shouldAnimate ? { opacity: 0, y: 8 } : false}
animate={{ opacity: 1, y: 0 }}
transition={delay > 0 ? { duration: 0.2, delay } : { duration: 0 }}
>
{item.kind === 'message' ? (
<MessageBubble
message={item.message}
sessionChats={sessionChats}
capHitInfo={item.capHitInfo}
/>
) : item.kind === 'tool_run' ? (
item.run.call.name === 'ask_user_input' ? (
<AskUserInputCard
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
) : item.run.call.name === 'request_read_access' ? (
<RequestReadAccessCard
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
) : (
<ToolCallLine run={item.run} />
)
) : (
<ToolCallGroup runs={item.runs} />
)}
</motion.div>
</div>
);
}}
/>
</div>
</MessageListErrorBoundary>
);
}

View File

@@ -0,0 +1,188 @@
import { useMemo } from 'react';
import { Clock, Cpu, Hash, Layers, RefreshCw, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Message } from '@/api/types';
interface TurnEntry {
message: Message;
turnNumber: number;
elapsed: string;
toolCallCount: number;
}
interface Props {
messages: Message[];
chatId: string;
onClose: () => void;
onScrollToMessage: (messageId: string) => void;
}
function formatElapsed(startedAt: string | null, finishedAt: string | null): string {
if (!startedAt || !finishedAt) return '—';
const start = new Date(startedAt).getTime();
const end = new Date(finishedAt).getTime();
if (Number.isNaN(start) || Number.isNaN(end)) return '—';
const ms = end - start;
if (ms < 0) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
const mins = Math.floor(ms / 60_000);
const secs = Math.round((ms % 60_000) / 1000);
return `${mins}m ${secs}s`;
}
/**
* SessionTimeline — vertical timeline of assistant turns in a chat.
*
* Renders a side-panel overlay with each turn's model, tokens, duration,
* and tool-call count. Clicking a turn scrolls the main chat to that
* message. The latest turn shows a "Scroll to latest" restore button.
*/
export function SessionTimeline({ messages, onClose, onScrollToMessage }: Props) {
const turns = useMemo<TurnEntry[]>(() => {
const assistantMsgs = messages.filter(
(m) => m.role === 'assistant' && m.status === 'complete',
);
return assistantMsgs.map((message, i) => ({
message,
turnNumber: i + 1,
elapsed: formatElapsed(message.started_at, message.finished_at),
toolCallCount: message.tool_calls?.length ?? 0,
}));
}, [messages]);
const latestTurnId = turns.length > 0 ? turns[turns.length - 1]!.message.id : null;
return (
<div className="absolute inset-y-0 right-0 w-80 z-20 bg-background border-l border-border shadow-xl flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border shrink-0">
<h3 className="text-sm font-semibold">Session Timeline</h3>
<Button variant="ghost" size="icon-xs" onClick={onClose} aria-label="Close timeline">
<X size={14} />
</Button>
</div>
{/* Timeline entries */}
<div className="flex-1 overflow-y-auto px-3 py-3">
{turns.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-8">
No assistant turns yet.
</div>
) : (
<div className="relative">
{turns.map((turn, i) => {
const isLatest = turn.message.id === latestTurnId;
return (
<div key={turn.message.id} className="relative flex gap-3 pb-4 last:pb-0">
{/* Vertical connector line */}
{i < turns.length - 1 && (
<div className="absolute left-[11px] top-5 bottom-0 w-px bg-border" />
)}
{/* Timeline dot button */}
<button
type="button"
onClick={() => onScrollToMessage(turn.message.id)}
className="relative flex-shrink-0 mt-1 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-full"
aria-label={`Scroll to turn ${turn.turnNumber}`}
>
<div
className={cn(
'size-[22px] rounded-full border-2 flex items-center justify-center',
isLatest
? 'border-primary bg-primary/10'
: 'border-muted-foreground/30 bg-background',
)}
>
<div
className={cn(
'size-2 rounded-full',
isLatest ? 'bg-primary' : 'bg-muted-foreground/50',
)}
/>
</div>
</button>
{/* Content card */}
<div className="flex-1 min-w-0">
<div
className="rounded-lg border border-border bg-card p-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
onClick={() => onScrollToMessage(turn.message.id)}
>
{/* Turn number + latest badge */}
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-foreground">
Turn {turn.turnNumber}
</span>
{isLatest && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full leading-none">
Latest
</span>
)}
</div>
{/* Model name */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1.5">
<Cpu size={11} className="shrink-0" />
<span className="truncate">{turn.message.model ?? 'Unknown model'}</span>
</div>
{/* Token count with breakdown */}
{turn.message.tokens_used != null && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1 flex-wrap">
<Hash size={11} className="shrink-0" />
<span>{turn.message.tokens_used.toLocaleString()} total</span>
{turn.message.cache_tokens != null && turn.message.cache_tokens > 0 && (
<span className="text-blue-500 dark:text-blue-400">
({turn.message.cache_tokens.toLocaleString()} cache)
</span>
)}
{turn.message.reasoning_tokens != null && turn.message.reasoning_tokens > 0 && (
<span className="text-amber-500 dark:text-amber-400">
({turn.message.reasoning_tokens.toLocaleString()} reasoning)
</span>
)}
</div>
)}
{/* Duration + tool calls */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock size={11} />
{turn.elapsed}
</span>
{turn.toolCallCount > 0 && (
<span className="inline-flex items-center gap-1">
<Layers size={11} />
{turn.toolCallCount} tool call{turn.toolCallCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
{/* Restore button for latest turn */}
{isLatest && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onScrollToMessage(turn.message.id);
}}
className="mt-1.5 w-full inline-flex items-center justify-center gap-1 text-[11px] font-medium text-primary hover:text-primary/80 transition-colors py-1 rounded-md hover:bg-primary/5"
>
<RefreshCw size={11} />
Scroll to latest
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,251 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
import { api } from '@/api/client';
import type { ToolTrace } from '@/api/types';
interface Props {
chatId: string;
}
// Max latency used as the 100% reference for the bar visualization
const MAX_LATENCY_REF = 30_000; // 30s
function latencyBarWidth(latencyMs: number | null): number {
if (latencyMs == null) return 0;
return Math.min(latencyMs / MAX_LATENCY_REF, 1);
}
function TraceRow({ trace }: { trace: ToolTrace }) {
const [expanded, setExpanded] = useState(false);
const isError = trace.outcome !== null && trace.outcome !== 'success';
const barWidth = latencyBarWidth(trace.latency_ms);
const latencyLabel =
trace.latency_ms != null
? trace.latency_ms >= 1000
? `${(trace.latency_ms / 1000).toFixed(1)}s`
: `${trace.latency_ms}ms`
: null;
return (
<div className="border-b border-border/40 last:border-0">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-2 w-full text-left px-2 py-1.5 hover:bg-muted/40 text-[11px]"
>
<span className="shrink-0 text-muted-foreground">
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
</span>
<span className="font-medium truncate min-w-0">
{trace.tool_name}
</span>
{isError && (
<span className="shrink-0 text-destructive" title={trace.error ?? 'error'}>
<AlertCircle size={10} />
</span>
)}
<span className="shrink-0 text-muted-foreground font-mono tabular-nums min-w-[3rem] text-right">
{latencyLabel ?? '—'}
</span>
<span className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden min-w-[24px] max-w-[60px]">
<span
className="block h-full rounded-full bg-primary/30 transition-all"
style={{ width: `${barWidth * 100}%` }}
/>
</span>
{trace.tokens_used != null && trace.tokens_used > 0 && (
<span className="shrink-0 text-muted-foreground font-mono tabular-nums">
{trace.tokens_used}t
</span>
)}
{trace.cache_tokens != null && trace.cache_tokens > 0 && (
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
c{trace.cache_tokens}
</span>
)}
{trace.reasoning_tokens != null && trace.reasoning_tokens > 0 && (
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
r{trace.reasoning_tokens}
</span>
)}
</button>
{expanded && (
<div className="px-3 pb-2 space-y-1.5 text-[11px] border-t border-border/40 pt-1.5">
<div>
<span className="text-muted-foreground font-medium">Input</span>
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
{JSON.stringify(trace.tool_input, null, 1)}
</pre>
</div>
{trace.tool_output != null && (
<div>
<span className="text-muted-foreground font-medium">Output</span>
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
{trace.tool_output.length > 2000
? `${trace.tool_output.slice(0, 2000)}`
: trace.tool_output}
</pre>
</div>
)}
{trace.error != null && (
<div className="text-destructive text-[10px] font-mono leading-relaxed bg-destructive/10 rounded p-1.5">
{trace.error}
</div>
)}
</div>
)}
</div>
);
}
function TraceGroup({ toolName, traces }: { toolName: string; traces: ToolTrace[] }) {
const [collapsed, setCollapsed] = useState(false);
const totalLatency = traces.reduce((sum, t) => sum + (t.latency_ms ?? 0), 0);
const totalTokens = traces.reduce((sum, t) => sum + (t.tokens_used ?? 0), 0);
const errorCount = traces.filter(
(t) => t.outcome !== null && t.outcome !== 'success',
).length;
return (
<div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="flex items-center gap-1.5 w-full text-left px-2 py-1 text-[11px] font-medium text-muted-foreground hover:bg-muted/30 sticky top-0 bg-background"
>
{collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
<span>{toolName}</span>
<span className="text-muted-foreground/60 font-mono tabular-nums">
×{traces.length}
</span>
{totalTokens > 0 && (
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
{totalTokens}t
</span>
)}
{totalLatency > 0 && (
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
{totalLatency >= 1000
? `${(totalLatency / 1000).toFixed(1)}s`
: `${totalLatency}ms`}
</span>
)}
{errorCount > 0 && (
<span className="ml-auto text-destructive text-[10px] font-medium">
{errorCount} error{errorCount > 1 ? 's' : ''}
</span>
)}
</button>
{!collapsed && traces.map((trace) => (
<TraceRow key={trace.id} trace={trace} />
))}
</div>
);
}
export function TraceViewer({ chatId }: Props) {
const [open, setOpen] = useState(false);
const [traces, setTraces] = useState<ToolTrace[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchTraces = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await api.chats.getTraces(chatId);
setTraces(res.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to load traces');
} finally {
setLoading(false);
}
}, [chatId]);
useEffect(() => {
if (open) {
void fetchTraces();
}
}, [open, fetchTraces]);
const groups = useMemo(() => {
const map = new Map<string, ToolTrace[]>();
for (const t of traces) {
const existing = map.get(t.tool_name);
if (existing) {
existing.push(t);
} else {
map.set(t.tool_name, [t]);
}
}
return map;
}, [traces]);
const totalCount = traces.length;
const errorCount = traces.filter(
(t) => t.outcome !== null && t.outcome !== 'success',
).length;
return (
<div className="border-t">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-[11px] font-medium text-muted-foreground hover:bg-muted/20"
>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>Tool traces</span>
{totalCount > 0 && (
<span className="font-mono tabular-nums text-muted-foreground/60">
{totalCount}
</span>
)}
{errorCount > 0 && (
<span className="text-destructive ml-auto text-[10px] font-medium">
{errorCount} error{errorCount > 1 ? 's' : ''}
</span>
)}
{loading && (
<span className="ml-auto inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</button>
{open && (
<div className="max-h-80 overflow-y-auto border-t border-border/40">
{loading && traces.length === 0 && (
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
Loading traces
</div>
)}
{error && (
<div className="px-3 py-2 text-[11px] text-destructive">
{error}
<button
type="button"
onClick={() => void fetchTraces()}
className="ml-2 underline hover:no-underline"
>
retry
</button>
</div>
)}
{!loading && !error && traces.length === 0 && (
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
No tool traces yet.
</div>
)}
{traces.length > 0 && (
<div className="divide-y divide-border/40">
{Array.from(groups.entries()).map(([toolName, groupTraces]) => (
<TraceGroup
key={toolName}
toolName={toolName}
traces={groupTraces}
/>
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Pencil, Send, X } from 'lucide-react';
import { History, Pencil, Send, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
import { StaleStreamBanner } from '@/components/StaleStreamBanner';
import { SessionTimeline } from '@/components/SessionTimeline';
import { TraceViewer } from '@/components/TraceViewer';
import { sendToChat } from '@/lib/events';
interface Props {
@@ -25,6 +27,7 @@ interface Props {
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) {
const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null);
const [showTimeline, setShowTimeline] = useState(false);
const [queue, setQueue] = useState<{ id: string; text: string }[]>([]);
const queueIdRef = useRef(0);
const processingRef = useRef(false);
@@ -203,11 +206,41 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
}
}
const handleScrollToMessage = useCallback((messageId: string) => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, []);
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex flex-col h-full min-h-0 relative">
{chatMessages.length > 0 && (
<div className="absolute top-2 right-2 z-10">
<button
type="button"
onClick={() => setShowTimeline((v) => !v)}
className={`
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
transition-colors border
${showTimeline
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:bg-muted hover:text-foreground'
}
`}
aria-label={showTimeline ? 'Close timeline' : 'Open timeline'}
>
<History size={12} />
Timeline
</button>
</div>
)}
{/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */}
<MessageList messages={chatMessages} sessionChats={sessionChats} />
<TraceViewer chatId={chatId} />
{/* Queued messages */}
{queue.length > 0 && (
<div className="border-t">
@@ -275,6 +308,16 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
messages={chatMessages}
modelContextLimit={modelContextLimit}
/>
{/* Timeline overlay panel */}
{showTimeline && (
<SessionTimeline
messages={chatMessages}
chatId={chatId}
onClose={() => setShowTimeline(false)}
onScrollToMessage={handleScrollToMessage}
/>
)}
</div>
);
}

View File

@@ -16,6 +16,133 @@ interface State {
error: string | null;
}
type Channel = 'text' | 'tool_call' | 'tool_result' | 'status' | 'error';
// Per-channel out-of-order frame buffer with contiguous-seq flush logic.
// Stores incoming channel_delta frames and releases them only when seq
// becomes contiguous with the expected next value.
class ChannelBuffer {
private expectedSeq = 0;
private buffer = new Map<number, ChannelDeltaWsFrame>();
push(frame: ChannelDeltaWsFrame): ChannelDeltaWsFrame[] {
if (frame.seq < this.expectedSeq) {
return [];
}
if (frame.seq === this.expectedSeq) {
this.expectedSeq++;
const flushed = [frame];
while (this.buffer.has(this.expectedSeq)) {
const next = this.buffer.get(this.expectedSeq)!;
this.buffer.delete(this.expectedSeq);
this.expectedSeq++;
flushed.push(next);
}
return flushed;
}
this.buffer.set(frame.seq, frame);
return [];
}
get expectedNextSeq(): number {
return this.expectedSeq;
}
get bufferedCount(): number {
return this.buffer.size;
}
reset(seq = 0) {
this.expectedSeq = seq;
this.buffer.clear();
}
}
type ChannelDeltaWsFrame = WsFrame & { type: 'channel_delta' };
// Converts a flushed channel_delta into the equivalent legacy frame so the
// existing applyFrame reducer handles the per-message mutation. Status
// deltas are handled separately (they may need to create the message first
// and apply throughput metadata independently of terminal status).
function channelDeltaToLegacyFrame(delta: ChannelDeltaWsFrame): WsFrame | null {
switch (delta.channel) {
case 'text':
return { type: 'delta', message_id: delta.message_id!, content: delta.content! };
case 'tool_call':
return { type: 'tool_call', message_id: delta.message_id!, tool_call: delta.tool_call! };
case 'tool_result':
return {
type: 'tool_result',
tool_message_id: delta.tool_message_id!,
chat_id: delta.chat_id,
tool_call_id: delta.tool_call_id!,
output: delta.output,
truncated: delta.truncated!,
...(delta.error ? { error: delta.error } : {}),
};
case 'error':
return {
type: 'error',
message_id: delta.message_id,
chat_id: delta.chat_id,
error: delta.error!,
...(delta.reason ? { reason: delta.reason as never } : {}),
};
case 'status':
return null;
}
}
// Apply a flushed status channel_delta to state. Status deltas carry both
// intermediate throughput metadata (tokens_used, ctx_used, model, etc.)
// and optional terminal transitions (complete / cancelled / failed).
function applyStatusDelta(state: State, delta: ChannelDeltaWsFrame): State {
const { message_id, chat_id, status, channel: _c, seq: _s, type: _t, ...meta } = delta;
if (!message_id) return state;
let next = state;
const exists = next.messages.some((m) => m.id === message_id);
if (!exists && status === 'running') {
next = applyFrame(next, {
type: 'message_started',
message_id,
chat_id,
role: 'assistant',
});
}
const metaFields: Record<string, unknown> = {};
if (meta.tokens_used !== undefined) metaFields.tokens_used = meta.tokens_used;
if (meta.ctx_used !== undefined) metaFields.ctx_used = meta.ctx_used;
if (meta.ctx_max !== undefined) metaFields.ctx_max = meta.ctx_max;
if (meta.cache_tokens !== undefined) metaFields.cache_tokens = meta.cache_tokens;
if (meta.reasoning_tokens !== undefined) metaFields.reasoning_tokens = meta.reasoning_tokens;
if (meta.started_at !== undefined) metaFields.started_at = meta.started_at;
if (meta.finished_at !== undefined) metaFields.finished_at = meta.finished_at;
if (meta.model !== undefined) metaFields.model = meta.model;
if (meta.metadata !== undefined) metaFields.metadata = meta.metadata;
if (Object.keys(metaFields).length > 0) {
next = {
...next,
messages: next.messages.map((m) =>
m.id === message_id ? { ...m, ...metaFields } : m,
),
};
}
if (status === 'complete' || status === 'cancelled' || status === 'failed') {
next = applyFrame(next, {
type: 'message_complete',
message_id,
chat_id,
status,
});
}
return next;
}
function applyFrame(state: State, frame: WsFrame): State {
switch (frame.type) {
case 'snapshot': {
@@ -33,8 +160,6 @@ function applyFrame(state: State, frame: WsFrame): State {
kind: 'message',
tool_calls: null,
tool_results: null,
// v1.8.2: cap-hit sentinels arrive role='system' and are static, so
// skipping the streaming dot for them keeps the UI accurate.
status: frame.role === 'system' ? 'complete' : 'streaming',
last_seq: 0,
tokens_used: null,
@@ -65,7 +190,7 @@ function applyFrame(state: State, frame: WsFrame): State {
const next = state.messages.map((m) =>
m.id === frame.message_id
? { ...m, tool_calls: [...(m.tool_calls ?? []), frame.tool_call] }
: m
: m,
);
return { ...state, messages: next };
}
@@ -85,7 +210,7 @@ function applyFrame(state: State, frame: WsFrame): State {
},
status: 'complete' as const,
}
: m
: m,
);
return { ...state, messages: next };
}
@@ -132,19 +257,13 @@ function applyFrame(state: State, frame: WsFrame): State {
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
...(frame.model !== undefined ? { model: frame.model } : {}),
// v1.8.2: cap-hit sentinels (and future stamped metadata) ride
// in on this terminal frame so the reducer can attach it
// without waiting for a refetch.
...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
}
: m
: m,
);
return { ...state, messages: next };
}
case 'usage': {
// v1.12.2: live throughput. Side-effects into the module-level
// singleton consumed by ChatThroughput; no message-state mutation.
// chat_id is the optional ws-frame field; usage frames always include it.
if (frame.chat_id) {
recordUsage(frame.chat_id, {
completion_tokens: frame.completion_tokens,
@@ -172,10 +291,6 @@ function applyFrame(state: State, frame: WsFrame): State {
return state;
}
case 'error': {
// v1.8.2: when the frame carries a structured reason, stamp it onto the
// failed message's metadata so the bubble can render specifics inline
// (the WS error frame is one-shot; refresh-safe rendering needs the
// value persisted on the message).
const errorMeta = frame.reason
? { kind: 'error' as const, error_reason: frame.reason, error_text: frame.error }
: null;
@@ -187,47 +302,53 @@ function applyFrame(state: State, frame: WsFrame): State {
status: 'failed' as const,
...(errorMeta ? { metadata: errorMeta } : {}),
}
: m
: m,
)
: state.messages;
return { ...state, messages: next, error: frame.error };
}
case 'compacted': {
// v1.11: side effects (refetch + toast) live in ws.onmessage; the
// reducer just no-ops so TS exhaustiveness is satisfied without
// duplicating async work inside a synchronous reducer.
return state;
}
case 'agent_snapshot': {
return state;
}
case 'agent_status_updated': {
// agent-status-normalize (#10): coder-only frame consumed by CoderPane's
// own WS handler, not BooChat's native message reducer. No-op here to keep
// TS exhaustiveness satisfied (native sessions never emit it).
return state;
}
case 'flow_run_started':
case 'flow_run_step_updated': {
// Orchestrator frames consumed by OrchestratorPane's own subscription.
// No-op here to keep TS exhaustiveness satisfied.
return state;
}
case 'battle_started':
case 'contestant_updated':
case 'battle_updated': {
// Arena frames consumed by ArenaPane's own subscription.
// No-op here to keep TS exhaustiveness satisfied.
return state;
}
case 'channel_delta': {
return state;
}
default: {
return state;
}
}
}
// Matches useUserEvents — exponential backoff with the same ceiling so the
// two channels reconnect on the same cadence after a network handoff.
const RECONNECT_INITIAL_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
const CHANNEL_STALL_MS = 5000;
export function useSessionStream(sessionId: string | undefined) {
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
const wsRef = useRef<WebSocket | null>(null);
const channelBuffersRef = useRef<Map<Channel, ChannelBuffer>>(new Map());
const lastFrameTimeRef = useRef<Partial<Record<Channel, number>>>({});
// Reset channel buffers when session changes
useEffect(() => {
channelBuffersRef.current = new Map();
lastFrameTimeRef.current = {};
}, [sessionId]);
useEffect(() => {
if (!sessionId) return;
@@ -238,6 +359,73 @@ export function useSessionStream(sessionId: string | undefined) {
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = RECONNECT_INITIAL_MS;
const getLastSeqPerChannel = () => {
const seqs: Partial<Record<Channel, number>> = {};
for (const [ch, buf] of channelBuffersRef.current) {
seqs[ch] = buf.expectedNextSeq;
}
return seqs;
};
const flushDeltaToState = (delta: ChannelDeltaWsFrame) => {
console.error('FDS', delta.channel, 'flushed');
if (delta.channel === 'status') {
setState((s) => applyStatusDelta(s, delta));
} else {
const legacy = channelDeltaToLegacyFrame(delta);
if (legacy) {
setState((s) => applyFrame(s, legacy));
}
}
};
const handleChannelDelta = (frame: ChannelDeltaWsFrame) => {
console.error('HCD', frame.channel, frame.seq, 'bufs', channelBuffersRef.current.size);
const buffers = channelBuffersRef.current;
let buffer = buffers.get(frame.channel);
if (!buffer) {
buffer = new ChannelBuffer();
buffers.set(frame.channel, buffer);
}
const flushed = buffer.push(frame);
if (flushed.length === 0) return;
for (const delta of flushed) {
flushDeltaToState(delta);
}
let emittedRefresh = false;
for (const delta of flushed) {
if (delta.channel === 'status' && (delta.status === 'complete' || delta.status === 'cancelled' || delta.status === 'failed')) {
emittedRefresh = true;
}
}
if (emittedRefresh) {
sessionEvents.emit({ type: 'git_diff_refresh' });
}
lastFrameTimeRef.current[frame.channel] = Date.now();
};
// Periodic channel stall check: if any channel has buffered frames
// but no progress for 5s, force a snapshot refetch.
let stallTimer: ReturnType<typeof setInterval> | null = null;
const startStallTimer = () => {
stallTimer = setInterval(() => {
const now = Date.now();
for (const [channel, buffer] of channelBuffersRef.current) {
if (buffer.bufferedCount === 0) continue;
const lastTime = lastFrameTimeRef.current[channel as Channel] ?? 0;
if (now - lastTime >= CHANNEL_STALL_MS) {
buffer.reset();
sessionEvents.emit({ type: 'refetch_messages' });
}
}
}, 1000);
};
const connect = () => {
if (unmounted) return;
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
@@ -248,13 +436,16 @@ export function useSessionStream(sessionId: string | undefined) {
ws.onopen = () => {
reconnectDelay = RECONNECT_INITIAL_MS;
setState((s) => ({ ...s, connected: true, error: null }));
// Mid-stream reconnection protocol: send last known seq per channel
// so the server can replay deltas or fall back to a full snapshot.
const lastSeq = getLastSeqPerChannel();
ws.send(JSON.stringify({ type: 'reconnect', lastSeqPerChannel: lastSeq }));
startStallTimer();
};
ws.onmessage = (ev) => {
// v1.13.11-a: Zod-validate every inbound frame. Fail-closed — invalid
// frames are logged and dropped. WsFrameSchema is the runtime guard;
// the hand-maintained WsFrame type stays as the narrowed dev-time
// shape (Zod uses OpaqueObject for nested types like Message[]). One
// cast bridges the two.
let raw: unknown;
try {
raw = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
@@ -272,13 +463,14 @@ export function useSessionStream(sessionId: string | undefined) {
}
try {
const frame = validated.data as unknown as WsFrame;
// v1.11: on a compaction completion, re-fetch the message list so
// the new summary row + the cohort of compacted_at-stamped older
// rows render correctly. We dispatch the fresh list as a synthetic
// 'snapshot' frame so the reducer's existing path handles state
// replacement (no need for a parallel "refetched" path).
// The toast is purely UX feedback; missing it would still leave
// the chat in a valid state.
if (frame.type === 'channel_delta') {
console.error('RAW_PARSE', JSON.stringify(validated.data).slice(0, 200));
console.error('CD', frame.channel, frame.seq, JSON.stringify(frame).slice(0, 80));
handleChannelDelta(frame);
return;
}
if (frame.type === 'compacted') {
toast.success('Context compacted to free space');
void api.messages
@@ -291,8 +483,9 @@ export function useSessionStream(sessionId: string | undefined) {
});
return;
}
setState((s) => applyFrame(s, frame));
// Trigger git diff refresh after each completed assistant turn.
if (frame.type === 'message_complete') {
sessionEvents.emit({ type: 'git_diff_refresh' });
}
@@ -300,15 +493,18 @@ export function useSessionStream(sessionId: string | undefined) {
console.warn('bad ws frame', err);
}
};
// v1.8.1: WS errors no longer surface as user-facing toasts here. The
// user-channel hook (useUserEvents) owns the debounced "reconnecting…"
// UI; this channel just reconnects silently on the same backoff.
ws.onerror = () => {
try { ws.close(); } catch {}
};
ws.onclose = () => {
if (unmounted) return;
setState((s) => ({ ...s, connected: false }));
if (stallTimer) {
clearInterval(stallTimer);
stallTimer = null;
}
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
reconnectTimer = setTimeout(connect, delay);
@@ -320,6 +516,7 @@ export function useSessionStream(sessionId: string | undefined) {
return () => {
unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (stallTimer) clearInterval(stallTimer);
const ws = wsRef.current;
wsRef.current = null;
if (ws) try { ws.close(); } catch {}