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:
142
apps/server/src/services/read_tab_by_number.ts
Normal file
142
apps/server/src/services/read_tab_by_number.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user