refactor: extract Codex event interpretation (#1224)

* refactor: extract Codex event interpreter

* fix: reuse Codex event interpretation

* test: preserve dispatch coverage boundary

* test: stabilize extracted dispatch coverage

* test: await provider turn persistence

* test: align web mocks with API client
This commit is contained in:
Brad Groux 2026-08-23 22:42:27 -05:00 committed by GitHub
parent 2fa9b2ea89
commit 25be48454b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 405 additions and 348 deletions

View file

@ -26,6 +26,7 @@
"src/__tests__/codex-app-server-provider.smoke.test.ts",
"src/__tests__/codex-app-server-provider.test.ts",
"src/__tests__/codex-env.test.ts",
"src/__tests__/codex-event-interpreter.test.ts",
"src/__tests__/codex-provider-service.test.ts",
"src/__tests__/ceremony-state-repository.test.ts",
"src/__tests__/communication-adapter-schemas.test.ts",
@ -113,7 +114,9 @@
"description": "Provider dispatch, persisted runtime evidence, workflow execution, completion, and recovery.",
"include": [
"server/src/services/clawdbot-agent-service.ts",
"server/src/services/codex-event-interpreter.ts",
"server/src/services/*provider*.ts",
"server/src/services/run-launch-compiler.ts",
"server/src/services/workflow-run-service.ts",
"server/src/services/workflow-step-executor.ts",
"server/src/services/run-launch-manifest-service.ts",

View file

@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import {
interpretCodexEvent,
redactProviderTraceText,
} from '../services/codex-event-interpreter.js';
describe('Codex event interpretation', () => {
it('normalizes nested completion events and protects trace text', () => {
const interpreted = interpretCodexEvent(
{
args: [' status ', '', 42],
file: 'direct.ts',
file_path: [
'./result.ts',
'../parent.ts',
'/tmp/output.log',
'https://example.com/result',
'nested/result.json',
'notes.md',
'line\nbreak',
'',
42,
],
retryAttempt: '2',
retryDelayMs: false,
usage: {
input_tokens: 10,
output_tokens: 4,
cost_usd: 0.01,
model: 'gpt-test',
},
nested: { final_response: ' done ' },
item: { type: 'completed_item' },
},
'turn.completed'
);
expect(interpreted).toMatchObject({
command: 'status',
files: [
'direct.ts',
'./result.ts',
'../parent.ts',
'/tmp/output.log',
'https://example.com/result',
'nested/result.json',
'notes.md',
],
retryAttempt: 2,
tool: 'completed_item',
traceStepType: 'complete',
logActivity: true,
summary: 'done',
usage: {
inputTokens: 10,
outputTokens: 4,
totalTokens: 14,
cost: 0.01,
model: 'gpt-test',
},
});
expect(interpreted.stream).toBeUndefined();
const redacted = redactProviderTraceText(`token=secret-value ${'x'.repeat(2100)}`);
expect(redacted.startsWith('token=[REDACTED]')).toBe(true);
expect(redacted).toHaveLength(2003);
expect(redacted.endsWith('...')).toBe(true);
});
it.each([
['turn.retrying', {}, 'retry', undefined],
['run.aborted', {}, 'abort', undefined],
['run.cancelled', {}, 'abort', undefined],
['turn.failed', { message: 'failed' }, 'error', undefined],
['error', { error: 'broken' }, 'error', 'stderr'],
['run.finalizing', {}, 'finalize', undefined],
['response.output', {}, 'stream', 'stdout'],
['item.created', { item: { type: 'message_delta' } }, 'stream', undefined],
['response.completed', {}, 'complete', undefined],
['item.created', {}, 'execute', undefined],
] as const)('maps %s to its trace lifecycle', (type, event, traceStepType, stream) => {
expect(interpretCodexEvent(event, type)).toMatchObject({ traceStepType, stream });
});
});

View file

@ -1691,6 +1691,15 @@ describe('ClawdbotAgentService Codex providers', () => {
await waitFor(() => {
expect(received.some(({ method }) => method === 'turn/start')).toBe(true);
});
await waitFor(() => {
expect(mockPatchTaskAttempt).toHaveBeenCalledWith(
task.id,
status.attemptId,
expect.objectContaining({
conversation: expect.objectContaining({ currentTurnId: 'turn-app-server-fixture' }),
})
);
});
expect(captureBoundary).toHaveBeenCalledWith(
expect.objectContaining({
taskId: task.id,

View file

@ -5,15 +5,19 @@ const createFsPromisesMock = vi.hoisted(() => () => {
access: vi.fn().mockResolvedValue(undefined),
appendFile: vi.fn().mockResolvedValue(undefined),
copyFile: vi.fn().mockResolvedValue(undefined),
cp: vi.fn().mockResolvedValue(undefined),
lstat: vi.fn().mockResolvedValue({ isSymbolicLink: () => false }),
mkdir: vi.fn().mockResolvedValue(undefined),
open: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(''),
writeFile: vi.fn().mockResolvedValue(undefined),
readdir: vi.fn().mockResolvedValue([]),
rename: vi.fn().mockResolvedValue(undefined),
unlink: vi.fn().mockResolvedValue(undefined),
rm: vi.fn().mockResolvedValue(undefined),
rmdir: vi.fn().mockResolvedValue(undefined),
stat: vi.fn().mockResolvedValue({ isDirectory: () => true, size: 0 }),
statfs: vi.fn().mockResolvedValue({ bfree: 1, bsize: 1 }),
};
return { ...mod, default: mod };
});

View file

@ -5,7 +5,8 @@ import jwt from 'jsonwebtoken';
// vi.hoisted runs before vi.mock hoisting — makes mockFs available to the mock factory
const mockFs: Record<string, string> = vi.hoisted(() => ({}));
vi.mock('node:fs/promises', () => {
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const access = vi.fn().mockResolvedValue(undefined);
const lstat = vi.fn().mockResolvedValue({ isSymbolicLink: () => false });
const mkdir = vi.fn().mockResolvedValue(undefined);
@ -16,6 +17,7 @@ vi.mock('node:fs/promises', () => {
const unlink = vi.fn().mockResolvedValue(undefined);
const rm = vi.fn().mockResolvedValue(undefined);
return {
...actual,
access,
lstat,
mkdir,
@ -25,7 +27,18 @@ vi.mock('node:fs/promises', () => {
readdir,
unlink,
rm,
default: { access, lstat, mkdir, readFile, rename, writeFile, readdir, unlink, rm },
default: {
...actual,
access,
lstat,
mkdir,
readFile,
rename,
writeFile,
readdir,
unlink,
rm,
},
};
});

View file

@ -7,6 +7,7 @@ import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { TraceService } from '../services/trace-service.js';
import { TraceFileRepository } from '../storage/trace-file-repository.js';
function requireValue<T>(value: T | null | undefined): T {
if (value === null || value === undefined) {
@ -35,8 +36,11 @@ describe('TraceService', () => {
service = new TraceService();
// Override private fields
const internals = service as unknown as { tracesDir: string; enabled: boolean };
internals.tracesDir = tracesDir;
const internals = service as unknown as {
repository: TraceFileRepository;
enabled: boolean;
};
internals.repository = new TraceFileRepository(tracesDir);
internals.enabled = true;
});

View file

@ -315,19 +315,12 @@ import {
DependencyCircuitExecutionService,
type DependencyCircuitExecutionOptions,
} from './dependency-circuit-routing-service.js';
import {
interpretCodexEvent,
redactProviderTraceText,
type CodexEventInterpretation,
} from './codex-event-interpreter.js';
const log = createLogger('clawdbot-agent-service');
const TRACE_SECRET_PATTERNS: Array<[RegExp, string]> = [
[/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'],
[/\bsk-[A-Za-z0-9_-]{12,}/g, 'sk-[REDACTED]'],
[/\bghp_[A-Za-z0-9_]{12,}/g, 'ghp_[REDACTED]'],
[/\bgithub_pat_[A-Za-z0-9_]{12,}/g, 'github_pat_[REDACTED]'],
[
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi,
'$1=[REDACTED]',
],
[/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*([^\s"'`,}]+)/gi, '$1=[REDACTED]'],
];
const CLAUDE_CODE_MAX_STDERR_BUFFER_BYTES = 64 * 1024;
const providerDependencyExecutionOptions = {
signalsForError: (error: unknown) => {
@ -8966,8 +8959,8 @@ export class ClawdbotAgentService {
}
const record = event as Record<string, unknown>;
const type = String(record.type || record.event || 'codex.event');
const summary = this.extractCodexSummary(record);
const usage = this.extractCodexUsage(record);
const interpreted = interpretCodexEvent(record, type);
const { summary, usage } = interpreted;
if (usage && task) {
assertProviderRuntimeControl(
pendingAgents.get(task.id)?.providerRuntimeManifest,
@ -8975,7 +8968,7 @@ export class ClawdbotAgentService {
);
}
if (task && attemptId) {
await this.recordCodexEvent(task, attemptId, agentConfig, type, record, summary);
await this.recordCodexEvent(task, attemptId, agentConfig, type, record, interpreted);
}
const redactedRecord = this.redactTraceText(JSON.stringify(record, null, 2));
await this.appendLog(
@ -9279,18 +9272,14 @@ export class ClawdbotAgentService {
agentConfig: AgentConfig | undefined,
type: string,
event: Record<string, unknown>,
summary?: string
interpreted: CodexEventInterpretation
): Promise<void> {
const agent =
agentConfig?.type || (agentConfig?.provider === 'codex-sdk' ? 'codex-sdk' : 'codex');
const files = this.extractCodexFiles(event);
const usage = this.extractCodexUsage(event);
const command = this.extractCodexCommand(event);
const tool = this.extractCodexTool(event, type);
const error = this.extractCodexError(event, type);
const { summary, files, usage, command, tool, error } = interpreted;
const sanitizedSummary = summary ? this.redactTraceText(summary) : undefined;
const stepType = this.codexTraceStepType(type, event);
const stream = this.extractCodexStream(event, type);
const stepType = interpreted.traceStepType;
const stream = interpreted.stream;
const provider = agentConfig?.provider === 'codex-sdk' ? 'codex-sdk' : 'codex-cli';
const journalEvent = await this.appendMappedProviderEvent(
task,
@ -9332,8 +9321,8 @@ export class ClawdbotAgentService {
tool,
files,
error: error ? this.redactTraceText(error) : undefined,
retryAttempt: this.extractCodexNumber(event, ['retryAttempt', 'retry_attempt', 'attempt']),
retryDelayMs: this.extractCodexNumber(event, ['retryDelayMs', 'retry_delay_ms', 'delayMs']),
retryAttempt: interpreted.retryAttempt,
retryDelayMs: interpreted.retryDelayMs,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
@ -9341,7 +9330,7 @@ export class ClawdbotAgentService {
finalResult: stepType === 'complete' ? sanitizedSummary : undefined,
});
if (this.shouldLogCodexActivity(type)) {
if (interpreted.logActivity) {
await activityService.logActivity(
'agent_event',
task.id,
@ -9374,235 +9363,8 @@ export class ClawdbotAgentService {
}
}
private codexTraceStepType(type: string, event?: Record<string, unknown>): AgentRunTraceStepType {
const normalized = type.toLowerCase();
if (normalized.includes('retry')) return 'retry';
if (normalized.includes('abort') || normalized.includes('cancel')) return 'abort';
if (normalized.includes('failed') || normalized === 'error') return 'error';
if (normalized.includes('finaliz')) return 'finalize';
if (
normalized.includes('delta') ||
normalized.includes('stream') ||
normalized.includes('output') ||
normalized.includes('stdout') ||
normalized.includes('stderr')
) {
return 'stream';
}
if (event && typeof event.item === 'object' && event.item !== null) {
const itemType = String((event.item as Record<string, unknown>).type || '').toLowerCase();
if (itemType.includes('delta') || itemType.includes('message_delta')) return 'stream';
}
if (type.includes('failed') || type === 'error') return 'error';
if (type === 'turn.completed' || type === 'response.completed') return 'complete';
return 'execute';
}
private shouldLogCodexActivity(type: string): boolean {
return (
type.includes('command') ||
type.includes('tool') ||
type.includes('file') ||
type.includes('retry') ||
type.includes('abort') ||
type.includes('completed') ||
type.includes('failed') ||
type === 'error'
);
}
private extractCodexFiles(event: unknown): string[] {
const files = new Set<string>();
const seen = new Set<unknown>();
const fileKeys = new Set([
'file',
'file_path',
'filePath',
'path',
'relative_path',
'relativePath',
'absolute_path',
'absolutePath',
]);
const visit = (value: unknown, key?: string): void => {
if (!value) return;
if (typeof value === 'string') {
if (key && fileKeys.has(key) && this.looksLikeFilePath(value)) files.add(value);
return;
}
if (Array.isArray(value)) {
for (const item of value) visit(item, key);
return;
}
if (typeof value !== 'object' || seen.has(value)) return;
seen.add(value);
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
visit(childValue, childKey);
}
};
visit(event);
return [...files].slice(0, 25);
}
private extractCodexCommand(event: unknown): string | undefined {
const command = this.findCodexString(event, [
'command',
'cmd',
'shell_command',
'shellCommand',
]);
const args = this.findCodexStringArray(event, ['args', 'argv']);
if (command && args.length > 0) return `${command} ${args.join(' ')}`;
return command ?? (args.length > 0 ? args.join(' ') : undefined);
}
private extractCodexTool(event: unknown, fallbackType: string): string | undefined {
const tool = this.findCodexString(event, [
'tool',
'tool_name',
'toolName',
'function_name',
'functionName',
]);
if (tool) return tool;
if (event && typeof event === 'object') {
const item = (event as Record<string, unknown>).item;
if (item && typeof item === 'object') {
const itemType = (item as Record<string, unknown>).type;
if (typeof itemType === 'string' && itemType.trim()) return itemType.trim();
}
}
return fallbackType;
}
private extractCodexError(event: unknown, type: string): string | undefined {
if (!type.includes('failed') && type !== 'error') return undefined;
const error = this.findCodexString(event, ['error', 'message']);
return error;
}
private extractCodexStream(
event: Record<string, unknown>,
type: string
): 'stdout' | 'stderr' | undefined {
const stream = this.findCodexString(event, ['stream', 'channel', 'fd']);
if (stream === 'stdout' || stream === 'stderr') return stream;
if (/stderr|error/i.test(type)) return 'stderr';
if (/stdout|delta|output|stream/i.test(type)) return 'stdout';
return undefined;
}
private extractCodexNumber(event: unknown, keys: string[]): number | undefined {
const wanted = new Set(keys);
const seen = new Set<unknown>();
const visit = (value: unknown, key?: string): number | undefined => {
if (!value) return undefined;
if (typeof value === 'number') {
return key && wanted.has(key) ? value : undefined;
}
if (typeof value === 'string' && key && wanted.has(key)) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
if (Array.isArray(value)) {
for (const item of value) {
const result = visit(item, key);
if (result !== undefined) return result;
}
return undefined;
}
if (typeof value !== 'object' || seen.has(value)) return undefined;
seen.add(value);
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
const result = visit(childValue, childKey);
if (result !== undefined) return result;
}
return undefined;
};
return visit(event);
}
private findCodexString(event: unknown, keys: string[]): string | undefined {
const wanted = new Set(keys);
const seen = new Set<unknown>();
const visit = (value: unknown, key?: string): string | undefined => {
if (!value) return undefined;
if (typeof value === 'string') {
if (key && wanted.has(key) && value.trim()) return value.trim();
return undefined;
}
if (Array.isArray(value)) {
for (const item of value) {
const result = visit(item, key);
if (result) return result;
}
return undefined;
}
if (typeof value !== 'object' || seen.has(value)) return undefined;
seen.add(value);
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
const result = visit(childValue, childKey);
if (result) return result;
}
return undefined;
};
return visit(event);
}
private findCodexStringArray(event: unknown, keys: string[]): string[] {
const wanted = new Set(keys);
const seen = new Set<unknown>();
const visit = (value: unknown, key?: string): string[] => {
if (!value) return [];
if (Array.isArray(value)) {
if (key && wanted.has(key)) {
return value
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
.map((item) => item.trim())
.slice(0, 20);
}
for (const item of value) {
const result = visit(item, key);
if (result.length > 0) return result;
}
return [];
}
if (typeof value !== 'object' || seen.has(value)) return [];
seen.add(value);
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
const result = visit(childValue, childKey);
if (result.length > 0) return result;
}
return [];
};
return visit(event);
}
private redactTraceText(value: string): string {
let redacted = value;
for (const [pattern, replacement] of TRACE_SECRET_PATTERNS) {
redacted = redacted.replace(pattern, replacement);
}
return redacted.length > 2000 ? `${redacted.slice(0, 2000)}...` : redacted;
}
private looksLikeFilePath(value: string): boolean {
const trimmed = value.trim();
if (!trimmed || trimmed.includes('\n')) return false;
if (/^https?:\/\//i.test(trimmed)) return true;
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../'))
return true;
return /^[\w.-]+\/[\w./-]+$/.test(trimmed) || /\.[a-z0-9]{1,12}$/i.test(trimmed);
return redactProviderTraceText(value);
}
private async attachProviderDeliverables(
@ -9726,87 +9488,6 @@ export class ClawdbotAgentService {
return conversation;
}
private extractCodexSummary(event: unknown): string | undefined {
const seen = new Set<unknown>();
const visit = (value: unknown): string | undefined => {
if (!value || typeof value !== 'object') return undefined;
if (seen.has(value)) return undefined;
seen.add(value);
const record = value as Record<string, unknown>;
for (const key of [
'final_response',
'finalMessage',
'final_message',
'message',
'text',
'delta',
'chunk',
'content',
'output',
]) {
const candidate = record[key];
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
}
for (const child of Object.values(record)) {
const result = visit(child);
if (result) return result;
}
return undefined;
};
return visit(event);
}
private extractCodexUsage(event: unknown):
| {
inputTokens: number;
outputTokens: number;
totalTokens?: number;
cost?: number;
model?: string;
}
| undefined {
const seen = new Set<unknown>();
const visit = (value: unknown): Record<string, unknown> | undefined => {
if (!value || typeof value !== 'object') return undefined;
if (seen.has(value)) return undefined;
seen.add(value);
const record = value as Record<string, unknown>;
const input =
record.input_tokens ?? record.inputTokens ?? record.prompt_tokens ?? record.promptTokens;
const output =
record.output_tokens ??
record.outputTokens ??
record.completion_tokens ??
record.completionTokens;
if (typeof input === 'number' && typeof output === 'number') return record;
for (const child of Object.values(record)) {
const result = visit(child);
if (result) return result;
}
return undefined;
};
const usage = visit(event);
if (!usage) return undefined;
const input = (usage.input_tokens ??
usage.inputTokens ??
usage.prompt_tokens ??
usage.promptTokens) as number;
const output = (usage.output_tokens ??
usage.outputTokens ??
usage.completion_tokens ??
usage.completionTokens) as number;
const total = usage.total_tokens ?? usage.totalTokens;
const cost = usage.cost ?? usage.cost_usd ?? usage.costUsd;
return {
inputTokens: input,
outputTokens: output,
totalTokens: typeof total === 'number' ? total : input + output,
cost: typeof cost === 'number' ? cost : undefined,
model: typeof usage.model === 'string' ? usage.model : undefined,
};
}
private async appendLog(logPath: string, content: string): Promise<void> {
ensureWithinBase(this.logsDir, logPath);
await fs.appendFile(logPath, content, 'utf-8');

View file

@ -0,0 +1,256 @@
import type { AgentRunTraceStepType } from '@veritas-kanban/shared';
const SECRET_PATTERNS: Array<[RegExp, string]> = [
[/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'],
[/\bsk-[A-Za-z0-9_-]{12,}/g, 'sk-[REDACTED]'],
[/\bghp_[A-Za-z0-9_]{12,}/g, 'ghp_[REDACTED]'],
[/\bgithub_pat_[A-Za-z0-9_]{12,}/g, 'github_pat_[REDACTED]'],
[
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi,
'$1=[REDACTED]',
],
[/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*([^\s"'`,}]+)/gi, '$1=[REDACTED]'],
];
const SUMMARY_KEYS = [
'final_response',
'finalMessage',
'final_message',
'message',
'text',
'delta',
'chunk',
'content',
'output',
];
const FILE_KEYS = new Set([
'file',
'file_path',
'filePath',
'path',
'relative_path',
'relativePath',
'absolute_path',
'absolutePath',
]);
export interface CodexEventUsage {
inputTokens: number;
outputTokens: number;
totalTokens?: number;
cost?: number;
model?: string;
}
export interface CodexEventInterpretation {
summary?: string;
usage?: CodexEventUsage;
files: string[];
command?: string;
tool?: string;
error?: string;
traceStepType: AgentRunTraceStepType;
stream?: 'stdout' | 'stderr';
retryAttempt?: number;
retryDelayMs?: number;
logActivity: boolean;
}
export function redactProviderTraceText(value: string): string {
let redacted = value;
for (const [pattern, replacement] of SECRET_PATTERNS) {
redacted = redacted.replace(pattern, replacement);
}
return redacted.length > 2000 ? `${redacted.slice(0, 2000)}...` : redacted;
}
export function interpretCodexEvent(
event: Record<string, unknown>,
type: string
): CodexEventInterpretation {
const command = findString(event, new Set(['command', 'cmd', 'shell_command', 'shellCommand']));
const args = findStringArray(event, new Set(['args', 'argv']));
const tool =
findString(
event,
new Set(['tool', 'tool_name', 'toolName', 'function_name', 'functionName'])
) ??
itemType(event) ??
type;
return {
summary: extractSummary(event),
usage: extractUsage(event),
files: extractFiles(event),
command: command
? `${command}${args.length ? ` ${args.join(' ')}` : ''}`
: args.join(' ') || undefined,
tool,
error:
type.includes('failed') || type === 'error'
? findString(event, new Set(['error', 'message']))
: undefined,
traceStepType: traceStepType(type, event),
stream: extractStream(event, type),
retryAttempt: findNumber(event, new Set(['retryAttempt', 'retry_attempt', 'attempt'])),
retryDelayMs: findNumber(event, new Set(['retryDelayMs', 'retry_delay_ms', 'delayMs'])),
logActivity: /command|tool|file|retry|abort|completed|failed/.test(type) || type === 'error',
};
}
function visitRecords(
value: unknown,
visitor: (record: Record<string, unknown>) => boolean | void,
seen = new Set<unknown>()
): void {
if (!value || typeof value !== 'object' || seen.has(value)) return;
seen.add(value);
if (visitor(value as Record<string, unknown>)) return;
for (const child of Object.values(value as Record<string, unknown>)) {
visitRecords(child, visitor, seen);
}
}
function findString(value: unknown, keys: Set<string>): string | undefined {
let found: string | undefined;
visitRecords(value, (record) => {
for (const [key, candidate] of Object.entries(record)) {
if (keys.has(key) && typeof candidate === 'string' && candidate.trim()) {
found = candidate.trim();
return true;
}
}
return false;
});
return found;
}
function findStringArray(value: unknown, keys: Set<string>): string[] {
let found: string[] = [];
visitRecords(value, (record) => {
for (const [key, candidate] of Object.entries(record)) {
if (keys.has(key) && Array.isArray(candidate)) {
const strings = candidate
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
.map((item) => item.trim())
.slice(0, 20);
if (strings.length) {
found = strings;
return true;
}
}
}
return false;
});
return found;
}
function findNumber(value: unknown, keys: Set<string>): number | undefined {
let found: number | undefined;
visitRecords(value, (record) => {
for (const [key, candidate] of Object.entries(record)) {
if (!keys.has(key)) continue;
if (typeof candidate !== 'number' && typeof candidate !== 'string') continue;
const parsed = typeof candidate === 'number' ? candidate : Number(candidate);
if (Number.isFinite(parsed)) {
found = parsed;
return true;
}
}
return false;
});
return found;
}
function extractFiles(value: unknown): string[] {
const files = new Set<string>();
visitRecords(value, (record) => {
for (const [key, candidate] of Object.entries(record)) {
if (FILE_KEYS.has(key) && typeof candidate === 'string' && looksLikeFilePath(candidate)) {
files.add(candidate);
}
if (FILE_KEYS.has(key) && Array.isArray(candidate)) {
for (const item of candidate) {
if (typeof item === 'string' && looksLikeFilePath(item)) files.add(item);
}
}
}
});
return [...files].slice(0, 25);
}
function extractUsage(value: unknown): CodexEventUsage | undefined {
let usage: CodexEventUsage | undefined;
visitRecords(value, (record) => {
const input =
record.input_tokens ?? record.inputTokens ?? record.prompt_tokens ?? record.promptTokens;
const output =
record.output_tokens ??
record.outputTokens ??
record.completion_tokens ??
record.completionTokens;
if (typeof input !== 'number' || typeof output !== 'number') return false;
const total = record.total_tokens ?? record.totalTokens;
const cost = record.cost ?? record.cost_usd ?? record.costUsd;
usage = {
inputTokens: input,
outputTokens: output,
totalTokens: typeof total === 'number' ? total : input + output,
cost: typeof cost === 'number' ? cost : undefined,
model: typeof record.model === 'string' ? record.model : undefined,
};
return true;
});
return usage;
}
function traceStepType(type: string, event: Record<string, unknown>): AgentRunTraceStepType {
const normalized = type.toLowerCase();
if (normalized.includes('retry')) return 'retry';
if (normalized.includes('abort') || normalized.includes('cancel')) return 'abort';
if (normalized.includes('failed') || normalized === 'error') return 'error';
if (normalized.includes('finaliz')) return 'finalize';
if (/delta|stream|output|stdout|stderr/.test(normalized)) return 'stream';
if (/delta|message_delta/.test((itemType(event) ?? '').toLowerCase())) return 'stream';
if (type === 'turn.completed' || type === 'response.completed') return 'complete';
return 'execute';
}
function itemType(event: Record<string, unknown>): string | undefined {
if (!event.item || typeof event.item !== 'object') return undefined;
const type = (event.item as Record<string, unknown>).type;
return typeof type === 'string' && type.trim() ? type.trim() : undefined;
}
function extractSummary(value: unknown): string | undefined {
let summary: string | undefined;
visitRecords(value, (record) => {
for (const key of SUMMARY_KEYS) {
const candidate = record[key];
if (typeof candidate === 'string' && candidate.trim()) {
summary = candidate.trim();
return true;
}
}
return false;
});
return summary;
}
function extractStream(
event: Record<string, unknown>,
type: string
): 'stdout' | 'stderr' | undefined {
const stream = findString(event, new Set(['stream', 'channel', 'fd']));
if (stream === 'stdout' || stream === 'stderr') return stream;
if (/stderr|error/i.test(type)) return 'stderr';
if (/stdout|delta|output|stream/i.test(type)) return 'stdout';
return undefined;
}
function looksLikeFilePath(value: string): boolean {
const trimmed = value.trim();
if (!trimmed || trimmed.includes('\n')) return false;
if (/^https?:\/\//i.test(trimmed)) return true;
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) return true;
return /^[\w.-]+\/[\w./-]+$/.test(trimmed) || /\.[a-z0-9]{1,12}$/i.test(trimmed);
}

View file

@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
deleteComment: vi.fn(),
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
getAttachmentText: vi.fn(),
timeStart: vi.fn(),
timeStop: vi.fn(),
timeAddEntry: vi.fn(),
@ -57,6 +58,9 @@ vi.mock('@/lib/api', () => ({
addEntry: mocks.timeAddEntry,
deleteEntry: mocks.timeDeleteEntry,
},
attachments: {
getText: mocks.getAttachmentText,
},
},
}));
@ -69,6 +73,11 @@ describe('task detail support sections Mantine migration', () => {
mocks.deleteComment.mockResolvedValue(undefined);
mocks.uploadAttachment.mockResolvedValue(undefined);
mocks.deleteAttachment.mockResolvedValue(undefined);
mocks.getAttachmentText.mockResolvedValue({
attachmentId: 'attachment-1',
text: 'Extracted design notes',
hasText: true,
});
});
afterEach(() => {
@ -136,11 +145,6 @@ describe('task detail support sections Mantine migration', () => {
it('renders attachments through direct Mantine controls and preserves upload, preview, and delete', async () => {
const user = userEvent.setup();
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ text: 'Extracted design notes' }),
});
vi.stubGlobal('fetch', fetchMock);
const task = createMockTask({
id: 'task-attachments',
attachments: [
@ -187,9 +191,7 @@ describe('task detail support sections Mantine migration', () => {
taskId: task.id,
formData: expect.any(FormData),
});
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/tasks/task-attachments/attachments/attachment-1/text')
);
expect(mocks.getAttachmentText).toHaveBeenCalledWith(task.id, 'attachment-1');
expect(mocks.deleteAttachment).toHaveBeenCalledWith({
taskId: task.id,
attachmentId: 'attachment-1',

View file

@ -128,7 +128,7 @@ function jsonResponse(data: unknown, ok = true) {
return {
ok,
status: ok ? 200 : 500,
json: async () => ({ data }),
json: async () => data,
} as Response;
}