diff --git a/src/main/projectDirectory.test.ts b/src/main/projectDirectory.test.ts index 2e2c4917..7067c245 100644 --- a/src/main/projectDirectory.test.ts +++ b/src/main/projectDirectory.test.ts @@ -6,6 +6,7 @@ import { createProjectDirectory, describeMkdirError } from "./projectDirectory"; describe("createProjectDirectory", () => { let root: string; + const nativeKind = process.platform === "win32" ? "windows" : "posix"; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "lc-create-project-")); @@ -16,7 +17,11 @@ describe("createProjectDirectory", () => { }); test("creates the folder under the parent and returns its path", async () => { - const result = await createProjectDirectory({ parent: root, name: "new-app", kind: "posix" }); + const result = await createProjectDirectory({ + parent: root, + name: "new-app", + kind: nativeKind, + }); const expected = join(root, "new-app"); expect(result.path).toBe(expected); @@ -25,16 +30,16 @@ describe("createProjectDirectory", () => { }); test("throws when a folder with that name already exists", async () => { - await createProjectDirectory({ parent: root, name: "dup", kind: "posix" }); + await createProjectDirectory({ parent: root, name: "dup", kind: nativeKind }); await expect( - createProjectDirectory({ parent: root, name: "dup", kind: "posix" }), + createProjectDirectory({ parent: root, name: "dup", kind: nativeKind }), ).rejects.toThrow(/already exists/i); }); test("surfaces a friendly message when the parent does not exist", async () => { await expect( - createProjectDirectory({ parent: join(root, "missing"), name: "app", kind: "posix" }), + createProjectDirectory({ parent: join(root, "missing"), name: "app", kind: nativeKind }), ).rejects.toThrow(/no longer exists/i); }); }); diff --git a/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.test.ts b/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.test.ts index 52270380..f2c3805c 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.test.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.test.ts @@ -40,6 +40,21 @@ describe("acpToolPayload", () => { }); }); + it("unwraps MCP content text blocks from result objects", () => { + expect( + extractAcpResultPart({ + result: { + content: [{ type: "text", text: '{"count":1}' }], + structuredContent: null, + _meta: null, + }, + }), + ).toEqual({ + text: '{\n "count": 1\n}', + language: "json", + }); + }); + it("synthesizes a unified diff from replacement-style edit args", () => { expect( extractAcpDiffResultPart({ diff --git a/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.ts b/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.ts index 277e5e04..1192717d 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.ts @@ -63,18 +63,26 @@ export function extractAcpResultPart(payload: unknown): ExtractedPart { return asPart(prettyIfJson(r.detailedContent)); if (typeof r.text === "string" && r.text.length > 0) return asPart(prettyIfJson(r.text)); if (typeof r.content === "string" && r.content.length > 0) return asPart(prettyIfJson(r.content)); + if (Array.isArray(r.content)) { + const part = extractTextBlockPart(r.content); + if (part.text.length > 0) return part; + } if (Array.isArray(r.contents)) { - const parts = r.contents - .map((c) => (c && typeof c.text === "string" ? prettyIfJson(c.text) : "")) - .filter((t) => t.length > 0); - if (parts.length > 0) { - const joined = parts.join("\n\n"); - return { text: joined, language: parts.every(isJsonText) ? "json" : "plain" }; - } + const part = extractTextBlockPart(r.contents); + if (part.text.length > 0) return part; } return { text: safeJson(result), language: "json" }; } +function extractTextBlockPart(blocks: readonly ({ text?: unknown } | undefined)[]): ExtractedPart { + const parts = blocks + .map((c) => (c && typeof c.text === "string" ? prettyIfJson(c.text) : "")) + .filter((t) => t.length > 0); + if (parts.length === 0) return emptyPart(); + const joined = parts.join("\n\n"); + return { text: joined, language: parts.every(isJsonText) ? "json" : "plain" }; +} + /** * Read-tool result serializer. Unwraps OpenCode's * `file` wrapper and strips diff --git a/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.test.ts b/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.test.ts index 9b0b8831..88429cc8 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.test.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Eye, GitBranch, ImageIcon, Pencil, SearchCode, Terminal } from "lucide-react"; +import { Eye, GitBranch, ImageIcon, Pencil, SearchCode, Sparkles, Terminal } from "lucide-react"; import type { ToolCallPayload } from "@/shared/contracts"; import { deriveToolDisplay, isSubAgentTool } from "./toolDisplay"; @@ -134,6 +134,19 @@ describe("deriveToolDisplay", () => { expect(display.Icon).toBe(Eye); }); + it("normalizes skill file reads to skill displays", () => { + const display = deriveToolDisplay( + makePayload({ + name: "Read", + args: { file_path: String.raw`C:\Users\sdsle\.codex\skills\.system\imagegen\SKILL.md` }, + }), + ); + + expect(display.title).toBe("Skill: imagegen"); + expect(display.parts).toBeUndefined(); + expect(display.Icon).toBe(Sparkles); + }); + it("includes line ranges for read tools that provide offsets", () => { const display = deriveToolDisplay( makePayload({ diff --git a/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.ts b/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.ts index c13b33c7..33d9aeed 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.ts @@ -108,7 +108,7 @@ export function deriveToolDisplay(payload: ToolCallPayload): ToolDisplay { } if (isSkillTool(payload)) { - const skill = readStr(args, "skill") ?? readStr(args, "name"); + const skill = readStr(args, "skill") ?? readStr(args, "name") ?? readSkillName(payload); return { title: skill ? i18n._(msg`Skill: ${skill}`) : payload.name, Icon: Sparkles }; } @@ -458,7 +458,30 @@ export function isSkillTool(payload: ToolCallPayload): boolean { const n = payload.name.trim(); if (n === "Skill" || /^(loaded|using) skill\b/i.test(n)) return true; const args = readArgsObject(payload); - return readStr(args, "skill") !== undefined; + return readStr(args, "skill") !== undefined || readSkillName(payload) !== undefined; +} + +function readSkillName(payload: ToolCallPayload): string | undefined { + const args = readArgsObject(payload); + return ( + readSkillNameFromPath(readPathArg(args)) ?? + readSkillNameFromPath(payload.title) ?? + readSkillNameFromPath(payload.name) + ); +} + +function readSkillNameFromPath(value: string | undefined): string | undefined { + if (!value) return undefined; + const cleaned = value + .replace(/^(?:view(?:\s+\d+(?::\d+)?)?|read(?:ing)?|open(?:ing)?)[:\s]+/i, "") + .trim() + .replace(/^["'`]+|["'`]+$/g, ""); + const parts = cleaned.split(/[\\/]+/).filter(Boolean); + if (parts.at(-1)?.toLowerCase() !== "skill.md") return undefined; + const skillsIndex = parts.findLastIndex((part) => part.toLowerCase() === "skills"); + if (skillsIndex === -1 || skillsIndex >= parts.length - 2) return undefined; + const skill = parts.at(-2); + return skill && !skill.startsWith(".") ? skill : undefined; } function formatAcpPathDisplay( diff --git a/src/renderer/state/usageRecorder.test.ts b/src/renderer/state/usageRecorder.test.ts index 95575b0a..c501f5b3 100644 --- a/src/renderer/state/usageRecorder.test.ts +++ b/src/renderer/state/usageRecorder.test.ts @@ -37,6 +37,10 @@ function emittedTokenValues(provider: string): number[] { .map((event) => event.value ?? 0); } +function emittedEvents(): UsageEventInputPayload[] { + return bridgeMock.appendUsageEvents.mock.calls.flatMap((call) => call[0].events); +} + describe("usageRecorder token baseline", () => { beforeEach(() => { flushNow(); // drain anything a prior test left buffered @@ -74,3 +78,113 @@ describe("usageRecorder token baseline", () => { expect(emittedTokenValues(provider)).toEqual([1_200]); }); }); + +describe("usageRecorder item classification", () => { + beforeEach(() => { + flushNow(); + bridgeMock.appendUsageEvents.mockReset(); + bridgeMock.appendUsageEvents.mockResolvedValue(undefined); + }); + afterEach(() => { + flushNow(); + }); + + it("records Codex skill calls with lower-case skill names", () => { + const thread = makeThread("skill-thread", "codex"); + recordRuntimeUsage( + "skill-thread", + [ + { + type: "item.started", + threadId: "skill-thread", + itemId: "skill-codex", + itemType: "tool_call", + payload: { name: "skill", args: { skill: "imagegen" }, status: "running" }, + }, + ], + [thread], + ); + + flushNow(); + expect(emittedEvents()).toContainEqual( + expect.objectContaining({ kind: "skill", provider: "codex", name: "imagegen" }), + ); + }); + + it("records Codex skill file reads as skills", () => { + const thread = makeThread("skill-file-thread", "codex"); + recordRuntimeUsage( + "skill-file-thread", + [ + { + type: "item.started", + threadId: "skill-file-thread", + itemId: "skill-file-codex", + itemType: "dynamic_tool_call", + payload: { + name: "Read", + args: { + file_path: String.raw`C:\Users\sdsle\.codex\skills\.system\imagegen\SKILL.md`, + }, + status: "running", + }, + }, + ], + [thread], + ); + + flushNow(); + expect(emittedEvents()).toContainEqual( + expect.objectContaining({ kind: "skill", provider: "codex", name: "imagegen" }), + ); + }); + + it("records Codex collab agent tool calls as subagents", () => { + const thread = makeThread("subagent-thread", "codex"); + recordRuntimeUsage( + "subagent-thread", + [ + { + type: "item.started", + threadId: "subagent-thread", + itemId: "collab-agent", + itemType: "tool_call", + payload: { + name: "spawn_agent", + isSubAgent: true, + args: { prompt: "inspect one thing", agentType: "worker" }, + status: "running", + }, + }, + ], + [thread], + ); + + flushNow(); + expect(emittedEvents()).toContainEqual( + expect.objectContaining({ kind: "subagent", provider: "codex", name: "worker" }), + ); + }); + + it("records Codex MCP calls by server instead of generic mcp", () => { + const thread = makeThread("mcp-thread", "codex"); + recordRuntimeUsage( + "mcp-thread", + [ + { + type: "item.started", + threadId: "mcp-thread", + itemId: "mcp-codex", + itemType: "mcp_tool_call", + payload: { name: "mcpToolCall", server: "browser", status: "running" }, + }, + ], + [thread], + ); + + flushNow(); + expect(emittedEvents()).toContainEqual( + expect.objectContaining({ kind: "mcp", provider: "codex", name: "browser" }), + ); + }); +}); diff --git a/src/renderer/state/usageRecorder.ts b/src/renderer/state/usageRecorder.ts index 1ee74e7d..7811c423 100644 --- a/src/renderer/state/usageRecorder.ts +++ b/src/renderer/state/usageRecorder.ts @@ -167,31 +167,46 @@ function classifyItem(itemType: string, payload: unknown): ItemHit | undefined { const name = str(p, "name") ?? ""; const title = str(p, "title") ?? ""; const args = asRecord(p?.["args"]); + const lowerName = name.toLowerCase(); if (itemType === "mcp_tool_call" || /^mcp__/.test(name)) { const match = /^mcp__(.+?)__/.exec(name); - return { kind: "mcp", name: match?.[1] ?? str(p, "serverId") ?? "mcp" }; + return { + kind: "mcp", + name: + match?.[1] ?? + str(p, "serverId") ?? + str(p, "server") ?? + str(args, "serverId") ?? + str(args, "server") ?? + "mcp", + }; } if ( - name === "Skill" || + lowerName === "skill" || /^(loaded|using) skill\b/i.test(name) || - /^(loaded|using) skill\b/i.test(title) + /^(loaded|using) skill\b/i.test(title) || + readSkillName(p, args) !== undefined ) { const skill = str(args, "skill") ?? str(args, "name") ?? + readSkillName(p, args) ?? title .replace(/^(loaded|using) skill[:\s]*/i, "") .replace(/^skill:\s*/i, "") .trim(); return { kind: "skill", name: skill || "skill" }; } - const subagentType = str(args, "subagent_type"); + const subagentType = + str(args, "subagent_type") ?? str(args, "agent_type") ?? str(args, "agentType"); if ( p?.["isSubAgent"] === true || - name === "Task" || - name === "Workflow" || - name === "Agent" || + lowerName === "task" || + lowerName === "workflow" || + lowerName === "agent" || + lowerName === "collabagenttoolcall" || + lowerName === "collab agent tool call" || subagentType ) { // Prefer the agent type (Task/Agent); for workflows use the saved name or @@ -199,14 +214,53 @@ function classifyItem(itemType: string, payload: unknown): ItemHit | undefined { // generic "workflow"); otherwise the task description. const agent = subagentType ?? - (name === "Workflow" + (lowerName === "workflow" ? (str(args, "name") ?? str(args, "description") ?? "workflow") - : (str(args, "description") ?? "subagent")); + : (str(args, "description") ?? str(args, "prompt") ?? "subagent")); return { kind: "subagent", name: agent }; } return undefined; } +function readSkillName( + payload: Record | undefined, + args: Record | undefined, +): string | undefined { + return ( + str(args, "skill") ?? + str(args, "name") ?? + readSkillNameFromPath(readPathArg(args)) ?? + readSkillNameFromPath(str(payload, "title")) ?? + readSkillNameFromPath(str(payload, "name")) + ); +} + +function readPathArg(args: Record | undefined): string | undefined { + return ( + str(args, "file_path") ?? + str(args, "filePath") ?? + str(args, "path") ?? + str(args, "relative_path") ?? + str(args, "relativePath") ?? + str(args, "notebook_path") ?? + str(args, "notebookPath") + ); +} + +function readSkillNameFromPath(value: string | undefined): string | undefined { + if (!value) return undefined; + const cleaned = value + .replace(/^(?:view(?:\s+\d+(?::\d+)?)?|read(?:ing)?|open(?:ing)?)[:\s]+/i, "") + .trim() + .replace(/^["'`]+|["'`]+$/g, ""); + const parts = cleaned.split(/[\\/]+/).filter(Boolean); + if (parts.at(-1)?.toLowerCase() !== "skill.md") return undefined; + const skillsIndex = parts.findLastIndex((part) => part.toLowerCase() === "skills"); + if (skillsIndex === -1 || skillsIndex >= parts.length - 2) return undefined; + const skill = parts.at(-2); + return skill && !skill.startsWith(".") ? skill : undefined; +} + /** Record an AI-performed git action (commit / PR / conflict) into the buffer. */ export function recordAiAction(type: AiActionType, provider: string, model: string): void { push({ ts: Date.now(), kind: `ai_${type}`, provider, model, value: 1 }); diff --git a/src/supervisor/agents/codex/canonicalMapping.test.ts b/src/supervisor/agents/codex/canonicalMapping.test.ts index 29220dc0..39ab1a30 100644 --- a/src/supervisor/agents/codex/canonicalMapping.test.ts +++ b/src/supervisor/agents/codex/canonicalMapping.test.ts @@ -573,6 +573,132 @@ describe("mapCodexNotification — item lifecycle (item/started, item/completed) expect(completedPayload).toMatchObject({ status: "success", result: { hits: 3 } }); }); + it("normalizes Codex mcpToolCall items for MCP display", () => { + const state = createCodexMapperState("t-codex"); + const started = mapCodexNotification( + "item/started", + { + threadId: "x", + itemId: "mcp-tool-1", + item: { + id: "mcp-tool-1", + type: "mcpToolCall", + server: "browser", + tool: "console_logs", + arguments: { limit: 10 }, + }, + }, + state, + ); + + expect((started[0] as { itemType: string }).itemType).toBe("mcp_tool_call"); + expect((started[0] as { payload: Record }).payload).toMatchObject({ + name: "mcp__browser__console_logs", + serverId: "browser", + args: { limit: 10 }, + status: "running", + }); + + const completed = mapCodexNotification( + "item/completed", + { + threadId: "x", + itemId: "mcp-tool-1", + item: { + id: "mcp-tool-1", + type: "mcpToolCall", + status: "completed", + result: { + content: [{ type: "text", text: '{"count":1}' }], + structuredContent: null, + _meta: null, + }, + }, + }, + state, + ); + + expect((completed.at(-1) as { payload: Record }).payload).toMatchObject({ + status: "success", + result: { + content: [{ type: "text", text: '{"count":1}' }], + structuredContent: null, + _meta: null, + }, + }); + }); + + it("maps Codex collabAgentToolCall items as subagent tool calls", () => { + const state = createCodexMapperState("t-codex"); + const started = mapCodexNotification( + "item/started", + { + threadId: "x", + itemId: "collab-1", + item: { + id: "collab-1", + type: "collabAgentToolCall", + tool: "spawn_agent", + status: "in_progress", + senderThreadId: "parent-thread", + receiverThreadIds: ["child-thread"], + prompt: "inspect one thing", + model: "gpt-5.3-codex", + agentsStates: { + "child-thread": { status: "pending_init", message: null }, + }, + }, + }, + state, + ); + + expect((started[0] as { itemType: string }).itemType).toBe("tool_call"); + expect((started[0] as { payload: Record }).payload).toMatchObject({ + name: "spawn_agent", + isSubAgent: true, + args: { + description: "inspect one thing", + prompt: "inspect one thing", + senderThreadId: "parent-thread", + receiverThreadIds: ["child-thread"], + model: "gpt-5.3-codex", + }, + progress: { + description: "inspect one thing", + model: "gpt-5.3-codex", + stepCount: 1, + }, + status: "running", + }); + + const completed = mapCodexNotification( + "item/completed", + { + threadId: "x", + itemId: "collab-1", + item: { + id: "collab-1", + type: "collabAgentToolCall", + tool: "wait", + status: "completed", + agentsStates: { + "child-thread": { status: "completed", message: "done" }, + }, + }, + }, + state, + ); + + expect((completed.at(-1) as { payload: Record }).payload).toMatchObject({ + status: "success", + result: "done", + progress: { + description: "done", + stepCount: 1, + }, + }); + }); + it("preserves Codex dynamic and image tool item types", () => { const state = createCodexMapperState("t-codex"); const dynamic = mapCodexNotification( diff --git a/src/supervisor/agents/codex/canonicalMapping.ts b/src/supervisor/agents/codex/canonicalMapping.ts index f6e5dac2..a079bdff 100644 --- a/src/supervisor/agents/codex/canonicalMapping.ts +++ b/src/supervisor/agents/codex/canonicalMapping.ts @@ -48,6 +48,7 @@ import { canonicalTypeFor, createCodexMapperState, newItemId, + normalizeItemType, streamForType, type CodexMapperState, } from "./canonicalMappingState"; @@ -105,6 +106,23 @@ export interface CodexItemPayload { changeKind?: string; changes?: unknown; content?: unknown; + server?: string; + serverId?: string; + tool?: string; + arguments?: unknown; + error?: unknown; + senderThreadId?: string; + sender_thread_id?: string; + receiverThreadIds?: unknown; + receiver_thread_ids?: unknown; + agentsStates?: unknown; + agents_states?: unknown; + prompt?: string; + model?: unknown; + reasoningEffort?: unknown; + reasoning_effort?: unknown; + toolKind?: unknown; + tool_kind?: unknown; /** Generic tool input (codex `mcp` / `dynamic` tool items). */ input?: unknown; args?: unknown; @@ -766,9 +784,15 @@ export function buildStartedPayload( } if (isToolLikeItemType(itemType)) { const args = pickToolInput(source); + const serverId = toolServerId(source); + const isSubAgent = isCodexCollabAgentToolCall(source); + const progress = isSubAgent ? readCollabAgentProgress(source) : undefined; return { name: toolName(source) ?? "tool", + ...(serverId ? { serverId } : {}), ...(args !== undefined ? { args } : {}), + ...(progress ? { progress } : {}), + ...(isSubAgent ? { isSubAgent: true } : {}), status: "running" as const, }; } @@ -803,9 +827,13 @@ export function buildCompletedPayload( } if (isToolLikeItemType(itemType)) { const result = pickToolOutput(source); + const progress = isCodexCollabAgentToolCall(source) + ? readCollabAgentProgress(source) + : undefined; return { status: codexFinalStatus(source.status), ...(result !== undefined ? { result } : {}), + ...(progress ? { progress } : {}), }; } if (itemType === "file_change") { @@ -877,8 +905,10 @@ function codexFinalStatus(raw: unknown): "success" | "error" { * the common aliases — `args` / `input` — without inventing new ones. */ function pickToolInput(source: CodexItemPayload): unknown { + if (isCodexCollabAgentToolCall(source)) return pickCollabAgentInput(source); if (source.args !== undefined) return source.args; if (source.input !== undefined) return source.input; + if (source.arguments !== undefined) return source.arguments; return undefined; } @@ -890,6 +920,7 @@ function pickCodexWebSearchInput(source: CodexItemPayload): unknown { function pickToolOutput(source: CodexItemPayload): unknown { if (source.result !== undefined) return source.result; if (source.output !== undefined) return source.output; + if (isCodexCollabAgentToolCall(source)) return pickCollabAgentResult(source); return undefined; } @@ -977,12 +1008,111 @@ function extractTitlePath(value: unknown): string | undefined { } function toolName(source: CodexItemPayload): string | undefined { + const mcpName = mcpToolName(source); + if (mcpName) return mcpName; + if (isCodexCollabAgentToolCall(source) && readNonEmptyString(source.tool)) return source.tool; if (typeof source.title === "string" && source.title.length > 0) return source.title; if (typeof source.name === "string" && source.name.length > 0) return source.name; + if (readNonEmptyString(source.tool)) return source.tool; if (typeof source.type === "string" && source.type.length > 0) return source.type; return undefined; } +function mcpToolName(source: CodexItemPayload): string | undefined { + const server = toolServerId(source); + const tool = readNonEmptyString(source.tool); + return server && tool ? `mcp__${server}__${tool}` : undefined; +} + +function toolServerId(source: CodexItemPayload): string | undefined { + if (canonicalTypeFor(source.type ?? source.kind) !== "mcp_tool_call") return undefined; + return readNonEmptyString(source.server) ?? readNonEmptyString(source.serverId); +} + +function isCodexCollabAgentToolCall(source: CodexItemPayload): boolean { + const type = normalizeItemType(source.type ?? source.kind); + return type === "collab agent tool call" || type === "collab agent"; +} + +function pickCollabAgentInput(source: CodexItemPayload): unknown { + const prompt = readNonEmptyString(source.prompt); + const senderThreadId = + readNonEmptyString(source.senderThreadId) ?? readNonEmptyString(source.sender_thread_id); + const receiverThreadIds = readStringArray(source.receiverThreadIds ?? source.receiver_thread_ids); + const agentsStates = readCollabAgentStates(source); + const model = readNonEmptyString(source.model); + const reasoningEffort = + readNonEmptyString(source.reasoningEffort) ?? readNonEmptyString(source.reasoning_effort); + const toolKind = readNonEmptyString(source.toolKind) ?? readNonEmptyString(source.tool_kind); + + const input: Record = {}; + if (prompt) { + input.description = prompt; + input.prompt = prompt; + } + if (senderThreadId) input.senderThreadId = senderThreadId; + if (receiverThreadIds.length > 0) input.receiverThreadIds = receiverThreadIds; + if (agentsStates !== undefined) input.agentsStates = agentsStates; + if (model) input.model = model; + if (reasoningEffort) input.reasoningEffort = reasoningEffort; + if (toolKind) input.toolKind = toolKind; + return Object.keys(input).length > 0 ? input : undefined; +} + +function pickCollabAgentResult(source: CodexItemPayload): unknown { + const agentsStates = readCollabAgentStates(source); + const messages = readCollabAgentMessages(agentsStates); + if (messages.length === 1) return messages[0]; + if (messages.length > 1) return messages.join("\n\n"); + return agentsStates !== undefined ? { agentsStates } : undefined; +} + +function readCollabAgentProgress(source: CodexItemPayload): + | { + description?: string; + model?: string; + stepCount?: number; + } + | undefined { + const agentsStates = readCollabAgentStates(source); + const description = readCollabAgentMessages(agentsStates)[0] ?? readNonEmptyString(source.prompt); + const model = readNonEmptyString(source.model); + const receiverThreadIds = readStringArray(source.receiverThreadIds ?? source.receiver_thread_ids); + const stepCount = + receiverThreadIds.length > 0 + ? receiverThreadIds.length + : agentsStates && typeof agentsStates === "object" && !Array.isArray(agentsStates) + ? Object.keys(agentsStates as Record).length + : undefined; + const progress = { + ...(description ? { description } : {}), + ...(model ? { model } : {}), + ...(stepCount !== undefined ? { stepCount } : {}), + }; + return Object.keys(progress).length > 0 ? progress : undefined; +} + +function readCollabAgentStates(source: CodexItemPayload): unknown { + return source.agentsStates ?? source.agents_states; +} + +function readCollabAgentMessages(states: unknown): string[] { + if (!states || typeof states !== "object" || Array.isArray(states)) return []; + const messages: string[] = []; + for (const state of Object.values(states as Record)) { + if (!state || typeof state !== "object" || Array.isArray(state)) continue; + const message = readNonEmptyString((state as Record).message); + if (message) messages.push(message); + } + return messages; +} + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string" && item.length > 0) + : []; +} + function extractCodexWebSearchQuery(source: CodexItemPayload): string | undefined { const direct = readNonEmptyString(source.query) ?? readNonEmptyString(source.text); if (direct) return direct; diff --git a/src/supervisor/agents/codex/canonicalMappingState.ts b/src/supervisor/agents/codex/canonicalMappingState.ts index 4053d35f..7639b913 100644 --- a/src/supervisor/agents/codex/canonicalMappingState.ts +++ b/src/supervisor/agents/codex/canonicalMappingState.ts @@ -66,7 +66,7 @@ export function canonicalTypeFor(raw: string | undefined | null): CanonicalItemT return "tool_call"; } -function normalizeItemType(raw: string | undefined | null): string { +export function normalizeItemType(raw: string | undefined | null): string { if (!raw) return ""; return raw .replace(/([a-z0-9])([A-Z])/g, "$1 $2") diff --git a/src/supervisor/agents/commandcode/plugin/install.stage.test.ts b/src/supervisor/agents/commandcode/plugin/install.stage.test.ts index 49cda320..a0b349f0 100644 --- a/src/supervisor/agents/commandcode/plugin/install.stage.test.ts +++ b/src/supervisor/agents/commandcode/plugin/install.stage.test.ts @@ -36,8 +36,8 @@ describe("installCommandCodePlugin (native staging)", () => { expect(existsSync(join(dir, "plugin.json"))).toBe(true); expect(existsSync(join(dir, "forward.mjs"))).toBe(true); expect(existsSync(join(dir, "lightcode-hook-runtime.mjs"))).toBe(true); - // POSIX wrapper (this test only runs the native posix branch). - expect(existsSync(join(dir, "lightcode-hook.sh"))).toBe(true); + const wrapperName = process.platform === "win32" ? "lightcode-hook.cmd" : "lightcode-hook.sh"; + expect(existsSync(join(dir, wrapperName))).toBe(true); const doc = JSON.parse(readFileSync(join(ccDir, "settings.json"), "utf8")) as { hooks: Record }>>; @@ -45,8 +45,9 @@ describe("installCommandCodePlugin (native staging)", () => { for (const ev of ["PreToolUse", "PostToolUse", "Stop"]) { const entry = doc.hooks[ev]?.[0]?.hooks?.[0]; expect(entry?.type).toBe("command"); - expect(entry?.command).toContain("agent-plugins/commandcode/lightcode-hook.sh"); - expect(entry?.command.endsWith(` ${ev}`)).toBe(true); + const command = entry?.command ?? ""; + expect(command.replace(/\\/g, "/")).toContain(`agent-plugins/commandcode/${wrapperName}`); + expect(command.endsWith(` ${ev}`)).toBe(true); } });