Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/main/projectDirectory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand All @@ -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);
Expand All @@ -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);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<path>…</path><type>file</type><content>…</content>` wrapper and strips
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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(
Expand Down
114 changes: 114 additions & 0 deletions src/renderer/state/usageRecorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" }),
);
});
});
72 changes: 63 additions & 9 deletions src/renderer/state/usageRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,46 +167,100 @@ 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid classifying args.name as a skill

When any non-skill tool carries an args.name value, this predicate now succeeds because readSkillName() returns str(args, "name"); for example existing Workflow tool events with args.name never reach the subagent branch below and are recorded as kind: "skill" instead of subagent, corrupting usage stats. Restrict this check to actual Skill tools or SKILL.md paths, and only use args.name after the item is known to be a skill.

Useful? React with 👍 / 👎.

) {
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
// description (inline script workflows carry neither, so they bucket under a
// 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<string, unknown> | undefined,
args: Record<string, unknown> | 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<string, unknown> | 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 });
Expand Down
Loading