// 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); }, };