diff --git a/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts b/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts index 501a97dcb0..db47205307 100644 --- a/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts +++ b/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts @@ -1574,6 +1574,521 @@ interface IManageTodoListToolInputParams { }[]; } +// ── SQL-based todo tracking ───────────────────────────────────────────────── + +type TodoStatus = 'not-started' | 'in-progress' | 'completed'; +interface TrackedTodo { + id: string; + title: string; + description: string; + status: TodoStatus; +} + +const SQL_STATUS_MAP: Record = { + 'pending': 'not-started', + 'in_progress': 'in-progress', + 'done': 'completed', + 'blocked': 'not-started', // closest VS Code UI equivalent +}; + +/** + * Parse SQL INSERT into todos and extract todo items. + * Handles: INSERT INTO todos (id, title, description) VALUES ('id', 'title', 'desc'), ... + * Also handles: INSERT INTO todos (id, title) VALUES ('id', 'title'), ... + * Splits compound statements on semicolons so INSERT INTO todo_deps is not misinterpreted. + */ +function parseSqlTodoInserts(query: string): TrackedTodo[] { + // Split on semicolons to isolate individual statements + const statements = query.split(/;\s*/); + const allTodos: TrackedTodo[] = []; + for (const stmt of statements) { + const normalized = stmt.trim().toUpperCase(); + if (!normalized.startsWith('INSERT INTO TODOS')) { + continue; + } + + // Extract column names to determine ordering + const colMatch = stmt.match(/INSERT\s+INTO\s+todos\s*\(([^)]+)\)/i); + if (!colMatch) { + continue; + } + const columns = colMatch[1].split(',').map(c => c.trim().toLowerCase()); + const idIdx = columns.indexOf('id'); + const titleIdx = columns.indexOf('title'); + const descIdx = columns.indexOf('description'); + const statusIdx = columns.indexOf('status'); + + if (idIdx === -1 || titleIdx === -1) { + continue; + } + + // Extract VALUES tuples using quote-aware parsing (handles parentheses inside quoted strings) + const valuesStart = stmt.toUpperCase().indexOf('VALUES'); + if (valuesStart === -1) { + continue; + } + const valuesStr = stmt.slice(valuesStart + 6); + const tuples = extractQuotedTuples(valuesStr); + + for (const raw of tuples) { + const values = splitQuotedValues(raw); + + const id = values[idIdx] ?? ''; + const title = values[titleIdx] ?? ''; + const desc = descIdx >= 0 ? (values[descIdx] ?? '') : ''; + const statusRaw = statusIdx >= 0 ? (values[statusIdx] ?? 'pending') : 'pending'; + + allTodos.push({ + id, + title, + description: desc, + status: SQL_STATUS_MAP[statusRaw.toLowerCase()] ?? 'not-started', + }); + } + } + return allTodos; +} + +/** + * Extract the raw content of parenthesized tuples from a VALUES clause, + * respecting single-quoted strings (which may contain parentheses). + * Returns an array of raw tuple contents (without outer parens). + */ +function extractQuotedTuples(valuesStr: string): string[] { + const tuples: string[] = []; + let inQuote = false; + let depth = 0; + let current = ''; + + for (let i = 0; i < valuesStr.length; i++) { + const ch = valuesStr[i]; + + if (ch === '\'' && !inQuote) { + inQuote = true; + current += ch; + } else if (ch === '\'' && inQuote) { + // Handle escaped quotes '' + if (i + 1 < valuesStr.length && valuesStr[i + 1] === '\'') { + current += '\'\''; + i++; + } else { + inQuote = false; + current += ch; + } + } else if (ch === '(' && !inQuote) { + depth++; + if (depth === 1) { + current = ''; // start of a new tuple + } else { + current += ch; + } + } else if (ch === ')' && !inQuote) { + depth--; + if (depth === 0) { + tuples.push(current); + current = ''; + } else { + current += ch; + } + } else { + if (depth > 0) { + current += ch; + } + } + } + + return tuples; +} + +/** + * Split a comma-separated value list respecting single-quoted strings. + * Returns unquoted, unescaped values. + */ +function splitQuotedValues(raw: string): string[] { + const values: string[] = []; + let current = ''; + let inQuote = false; + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (ch === '\'' && !inQuote) { + inQuote = true; + } else if (ch === '\'' && inQuote) { + if (i + 1 < raw.length && raw[i + 1] === '\'') { + current += '\''; // escaped quote + i++; + } else { + inQuote = false; + } + } else if (ch === ',' && !inQuote) { + values.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + values.push(current.trim()); + return values; +} + +/** + * Parse SQL UPDATE on the todos table to extract status changes. + * Handles: UPDATE todos SET status = 'done' WHERE id = 'x' + * UPDATE todos SET status = 'done' WHERE id IN ('x', 'y') + * Also handles compound statements separated by semicolons/newlines. + */ +function parseSqlTodoUpdates(query: string): Array<{ id: string; status: TodoStatus }> { + const updates: Array<{ id: string; status: TodoStatus }> = []; + + // Split on semicolons to handle compound statements + const statements = query.split(/;\s*/); + for (const stmt of statements) { + const normalized = stmt.trim().toUpperCase(); + if (!normalized.startsWith('UPDATE TODOS')) { + continue; + } + + // Extract status value + const statusMatch = stmt.match(/SET\s+status\s*=\s*'([^']*)'/i); + if (!statusMatch) { + continue; + } + const status = SQL_STATUS_MAP[statusMatch[1].toLowerCase()]; + if (!status) { + continue; + } + + // Extract single ID: WHERE id = 'x' + const singleIdMatch = stmt.match(/WHERE\s+id\s*=\s*'([^']*)'/i); + if (singleIdMatch) { + updates.push({ id: singleIdMatch[1], status }); + continue; + } + + // Extract multiple IDs: WHERE id IN ('x', 'y') + const inMatch = stmt.match(/WHERE\s+id\s+IN\s*\(([^)]+)\)/i); + if (inMatch) { + const ids = Array.from(inMatch[1].matchAll(/'([^']*)'/g)).map(m => m[1]); + for (const id of ids) { + updates.push({ id, status }); + } + } + } + + return updates; +} + +/** + * Parse SQL DELETE on the todos table to determine which todos should be removed. + * Returns 'all' if it's a blanket delete (no WHERE or WHERE status = ...), or + * an array of specific IDs if WHERE id = / WHERE id IN is used. + */ +function parseSqlTodoDeletes(query: string): { type: 'all' | 'by-status' | 'by-ids'; statusFilter?: string; ids?: string[] }[] { + const results: { type: 'all' | 'by-status' | 'by-ids'; statusFilter?: string; ids?: string[] }[] = []; + + const statements = query.split(/;\s*/); + for (const stmt of statements) { + const normalized = stmt.trim().toUpperCase(); + if (!normalized.startsWith('DELETE FROM TODOS')) { + continue; + } + + // DELETE FROM todos WHERE status = 'done' + const statusMatch = stmt.match(/WHERE\s+status\s*=\s*'([^']*)'/i); + if (statusMatch) { + results.push({ type: 'by-status', statusFilter: statusMatch[1].toLowerCase() }); + continue; + } + + // DELETE FROM todos WHERE id = 'x' + const singleIdMatch = stmt.match(/WHERE\s+id\s*=\s*'([^']*)'/i); + if (singleIdMatch) { + results.push({ type: 'by-ids', ids: [singleIdMatch[1]] }); + continue; + } + + // DELETE FROM todos WHERE id IN ('x', 'y') + const inMatch = stmt.match(/WHERE\s+id\s+IN\s*\(([^)]+)\)/i); + if (inMatch) { + const ids = Array.from(inMatch[1].matchAll(/'([^']*)'/g)).map(m => m[1]); + results.push({ type: 'by-ids', ids }); + continue; + } + + // DELETE FROM todos (no WHERE — delete all) + results.push({ type: 'all' }); + } + + return results; +} + +/** + * Tracks SQL-based todo state across a session and syncs to VS Code's todo list UI. + * The agent uses `INSERT INTO todos` and `UPDATE todos SET status` via the sql tool; + * this class maintains the accumulated state and pushes it to CoreManageTodoList. + */ +export class SqlTodoTracker { + private readonly _todos = new Map(); + private readonly _instanceId = Math.random().toString(36).substring(2, 8); + + get debugId(): string { + return this._instanceId; + } + + constructor() { + console.log(`[SqlTodoTracker] New tracker instance created: ${this._instanceId}`); + } + + /** + * Process a SQL tool call. Returns true if the query modified the todos table + * and the VS Code todo list should be updated. + */ + processSqlQuery(query: string): boolean { + console.log(`[SqlTodoTracker:${this._instanceId}] processSqlQuery called, current map size: ${this._todos.size}, keys: [${Array.from(this._todos.keys()).join(', ')}]`); + let changed = false; + + // Try parsing inserts (handles compound statements internally) + const inserts = parseSqlTodoInserts(query); + if (inserts.length > 0) { + for (const todo of inserts) { + console.log(`[SqlTodoTracker] Inserting todo: id=${todo.id}, title=${todo.title}, status=${todo.status}`); + this._todos.set(todo.id, todo); + } + console.log(`[SqlTodoTracker] Total tracked todos after insert: ${this._todos.size}`); + changed = true; + } + + // Try parsing updates (handles compound statements internally) + const updates = parseSqlTodoUpdates(query); + if (updates.length > 0) { + for (const { id, status } of updates) { + const existing = this._todos.get(id); + if (existing) { + console.log(`[SqlTodoTracker] Updating todo: id=${id}, ${existing.status} -> ${status}`); + existing.status = status; + } else { + // We don't know the title, but track the status change anyway + console.log(`[SqlTodoTracker] Updating unknown todo (using id as title): id=${id}, status=${status}`); + this._todos.set(id, { id, title: id, description: '', status }); + } + } + console.log(`[SqlTodoTracker] Total tracked todos after update: ${this._todos.size}`); + changed = true; + } + + // Try parsing deletes (handles compound statements internally) + const deletes = parseSqlTodoDeletes(query); + if (deletes.length > 0) { + for (const del of deletes) { + if (del.type === 'all') { + console.log(`[SqlTodoTracker] DELETE all todos, clearing ${this._todos.size} entries`); + this._todos.clear(); + } else if (del.type === 'by-status' && del.statusFilter) { + const targetStatus = SQL_STATUS_MAP[del.statusFilter]; + if (targetStatus) { + for (const [id, todo] of this._todos) { + if (todo.status === targetStatus) { + console.log(`[SqlTodoTracker] DELETE by status: removing id=${id} (status=${todo.status})`); + this._todos.delete(id); + } + } + } + } else if (del.type === 'by-ids' && del.ids) { + for (const id of del.ids) { + console.log(`[SqlTodoTracker] DELETE by id: removing id=${id}`); + this._todos.delete(id); + } + } + } + console.log(`[SqlTodoTracker] Total tracked todos after delete: ${this._todos.size}`); + changed = true; + } + + return changed; + } + + /** + * Rebuild the full todo state from a SELECT result on the todos table. + * This is the authoritative sync point — the SQL database is the source of truth. + * Parses the markdown table format returned by the SQL tool: + * | id | title | description | status | ... | + */ + rebuildFromSelectResult(resultContent: string): boolean { + const rows = parseSqlTableResult(resultContent); + + // Empty result means all todos were deleted — clear the tracker + if (rows.length === 0) { + if (this._todos.size > 0) { + console.log(`[SqlTodoTracker] SELECT returned 0 rows, clearing ${this._todos.size} tracked todos`); + this._todos.clear(); + return true; + } + return false; + } + + // Check that the result has the expected columns + const firstRow = rows[0]; + if (!('id' in firstRow) || !('title' in firstRow)) { + console.log(`[SqlTodoTracker] SELECT result missing id/title columns, skipping rebuild`); + return false; + } + + this._todos.clear(); + for (const row of rows) { + const id = row['id'] ?? ''; + const title = row['title'] ?? ''; + const description = row['description'] ?? ''; + const statusRaw = row['status'] ?? 'pending'; + const status = SQL_STATUS_MAP[statusRaw.toLowerCase()] ?? 'not-started'; + + console.log(`[SqlTodoTracker] Rebuild from SELECT: id=${id}, title=${title}, status=${status}`); + this._todos.set(id, { id, title, description, status }); + } + console.log(`[SqlTodoTracker] Rebuilt ${this._todos.size} todos from SELECT result`); + return true; + } + + /** + * Get the current todo list formatted for VS Code's CoreManageTodoList tool. + */ + getTodoList(): IManageTodoListToolInputParams['todoList'] { + const todos = Array.from(this._todos.values()); + return todos.map((todo, i) => ({ + id: i, + title: todo.title, + description: todo.description, + status: todo.status, + })); + } +} + +/** + * Parse a markdown table from SQL tool output into an array of row objects. + * Expected format: + * N row(s) returned: + * | col1 | col2 | ... | + * | --- | --- | ... | + * | val1 | val2 | ... | + */ +function parseSqlTableResult(content: string): Array> { + const lines = content.split('\n').map(l => l.trim()).filter(l => l.startsWith('|')); + if (lines.length < 3) { + return []; // Need header + separator + at least 1 data row + } + + const parseRow = (line: string): string[] => + line.split('|').slice(1, -1).map(cell => cell.trim()); + + const headers = parseRow(lines[0]); + + // Skip separator line (lines[1]) + const rows: Array> = []; + for (let i = 2; i < lines.length; i++) { + const values = parseRow(lines[i]); + const row: Record = {}; + for (let j = 0; j < headers.length && j < values.length; j++) { + row[headers[j]] = values[j]; + } + rows.push(row); + } + return rows; +} + +/** + * Process a SQL tool execution event and update the VS Code todo list if the + * query modifies the todos table. + */ +export async function updateTodoListFromSql( + event: ToolExecutionStartEvent, + tracker: SqlTodoTracker, + toolsService: IToolsService, + toolInvocationToken: ChatParticipantToolToken, + token: CancellationToken +) { + const toolData = event.data as ToolCall; + + if (toolData.toolName !== 'sql') { + return; + } + + const query = toolData.arguments.query; + const database = toolData.arguments.database; + if (!query || database === 'session_store') { + console.log(`[SqlTodoTracker] Skipping SQL tool call: ${!query ? 'no query' : 'session_store database'}`); + return; + } + + console.log(`[SqlTodoTracker] Processing SQL query (toolCallId=${event.data.toolCallId}): ${query.substring(0, 200)}`); + if (!tracker.processSqlQuery(query)) { + console.log(`[SqlTodoTracker] Query did not modify todos table, skipping UI update`); + return; + } + + const todoList = tracker.getTodoList(); + if (!todoList.length) { + console.log(`[SqlTodoTracker] No todos to push to UI`); + return; + } + + console.log(`[SqlTodoTracker] Pushing ${todoList.length} todos to VS Code UI: ${JSON.stringify(todoList)}`); + await toolsService.invokeTool(ToolName.CoreManageTodoList, { + input: { + operation: 'write', + todoList, + } satisfies IManageTodoListToolInputParams, + toolInvocationToken, + }, token); + console.log(`[SqlTodoTracker] Successfully pushed todos to VS Code UI`); +} + +/** + * Process a SQL tool.execution_complete event. When the agent runs a SELECT on + * the todos table and results come back, rebuild the tracker state from the + * authoritative database output and sync to VS Code. + */ +export async function syncTodoListFromSqlResult( + event: ToolExecutionCompleteEvent, + originalQuery: string | undefined, + tracker: SqlTodoTracker, + toolsService: IToolsService, + toolInvocationToken: ChatParticipantToolToken, + token: CancellationToken +) { + if (!originalQuery || !event.data.success || !event.data.result?.content) { + return; + } + + // Only process SELECT queries on the todos table (not subqueries inside DELETE/UPDATE) + const normalized = originalQuery.trim().toUpperCase(); + const firstKeyword = normalized.split(/\s+/)[0]; + const isSelectOnTodos = (firstKeyword === 'SELECT' || firstKeyword === 'WITH') && normalized.includes('FROM TODOS'); + if (!isSelectOnTodos) { + return; + } + + const resultContent = typeof event.data.result.content === 'string' + ? event.data.result.content + : JSON.stringify(event.data.result.content); + + console.log(`[SqlTodoTracker] SELECT on todos completed, rebuilding from result: ${resultContent.substring(0, 200)}`); + + if (!tracker.rebuildFromSelectResult(resultContent)) { + console.log(`[SqlTodoTracker] Could not parse SELECT result, skipping UI sync`); + return; + } + + const todoList = tracker.getTodoList(); + + console.log(`[SqlTodoTracker] Syncing ${todoList.length} todos to VS Code UI from SELECT result: ${JSON.stringify(todoList)}`); + await toolsService.invokeTool(ToolName.CoreManageTodoList, { + input: { + operation: 'write', + todoList, + } satisfies IManageTodoListToolInputParams, + toolInvocationToken, + }, token); + console.log(`[SqlTodoTracker] Successfully synced todos from SELECT result`); +} + /** * No-op formatter for tool invocations that do not require custom formatting. * The `toolCall` parameter is unused and present for interface consistency. diff --git a/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts b/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts index 2681e983f0..903c3fd282 100644 --- a/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts +++ b/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts @@ -12,7 +12,7 @@ import { ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponsePullRequestPart, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatToolInvocationPart, MarkdownString } from '../../../../../vscodeTypes'; import { - buildChatHistoryFromEvents, createCopilotCLIToolInvocation, enrichToolInvocationWithSubagentMetadata, extractCdPrefix, getAffectedUrisForEditTool, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, processToolExecutionComplete, processToolExecutionStart, RequestIdDetails, stripReminders, ToolCall + buildChatHistoryFromEvents, createCopilotCLIToolInvocation, enrichToolInvocationWithSubagentMetadata, extractCdPrefix, getAffectedUrisForEditTool, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, parseTodoMarkdown, processToolExecutionComplete, processToolExecutionStart, RequestIdDetails, SqlTodoTracker, stripReminders, ToolCall } from '../copilotCLITools'; import { IChatDelegationSummaryService } from '../delegationSummaryService'; @@ -1130,5 +1130,407 @@ describe('CopilotCLITools', () => { expect(grep.subAgentInvocationId).toBe('L0'); }); }); + + describe('SqlTodoTracker', () => { + it('tracks INSERT INTO todos', () => { + const tracker = new SqlTodoTracker(); + const changed = tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('user-auth', 'Create auth module', 'Implement JWT auth');` + ); + expect(changed).toBe(true); + const list = tracker.getTodoList(); + expect(list).toHaveLength(1); + expect(list[0].title).toBe('Create auth module'); + expect(list[0].description).toBe('Implement JWT auth'); + expect(list[0].status).toBe('not-started'); + }); + + it('tracks INSERT with multiple rows', () => { + const tracker = new SqlTodoTracker(); + const changed = tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('auth', 'Auth module', 'JWT'), ('api', 'API routes', 'REST endpoints');` + ); + expect(changed).toBe(true); + expect(tracker.getTodoList()).toHaveLength(2); + }); + + it('tracks INSERT without description column', () => { + const tracker = new SqlTodoTracker(); + const changed = tracker.processSqlQuery( + `INSERT INTO todos (id, title) VALUES ('auth', 'Auth module');` + ); + expect(changed).toBe(true); + const list = tracker.getTodoList(); + expect(list).toHaveLength(1); + expect(list[0].title).toBe('Auth module'); + expect(list[0].description).toBe(''); + }); + + it('tracks UPDATE single todo status to done', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('auth', 'Auth module', 'JWT');` + ); + const changed = tracker.processSqlQuery( + `UPDATE todos SET status = 'done' WHERE id = 'auth';` + ); + expect(changed).toBe(true); + const list = tracker.getTodoList(); + expect(list[0].status).toBe('completed'); + }); + + it('tracks UPDATE to in_progress', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('auth', 'Auth module', 'JWT');` + ); + tracker.processSqlQuery( + `UPDATE todos SET status = 'in_progress' WHERE id = 'auth';` + ); + expect(tracker.getTodoList()[0].status).toBe('in-progress'); + }); + + it('tracks compound UPDATE statements (semicolon-separated)', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('a', 'Task A', ''), ('b', 'Task B', '');` + ); + const changed = tracker.processSqlQuery( + `UPDATE todos SET status = 'done' WHERE id = 'a'; +UPDATE todos SET status = 'in_progress' WHERE id = 'b';` + ); + expect(changed).toBe(true); + const list = tracker.getTodoList(); + const a = list.find(t => t.title === 'Task A'); + const b = list.find(t => t.title === 'Task B'); + expect(a?.status).toBe('completed'); + expect(b?.status).toBe('in-progress'); + }); + + it('tracks UPDATE with IN clause', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('a', 'A', ''), ('b', 'B', ''), ('c', 'C', '');` + ); + tracker.processSqlQuery( + `UPDATE todos SET status = 'done' WHERE id IN ('a', 'b');` + ); + const list = tracker.getTodoList(); + expect(list.find(t => t.title === 'A')?.status).toBe('completed'); + expect(list.find(t => t.title === 'B')?.status).toBe('completed'); + expect(list.find(t => t.title === 'C')?.status).toBe('not-started'); + }); + + it('returns false for non-todo queries', () => { + const tracker = new SqlTodoTracker(); + expect(tracker.processSqlQuery('SELECT * FROM sessions')).toBe(false); + expect(tracker.processSqlQuery('CREATE TABLE test_cases (id TEXT)')).toBe(false); + }); + + it('returns false for SELECT on todos', () => { + const tracker = new SqlTodoTracker(); + expect(tracker.processSqlQuery(`SELECT * FROM todos WHERE status = 'pending'`)).toBe(false); + }); + + it('creates placeholder entry for UPDATE on unknown todo', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `UPDATE todos SET status = 'done' WHERE id = 'unknown-task';` + ); + const list = tracker.getTodoList(); + expect(list).toHaveLength(1); + expect(list[0].title).toBe('unknown-task'); + expect(list[0].status).toBe('completed'); + }); + + it('handles escaped quotes in values', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('auth', 'Don''t forget auth', 'JWT isn''t easy');` + ); + const list = tracker.getTodoList(); + expect(list[0].title).toBe(`Don't forget auth`); + expect(list[0].description).toBe(`JWT isn't easy`); + }); + + it('does not confuse INSERT INTO todo_deps as todo items in compound statements', () => { + const tracker = new SqlTodoTracker(); + const changed = tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES + ('pull-latest', 'Pull latest', 'Pull the latest changes from the remote repository (git pull).'), + ('check-swift-regen', 'Check if Swift package needs regeneration', 'Verify whether the Swift package needs to be regenerated.'); +INSERT INTO todo_deps (todo_id, depends_on) VALUES + ('check-swift-regen', 'pull-latest');` + ); + expect(changed).toBe(true); + const list = tracker.getTodoList(); + expect(list).toHaveLength(2); + expect(list.find(t => t.title === 'Pull latest')).toBeDefined(); + expect(list.find(t => t.title === 'Check if Swift package needs regeneration')).toBeDefined(); + // Should NOT contain todo_deps data as todos + expect(list.find(t => t.title === 'depends_on')).toBeUndefined(); + expect(list.find(t => t.title === 'pull-latest')).toBeUndefined(); + }); + + it('handles parentheses inside quoted values', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('pull', 'Pull latest', 'Pull the latest changes (git pull).');` + ); + const list = tracker.getTodoList(); + expect(list).toHaveLength(1); + expect(list[0].description).toBe('Pull the latest changes (git pull).'); + }); + + it('collects todos from multiple INSERT INTO todos statements', () => { + const tracker = new SqlTodoTracker(); + const changed = tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('a', 'Task A', 'Desc A'); +INSERT INTO todos (id, title, description) VALUES ('b', 'Task B', 'Desc B');` + ); + expect(changed).toBe(true); + const list = tracker.getTodoList(); + expect(list).toHaveLength(2); + expect(list.find(t => t.title === 'Task A')).toBeDefined(); + expect(list.find(t => t.title === 'Task B')).toBeDefined(); + }); + + it('handles compound INSERT + UPDATE in single query', () => { + const tracker = new SqlTodoTracker(); + const changed = tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('a', 'Task A', 'Desc A'); +UPDATE todos SET status = 'in_progress' WHERE id = 'a';` + ); + expect(changed).toBe(true); + const list = tracker.getTodoList(); + expect(list).toHaveLength(1); + expect(list[0].title).toBe('Task A'); + expect(list[0].status).toBe('in-progress'); + }); + + it('accumulates new todos alongside completed ones across multiple calls', () => { + const tracker = new SqlTodoTracker(); + // First batch: 2 todos + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('a', 'Task A', ''), ('b', 'Task B', '');` + ); + // Complete both + tracker.processSqlQuery(`UPDATE todos SET status = 'done' WHERE id = 'a';`); + tracker.processSqlQuery(`UPDATE todos SET status = 'done' WHERE id = 'b';`); + expect(tracker.getTodoList()).toHaveLength(2); + expect(tracker.getTodoList().every(t => t.status === 'completed')).toBe(true); + + // Second batch: add more todos + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('c', 'Task C', ''), ('d', 'Task D', '');` + ); + const list = tracker.getTodoList(); + expect(list).toHaveLength(4); + expect(list.filter(t => t.status === 'completed')).toHaveLength(2); + expect(list.filter(t => t.status === 'not-started')).toHaveLength(2); + + // Delete completed, keep new ones + tracker.processSqlQuery(`DELETE FROM todos WHERE status = 'done';`); + const remaining = tracker.getTodoList(); + expect(remaining).toHaveLength(2); + expect(remaining.find(t => t.title === 'Task C')).toBeDefined(); + expect(remaining.find(t => t.title === 'Task D')).toBeDefined(); + }); + + it('handles DELETE FROM todos WHERE status', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('a', 'Task A', ''), ('b', 'Task B', '');` + ); + tracker.processSqlQuery(`UPDATE todos SET status = 'done' WHERE id = 'a';`); + expect(tracker.getTodoList()).toHaveLength(2); + + const changed = tracker.processSqlQuery(`DELETE FROM todos WHERE status = 'done';`); + expect(changed).toBe(true); + const list = tracker.getTodoList(); + expect(list).toHaveLength(1); + expect(list[0].title).toBe('Task B'); + }); + + it('handles DELETE FROM todos WHERE id', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('a', 'Task A', ''), ('b', 'Task B', '');` + ); + const changed = tracker.processSqlQuery(`DELETE FROM todos WHERE id = 'a';`); + expect(changed).toBe(true); + expect(tracker.getTodoList()).toHaveLength(1); + }); + + it('handles DELETE FROM todos with no WHERE (delete all)', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('a', 'Task A', ''), ('b', 'Task B', '');` + ); + const changed = tracker.processSqlQuery('DELETE FROM todos;'); + expect(changed).toBe(true); + expect(tracker.getTodoList()).toHaveLength(0); + }); + + it('handles compound DELETE todo_deps + DELETE todos', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('a', 'Done', ''), ('b', 'Pending', '');` + ); + tracker.processSqlQuery(`UPDATE todos SET status = 'done' WHERE id = 'a';`); + + const changed = tracker.processSqlQuery( + `DELETE FROM todo_deps WHERE todo_id IN (SELECT id FROM todos WHERE status = 'done'); +DELETE FROM todos WHERE status = 'done';` + ); + expect(changed).toBe(true); + const list = tracker.getTodoList(); + expect(list).toHaveLength(1); + expect(list[0].title).toBe('Pending'); + }); + + describe('rebuildFromSelectResult', () => { + it('rebuilds full state from SELECT result', () => { + const tracker = new SqlTodoTracker(); + // Pre-existing state with only 1 todo + tracker.processSqlQuery(`INSERT INTO todos (id, title, description) VALUES ('a', 'Task A', 'Desc A');`); + + const result = [ + '2 row(s) returned:', + '', + '| id | title | description | status | created_at | updated_at |', + '| --- | --- | --- | --- | --- | --- |', + '| a | Task A | Desc A | done | 2026-01-01 | 2026-01-02 |', + '| b | Task B | Desc B | pending | 2026-01-01 | 2026-01-01 |', + ].join('\n'); + + const rebuilt = tracker.rebuildFromSelectResult(result); + expect(rebuilt).toBe(true); + const list = tracker.getTodoList(); + expect(list).toHaveLength(2); + expect(list.find(t => t.title === 'Task A')?.status).toBe('completed'); + expect(list.find(t => t.title === 'Task B')?.status).toBe('not-started'); + }); + + it('handles in_progress status from SELECT', () => { + const tracker = new SqlTodoTracker(); + const result = [ + '1 row(s) returned:', + '', + '| id | title | status |', + '| --- | --- | --- |', + '| x | Active task | in_progress |', + ].join('\n'); + tracker.rebuildFromSelectResult(result); + expect(tracker.getTodoList()[0].status).toBe('in-progress'); + }); + + it('returns false for empty result when tracker has no state', () => { + const tracker = new SqlTodoTracker(); + expect(tracker.rebuildFromSelectResult('Query returned 0 rows.')).toBe(false); + }); + + it('clears state and returns true for empty result when tracker has todos', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery(`INSERT INTO todos (id, title, description) VALUES ('a', 'Task A', '');`); + expect(tracker.getTodoList()).toHaveLength(1); + + const rebuilt = tracker.rebuildFromSelectResult('Query returned 0 rows.'); + expect(rebuilt).toBe(true); + expect(tracker.getTodoList()).toHaveLength(0); + }); + + it('returns false for result missing id/title columns', () => { + const tracker = new SqlTodoTracker(); + const result = [ + '| status | count |', + '| --- | --- |', + '| pending | 2 |', + ].join('\n'); + expect(tracker.rebuildFromSelectResult(result)).toBe(false); + }); + + it('clears old state and replaces with SELECT result', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery(`INSERT INTO todos (id, title, description) VALUES ('old', 'Old task', '');`); + expect(tracker.getTodoList()).toHaveLength(1); + + const result = [ + '1 row(s) returned:', + '', + '| id | title | description | status |', + '| --- | --- | --- | --- |', + '| new | New task | New desc | pending |', + ].join('\n'); + tracker.rebuildFromSelectResult(result); + const list = tracker.getTodoList(); + expect(list).toHaveLength(1); + expect(list[0].title).toBe('New task'); + }); + }); + + it('maps blocked status to not-started', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('a', 'Task A', '');`); + tracker.processSqlQuery( + `UPDATE todos SET status = 'blocked' WHERE id = 'a';`); + expect(tracker.getTodoList()[0].status).toBe('not-started'); + }); + + it('ignores INSERT with unknown status gracefully', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description, status) VALUES ('a', 'Task', 'Desc', 'custom_status');`); + expect(tracker.getTodoList()[0].status).toBe('not-started'); + }); + + it('ignores UPDATE with unknown status', () => { + const tracker = new SqlTodoTracker(); + tracker.processSqlQuery( + `INSERT INTO todos (id, title, description) VALUES ('a', 'Task A', '');`); + const changed = tracker.processSqlQuery( + `UPDATE todos SET status = 'unknown' WHERE id = 'a';`); + expect(changed).toBe(false); + }); + + it('ignores INSERT INTO unrelated tables', () => { + const tracker = new SqlTodoTracker(); + expect(tracker.processSqlQuery( + `INSERT INTO test_cases (id, name) VALUES ('t1', 'test');`)).toBe(false); + }); + }); + + describe('parseTodoMarkdown', () => { + it('parses basic markdown checklist', () => { + const { todoList } = parseTodoMarkdown('# Tasks\n- [ ] First task\n- [x] Done task\n- [>] In progress'); + expect(todoList).toHaveLength(3); + expect(todoList[0].status).toBe('not-started'); + expect(todoList[1].status).toBe('completed'); + expect(todoList[2].status).toBe('in-progress'); + }); + + it('extracts title from heading', () => { + const { title } = parseTodoMarkdown('# My Plan\n- [ ] Task'); + expect(title).toBe('My Plan'); + }); + + it('handles ordered list items', () => { + const { todoList } = parseTodoMarkdown('1. [x] Done\n2. [ ] Pending'); + expect(todoList).toHaveLength(2); + expect(todoList[0].status).toBe('completed'); + expect(todoList[1].status).toBe('not-started'); + }); + + it('ignores code blocks', () => { + const { todoList } = parseTodoMarkdown('- [ ] Real task\n```\n- [x] Fake task\n```\n- [x] Another real task'); + expect(todoList).toHaveLength(2); + }); + + it('handles empty input', () => { + const { todoList } = parseTodoMarkdown(''); + expect(todoList).toHaveLength(0); + }); + }); }); diff --git a/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 32a7090dab..886dc3108a 100644 --- a/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -28,7 +28,7 @@ import { IToolsService } from '../../../tools/common/toolsService'; import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore'; import { ExternalEditTracker } from '../../common/externalEditTracker'; import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo'; -import { enrichToolInvocationWithSubagentMetadata, getAffectedUrisForEditTool, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, processToolExecutionComplete, processToolExecutionStart, ToolCall, updateTodoList } from '../common/copilotCLITools'; +import { enrichToolInvocationWithSubagentMetadata, getAffectedUrisForEditTool, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, processToolExecutionComplete, processToolExecutionStart, SqlTodoTracker, syncTodoListFromSqlResult, ToolCall, updateTodoList, updateTodoListFromSql } from '../common/copilotCLITools'; import { getCopilotCLISessionStateDir } from './cliHelpers'; import type { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor'; import { ICopilotCLIImageSupport } from './copilotCLIImageSupport'; @@ -95,6 +95,23 @@ export interface ICopilotCLISession extends IDisposable { getSelectedModelId(): Promise; } +/** + * Module-level storage for SqlTodoTracker instances, keyed by SDK session ID. + * This survives CopilotCLISession recreations between turns. + */ +const sqlTodoTrackers = new Map(); +function getSqlTodoTracker(sessionId: string): SqlTodoTracker { + let tracker = sqlTodoTrackers.get(sessionId); + if (!tracker) { + tracker = new SqlTodoTracker(); + sqlTodoTrackers.set(sessionId, tracker); + console.log(`[SqlTodoTracker] Created and cached tracker for session ${sessionId}, instance: ${tracker.debugId}`); + } else { + console.log(`[SqlTodoTracker] Reusing cached tracker for session ${sessionId}, instance: ${tracker.debugId}`); + } + return tracker; +} + export class CopilotCLISession extends DisposableStore implements ICopilotCLISession { public readonly sessionId: string; private _createdPullRequestUrl: string | undefined; @@ -594,10 +611,19 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } if ((event.data as ToolCall).toolName === 'update_todo') { + this.logService.trace(`[CopilotCLISession] update_todo tool call detected for toolCallId ${event.data.toolCallId}`); updateTodoList(event, this._toolsService, request.toolInvocationToken, token).catch(error => { this.logService.error(`[CopilotCLISession] Failed to invoke todo tool for toolCallId ${event.data.toolCallId}`, error); }); } + + if ((event.data as ToolCall).toolName === 'sql') { + const sqlArgs = (event.data as ToolCall).arguments as { query?: string; database?: string }; + this.logService.trace(`[CopilotCLISession] SQL tool call detected, query: ${sqlArgs.query?.substring(0, 200)}, database: ${sqlArgs.database ?? 'session'}`); + updateTodoListFromSql(event, getSqlTodoTracker(this.sessionId), this._toolsService, request.toolInvocationToken, token).catch(error => { + this.logService.error(`[CopilotCLISession] Failed to update todo list from SQL for toolCallId ${event.data.toolCallId}`, error); + }); + } } } this.logService.trace(`[CopilotCLISession] Start Tool ${event.data.toolName || ''}`); @@ -612,6 +638,15 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes GenAiMetrics.incrementPullRequestCount(this._otelService); } } + + // Sync todo list from SQL SELECT results on the todos table + if (toolName === 'sql') { + const originalQuery = (toolCalls.get(event.data.toolCallId)?.arguments as { query?: string })?.query; + this.logService.trace(`[CopilotCLISession] SQL tool.execution_complete for toolCallId ${event.data.toolCallId}, success=${event.data.success}, hasResult=${!!event.data.result?.content}, originalQuery=${originalQuery?.substring(0, 100)}`); + syncTodoListFromSqlResult(event, originalQuery, getSqlTodoTracker(this.sessionId), this._toolsService, request.toolInvocationToken, token).catch(error => { + this.logService.error(`[CopilotCLISession] Failed to sync todo list from SQL result for toolCallId ${event.data.toolCallId}`, error); + }); + } // Log tool call to request logger const eventError = event.data.error ? { ...event.data.error, code: event.data.error.code || '' } : undefined; const eventData = { ...event.data, error: eventError };