Files
openchamber/packages/ui/src/components/chat/message/parts/taskToolModel.ts
T
Bohdan Triapitsyn b8465ae133 fix: harden and de-slop the merged contribution batch
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)
2026-08-28 02:08:09 +03:00

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