Follow-ups promised on merge, plus review findings on the batch itself: - chat: task-tool output now respects the 512KiB render cap; quick-open icon is visible at rest on coarse pointers and reachable by keyboard (row keydown no longer swallows inner-button Enter/Space); composer inline-code decoration drops the metric-shifting padding; a btw fork send carries only the boundary instruction, never the promotion notice - sync: cascade revert/unrevert aborts busy descendants, busy state is read from every child store at the moment of use; rule 9 documents redo clearing all descendant revert markers - electron: renderer recovery keeps memory-eviction (a valid render-process-gone reason) and both windows share one attachRendererRecovery helper - vscode: process registry is a thin re-export of the web module (provider-env-aliases precedent) with ordered register/unregister writes and an awaited close - server/cli: managed-process registry takes injectable deps (fixes the unreaped-orphans ReferenceError), corrupt settings errors name the file, getWorktrees test restores console.warn - tests: module-mock harnesses removed (AgentsSidebar, SettingsView mobile focus — behaviors stay live but uncovered, accepted trade), QuestionMarkdown asserts rendered DOM - i18n: German gains the debug-panel request keys, Japanese/German drop removed worktree keys, Ukrainian unit spacing fixed - changelog: Copilot AI Credits entries (main + VS Code)
144 lines
5.9 KiB
TypeScript
144 lines
5.9 KiB
TypeScript
import type { MessageRecord } from '@/lib/messageCompletion';
|
|
|
|
import { capToolOutputText } from '../toolRenderers';
|
|
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
|
|
|
export type TaskToolSummaryEntry = {
|
|
id?: string;
|
|
tool?: string;
|
|
state?: {
|
|
status?: string;
|
|
title?: string;
|
|
input?: Record<string, unknown>;
|
|
};
|
|
};
|
|
|
|
const normalizeSessionIdCandidate = (value: unknown): string | undefined => {
|
|
if (typeof value !== 'string') return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
};
|
|
|
|
export const readTaskSessionIdFromRecord = (value: unknown): string | undefined => {
|
|
if (!value || typeof value !== 'object') return undefined;
|
|
const record = value as Record<string, unknown>;
|
|
return normalizeSessionIdCandidate(record.sessionID) ?? normalizeSessionIdCandidate(record.sessionId);
|
|
};
|
|
|
|
export const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => {
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
const normalized: TaskToolSummaryEntry[] = [];
|
|
for (const entry of value) {
|
|
if (typeof entry === 'string') {
|
|
normalized.push({ tool: 'tool', state: { status: 'completed', title: entry } });
|
|
continue;
|
|
}
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
|
|
const record = entry as {
|
|
id?: unknown;
|
|
tool?: unknown;
|
|
title?: unknown;
|
|
status?: unknown;
|
|
state?: { status?: unknown; title?: unknown; input?: unknown };
|
|
};
|
|
normalized.push({
|
|
id: typeof record.id === 'string' ? record.id : undefined,
|
|
tool: typeof record.tool === 'string' ? record.tool : 'tool',
|
|
state: {
|
|
status: typeof record.state?.status === 'string'
|
|
? record.state.status
|
|
: typeof record.status === 'string' ? record.status : undefined,
|
|
title: typeof record.state?.title === 'string'
|
|
? record.state.title
|
|
: typeof record.title === 'string' ? record.title : undefined,
|
|
input: record.state?.input && typeof record.state.input === 'object'
|
|
? record.state.input as Record<string, unknown>
|
|
: undefined,
|
|
},
|
|
});
|
|
}
|
|
return normalized;
|
|
};
|
|
|
|
export const parseTaskMetadataBlock = (output: string | undefined): {
|
|
sessionId?: string;
|
|
summaryEntries: TaskToolSummaryEntry[];
|
|
} => {
|
|
if (typeof output !== 'string' || output.trim().length === 0) return { summaryEntries: [] };
|
|
const blockMatch = output.match(/<task_metadata>\s*([\s\S]*?)\s*<\/task_metadata>/i);
|
|
if (!blockMatch?.[1]) return { summaryEntries: [] };
|
|
|
|
try {
|
|
const parsed = JSON.parse(blockMatch[1].trim()) as Record<string, unknown>;
|
|
return {
|
|
sessionId: normalizeSessionIdCandidate(parsed.sessionId) ?? normalizeSessionIdCandidate(parsed.sessionID),
|
|
summaryEntries: normalizeTaskSummaryEntries(parsed.summary ?? parsed.entries ?? parsed.tools ?? parsed.calls),
|
|
};
|
|
} catch {
|
|
return { summaryEntries: [] };
|
|
}
|
|
};
|
|
|
|
export const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => {
|
|
if (typeof output !== 'string' || output.trim().length === 0) return undefined;
|
|
const parsedMetadata = parseTaskMetadataBlock(output);
|
|
if (parsedMetadata.sessionId) return parsedMetadata.sessionId;
|
|
|
|
const taskMatch = output.match(/task_id\s*:\s*([^\s<"']+)/i);
|
|
const sessionMatch = output.match(/session[_\s-]?id\s*:\s*([^\s<"']+)/i);
|
|
const candidate = taskMatch?.[1] ?? sessionMatch?.[1];
|
|
if (candidate) return normalizeSessionIdCandidate(candidate);
|
|
return normalizeSessionIdCandidate(readTaskTagSessionIdFromOutput(output));
|
|
};
|
|
|
|
const messageSummaryCache = new WeakMap<MessageRecord, TaskToolSummaryEntry[]>();
|
|
|
|
const projectMessageSummaryEntries = (message: MessageRecord): TaskToolSummaryEntry[] => {
|
|
const cached = messageSummaryCache.get(message);
|
|
if (cached) return cached;
|
|
|
|
const entries: TaskToolSummaryEntry[] = [];
|
|
if (message.info.role === 'assistant') {
|
|
for (const part of message.parts) {
|
|
if (part.type !== 'tool') continue;
|
|
const toolName = part.tool?.trim().toLowerCase();
|
|
if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') continue;
|
|
const state = part.state as { status?: string; title?: string; input?: unknown } | undefined;
|
|
entries.push({
|
|
id: part.id,
|
|
tool: part.tool,
|
|
state: {
|
|
status: state?.status,
|
|
title: state?.title,
|
|
input: state?.input && typeof state.input === 'object'
|
|
? state.input as Record<string, unknown>
|
|
: undefined,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
messageSummaryCache.set(message, entries);
|
|
return entries;
|
|
};
|
|
|
|
export const buildTaskSummaryEntriesFromSession = (messages: MessageRecord[]): TaskToolSummaryEntry[] => {
|
|
const entries: TaskToolSummaryEntry[] = [];
|
|
for (const message of messages) entries.push(...projectMessageSummaryEntries(message));
|
|
return entries;
|
|
};
|
|
|
|
export const stripTaskMetadataFromOutput = (output: string): string => {
|
|
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
|
};
|
|
|
|
// The task tool renders its output through the markdown parser instead of the
|
|
// shared tool-output path, so it needs the same size guard as
|
|
// `getToolOutputText` (issue #2265): an unbounded single string reaching the
|
|
// parser can exhaust V8's Zone allocator and crash the renderer.
|
|
export const prepareTaskToolOutput = (output: string | undefined): string => {
|
|
if (!output) return '';
|
|
return capToolOutputText(stripTaskMetadataFromOutput(output));
|
|
};
|