From d05f73be26b1c0831aef826d8dad3318aa87f766 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 31 May 2026 02:14:42 +0000 Subject: [PATCH] feat(server): workspace_panes envelope + read_tab_by_number tool Widen the sessions.workspace_panes JSONB from a bare WorkspacePane[] to a WorkspaceState envelope { panes, tabNumbers, nextTabNumber, closedPaneStack }. The PATCH validator accepts either the legacy array or the envelope (zod union) and normalizes to a full envelope before storing, so existing array-shaped rows migrate transparently on next write. The session_workspace_updated WS frame schema is widened to match (kept byte-identical to the web copy; parity test passes). Adds read_tab_by_number, a read-only tool that resolves a session-scoped tab number to its chat via the persisted tabNumbers map and returns that chat's transcript (oldest-first, sentinels skipped, capped at 20k chars). Tools gain an optional ToolExecCtx ({ sql, sessionId }) 4th param on ToolDef.execute, threaded through executeToolCall from executeToolPhase; the param is optional so existing filesystem tools and the apps/coder consumer stay compatible. Registered in ALL_TOOLS + READ_ONLY_TOOL_NAMES. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/routes/sessions.ts | 55 +++++-- .../src/services/inference/tool-phase.ts | 9 +- .../server/src/services/read_tab_by_number.ts | 142 ++++++++++++++++++ apps/server/src/services/tools.ts | 30 +++- apps/server/src/types/ws-frames.ts | 7 +- 5 files changed, 226 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/services/read_tab_by_number.ts diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index a2fc66a..dbc24f3 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -28,18 +28,20 @@ const HtmlArtifactStateZ = z.object({ title: z.string().max(500), }); +const PaneKindZ = z.enum([ + 'chat', + 'terminal', + 'coder', + 'agent', // legacy alias — normalized to coder on write + 'empty', + 'settings', + 'markdown_artifact', + 'html_artifact', +]); + const WorkspacePaneZ = z.object({ id: z.string().min(1).max(200), - kind: z.enum([ - 'chat', - 'terminal', - 'coder', - 'agent', // legacy alias — normalized to coder on write - 'empty', - 'settings', - 'markdown_artifact', - 'html_artifact', - ]), + kind: PaneKindZ, chatId: z.string().min(1).max(200).optional(), chatIds: z.array(z.string().min(1).max(200)).max(50), activeChatIdx: z.number().int(), @@ -47,8 +49,27 @@ const WorkspacePaneZ = z.object({ html_artifact_state: HtmlArtifactStateZ.optional(), }); +// v2.6.x: workspace_panes column widened from a bare WorkspacePane[] to a +// WorkspaceState envelope (panes + stable session-scoped tab numbering + +// reopen stack). closedPaneStack entries are lighter than full panes — just +// the kind + chat ids needed to recreate a closed pane on reopen. +const ClosedPaneEntryZ = z.object({ + kind: PaneKindZ, + chatIds: z.array(z.string().min(1).max(200)).max(50), + activeChatIdx: z.number().int(), +}); + +const WorkspaceStateZ = z.object({ + panes: z.array(WorkspacePaneZ).max(10), + tabNumbers: z.record(z.string(), z.number().int()).default({}), + nextTabNumber: z.number().int().default(1), + closedPaneStack: z.array(ClosedPaneEntryZ).max(10).default([]), +}); + +// Accept either the legacy bare array OR the envelope. The handler normalizes +// to a full envelope before storing (see MIGRATION rule in the PATCH handler). const WorkspacePanesBody = z.object({ - workspace_panes: z.array(WorkspacePaneZ).max(10), + workspace_panes: z.union([z.array(WorkspacePaneZ).max(10), WorkspaceStateZ]), }); const PatchBody = z.object({ @@ -308,12 +329,20 @@ export function registerSessionRoutes( reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } - const workspacePanes = parsed.data.workspace_panes.map((pane) => + // v2.6.x MIGRATION: the body is either a legacy bare WorkspacePane[] or + // the WorkspaceState envelope. Normalize to a full envelope so the column + // always stores the envelope shape going forward. + const body = parsed.data.workspace_panes; + const envelope = Array.isArray(body) + ? { panes: body, tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] } + : body; + // agent → coder normalization on the panes array (unchanged write rule). + envelope.panes = envelope.panes.map((pane) => pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane, ); const rows = await sql` UPDATE sessions - SET workspace_panes = ${sql.json(workspacePanes as never)}, + SET workspace_panes = ${sql.json(envelope as never)}, updated_at = clock_timestamp() WHERE id = ${req.params.id} RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, diff --git a/apps/server/src/services/inference/tool-phase.ts b/apps/server/src/services/inference/tool-phase.ts index 8d7fbfe..6eab37c 100644 --- a/apps/server/src/services/inference/tool-phase.ts +++ b/apps/server/src/services/inference/tool-phase.ts @@ -2,6 +2,7 @@ import type { Agent, Session, ToolCall } from '../../types/api.js'; import * as modelContext from '../model-context.js'; import { PathScopeError } from '../path_guard.js'; import { TOOLS_BY_NAME } from '../tools.js'; +import type { ToolExecCtx } from '../tools.js'; import { matchToolGlob } from '../agents.js'; import { maybeFlagForCompaction } from './payload.js'; import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js'; @@ -31,6 +32,7 @@ async function executeToolCall( projectRoot: string, toolCall: ToolCall, extraRoots: readonly string[], + toolCtx?: ToolExecCtx, ): Promise<{ output: unknown; truncated: boolean; error?: string }> { const tool = TOOLS_BY_NAME[toolCall.name]; if (!tool) { @@ -65,7 +67,7 @@ async function executeToolCall( }; } try { - const output = await tool.execute(parsed.data, projectRoot, extraRoots); + const output = await tool.execute(parsed.data, projectRoot, extraRoots, toolCtx); const truncated = typeof output === 'object' && output !== null && 'truncated' in output ? Boolean((output as { truncated: unknown }).truncated) @@ -289,7 +291,10 @@ export async function executeToolPhase( }); return; } - const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths); + const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths, { + sql: ctx.sql, + sessionId, + }); if (SYNTHESIS_TOOLS.has(tc.name)) { synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) }); } diff --git a/apps/server/src/services/read_tab_by_number.ts b/apps/server/src/services/read_tab_by_number.ts new file mode 100644 index 0000000..73aa7a7 --- /dev/null +++ b/apps/server/src/services/read_tab_by_number.ts @@ -0,0 +1,142 @@ +// v2.6.x: read_tab_by_number tool. Reads the conversation transcript of the +// chat that occupies a given session-scoped tab number. Stable tab numbers are +// stored in the session's workspace_panes envelope (WorkspaceState.tabNumbers), +// keyed by chat id. Lives in its own file (not appended to tools.ts) so tests +// can import the executor directly without dragging in the whole tool registry. +// Registered in tools.ts ALL_TOOLS + READ_ONLY_TOOL_NAMES. + +import { z } from 'zod'; +import type { Sql } from '../db.js'; +// type-only import to dodge the runtime cycle (tools.ts re-exports this tool +// via ALL_TOOLS; importing ToolDef/ToolExecCtx at type level keeps the dep +// one-way). +import type { ToolDef, ToolExecCtx } from './tools.js'; + +const ReadTabByNumberInput = z.object({ + number: z.number().int().positive(), +}); +export type ReadTabByNumberInputT = z.infer; + +// Cap total transcript size so a long conversation can't blow the context +// window. The model gets a clear truncation marker when the cap is hit. +const MAX_TRANSCRIPT_CHARS = 20_000; + +// WorkspaceState envelope shape (panes omitted — we only need tabNumbers here). +interface WorkspaceStateLike { + panes?: unknown; + tabNumbers?: Record; + nextTabNumber?: number; + closedPaneStack?: unknown[]; +} + +// MIGRATION: the stored workspace_panes value may be the legacy bare +// WorkspacePane[] OR the WorkspaceState envelope. Normalize to an envelope so +// tabNumbers is always available (empty for the legacy shape — no tab numbers +// were tracked before the envelope landed). +function normalizeWorkspaceState(v: unknown): { + tabNumbers: Record; +} { + if (Array.isArray(v)) { + return { tabNumbers: {} }; + } + if (v && typeof v === 'object' && Array.isArray((v as WorkspaceStateLike).panes)) { + const env = v as WorkspaceStateLike; + return { tabNumbers: env.tabNumbers ?? {} }; + } + return { tabNumbers: {} }; +} + +// Pure executor split out from the ToolDef wrapper so tests can call it with a +// mocked Sql. Returns a transcript string (read-only — never writes). +export async function executeReadTabByNumber( + input: ReadTabByNumberInputT, + sql: Sql, + sessionId: string, +): Promise { + const sessionRows = await sql<{ workspace_panes: unknown }[]>` + SELECT workspace_panes FROM sessions WHERE id = ${sessionId} + `; + if (sessionRows.length === 0) { + return `Session not found.`; + } + const { tabNumbers } = normalizeWorkspaceState(sessionRows[0]!.workspace_panes); + + // Reverse-lookup: find the chat id whose stable tab number equals the input. + let chatId: string | null = null; + for (const [cid, num] of Object.entries(tabNumbers)) { + if (num === input.number) { + chatId = cid; + break; + } + } + if (chatId === null) { + return `No tab is numbered ${input.number} in this session.`; + } + + // Read the conversation: skip system sentinels (role='system') and empty + // content rows. Oldest first. + const messages = await sql<{ role: string; content: string }[]>` + SELECT role, content + FROM messages + WHERE chat_id = ${chatId} + AND role <> 'system' + AND content <> '' + ORDER BY created_at ASC + `; + if (messages.length === 0) { + return `Tab ${input.number} (chat ${chatId}) has no messages yet.`; + } + + // Format a compact transcript, capping total output size. + const parts: string[] = []; + let total = 0; + let truncated = false; + for (const m of messages) { + const block = `### ${m.role}\n${m.content}`; + // +2 accounts for the "\n\n" joiner between blocks. + if (total + block.length + 2 > MAX_TRANSCRIPT_CHARS) { + truncated = true; + break; + } + parts.push(block); + total += block.length + 2; + } + + let out = parts.join('\n\n'); + if (truncated) { + out += `\n\n[transcript truncated at ${MAX_TRANSCRIPT_CHARS} chars]`; + } + return out; +} + +export const readTabByNumber: ToolDef = { + name: 'read_tab_by_number', + description: + 'Read the conversation transcript of the tab with the given session-scoped tab number. Tab numbers are stable per session (shown in the workspace tab strip). Returns the messages of that tab oldest-first as a compact transcript. Read-only.', + inputSchema: ReadTabByNumberInput, + jsonSchema: { + type: 'function', + function: { + name: 'read_tab_by_number', + description: + 'Read the conversation transcript of the tab with the given session-scoped tab number. Read-only.', + parameters: { + type: 'object', + properties: { + number: { + type: 'integer', + description: 'The session-scoped tab number (positive integer).', + }, + }, + required: ['number'], + additionalProperties: false, + }, + }, + }, + async execute(input, _projectRoot, _extraRoots, toolCtx?: ToolExecCtx) { + if (!toolCtx) { + return 'read_tab_by_number unavailable: no session context'; + } + return await executeReadTabByNumber(input, toolCtx.sql, toolCtx.sessionId); + }, +}; diff --git a/apps/server/src/services/tools.ts b/apps/server/src/services/tools.ts index 4ee5d69..000db3b 100644 --- a/apps/server/src/services/tools.ts +++ b/apps/server/src/services/tools.ts @@ -1,6 +1,7 @@ import { readFile, readdir, stat } from 'node:fs/promises'; import { resolve, basename, relative } from 'node:path'; import { z } from 'zod'; +import type { Sql } from '../db.js'; import { pathGuard, PathScopeError } from './path_guard.js'; import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js'; import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js'; @@ -30,6 +31,9 @@ import { // with the pause-on-pending-grant branch in inference/tool-phase.ts and the // POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts. 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'; const MAX_FILE_BYTES = 5 * 1024 * 1024; const DEFAULT_VIEW_LINES = 200; @@ -48,6 +52,16 @@ export interface ToolJsonSchema { }; } +// v2.6.x: optional DB/session context threaded into a tool's execute(). Only +// tools that need to read session-scoped DB state (e.g. read_tab_by_number) +// use it; every other tool ignores the 4th arg. Kept optional so existing +// 3-arg execute() implementations stay assignable (apps/coder consumes this +// type from the compiled dist — the optional param keeps it backward-compatible). +export interface ToolExecCtx { + sql: Sql; + sessionId: string; +} + export interface ToolDef { name: string; description: string; @@ -59,7 +73,15 @@ export interface ToolDef { // view_truncated_output) forward it to pathGuard; other tools accept the // arg and ignore it. The execute signature stays compatible with // pre-v1.13.17 callsites because the parameter is optional. - execute(input: TInput, projectRoot: string, extraRoots?: readonly string[]): Promise; + // v2.6.x: optional 4th param toolCtx carries DB/session context for tools + // that read session-scoped state (read_tab_by_number). Optional so 3-arg + // implementations remain assignable. + execute( + input: TInput, + projectRoot: string, + extraRoots?: readonly string[], + toolCtx?: ToolExecCtx, + ): Promise; } const ViewFileInput = z.object({ @@ -694,6 +716,9 @@ export let ALL_TOOLS: ToolDef[] = [ // state change is appending to sessions.allowed_read_paths via the // grant endpoint, gated by user consent. requestReadAccess as ToolDef, + // v2.6.x: read a tab's transcript by its session-scoped tab number. + // Read-only; uses the ToolExecCtx 4th arg for DB/session access. + readTabByNumber as ToolDef, ].sort((a, b) => a.name.localeCompare(b.name)); // v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is @@ -734,6 +759,9 @@ export const READ_ONLY_TOOL_NAMES = [ // state directly (the grant endpoint appends to sessions.allowed_read_paths // only with user consent). Belongs in the read-only budget tier. 'request_read_access', + // v2.6.x: reads a tab's transcript from session-scoped DB state; never + // writes. Belongs in the read-only budget tier. + 'read_tab_by_number', ] as const; export let TOOLS_BY_NAME: Record> = Object.fromEntries( diff --git a/apps/server/src/types/ws-frames.ts b/apps/server/src/types/ws-frames.ts index b743309..63a4014 100644 --- a/apps/server/src/types/ws-frames.ts +++ b/apps/server/src/types/ws-frames.ts @@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({ export const SessionWorkspaceUpdatedFrame = z.object({ type: z.literal('session_workspace_updated'), session_id: Uuid, - workspace_panes: z.array(OpaqueObject), + // v2.6.x: widened from z.array — the payload is now either the legacy bare + // WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers + + // nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop + // every envelope frame at validation. MUST be mirrored in the server's + // byte-identical copy (parity test). + workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]), }); export const ChatCreatedFrame = z.object({