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) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 02:14:42 +00:00
parent e857815d79
commit d05f73be26
5 changed files with 226 additions and 17 deletions

View File

@@ -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<Session[]>`
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,

View File

@@ -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 } : {}) });
}

View File

@@ -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<typeof ReadTabByNumberInput>;
// 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<string, number>;
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<string, number>;
} {
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<string> {
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<ReadTabByNumberInputT> = {
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);
},
};

View File

@@ -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<TInput> {
name: string;
description: string;
@@ -59,7 +73,15 @@ export interface ToolDef<TInput> {
// 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<unknown>;
// 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<unknown>;
}
const ViewFileInput = z.object({
@@ -694,6 +716,9 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
// state change is appending to sessions.allowed_read_paths via the
// grant endpoint, gated by user consent.
requestReadAccess as ToolDef<unknown>,
// 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<unknown>,
].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<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -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({