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>
143 lines
5.0 KiB
TypeScript
143 lines
5.0 KiB
TypeScript
// 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);
|
|
},
|
|
};
|