Skip to content
This repository was archived by the owner on Apr 14, 2026. It is now read-only.
Closed
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
20 changes: 14 additions & 6 deletions src/features/chat/hooks/ArtifactPolicyContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,20 @@ export function ArtifactPolicyProvider({
const displayByToolCallId = new Map<string, ToolCardDisplay>();

for (const ranking of artifactsIndex.byMessageId.values()) {
if (!ranking.primaryToolCallId || !ranking.primaryCandidate) continue;
displayByToolCallId.set(ranking.primaryToolCallId, {
role: "primary_host",
primaryCandidate: ranking.primaryCandidate,
secondaryCandidates: ranking.secondaryCandidates,
});
for (const [toolCallId, candidates] of ranking.candidatesByToolCallId) {
if (candidates.length === 0) continue;
const primaryIndex = candidates.findIndex((c) => c.allowed);
const primary =
primaryIndex !== -1 ? candidates[primaryIndex] : candidates[0];
const secondary = candidates.filter(
(_, i) => i !== (primaryIndex !== -1 ? primaryIndex : 0),
);
displayByToolCallId.set(toolCallId, {
role: "primary_host",
primaryCandidate: primary,
secondaryCandidates: secondary,
});
}
}

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,32 @@ function Probe({
);
}

function AllowedProbe({
args,
name,
}: {
args: Record<string, unknown>;
name: string;
}) {
const { resolveToolCardDisplay } = useArtifactPolicyContext();
const display = resolveToolCardDisplay(args, name);

return (
<div>
<span data-testid="role">{display.role}</span>
<span data-testid="primary-path">
{display.primaryCandidate?.resolvedPath ?? ""}
</span>
<span data-testid="primary-allowed">
{String(display.primaryCandidate?.allowed ?? "")}
</span>
<span data-testid="secondary-count">
{String(display.secondaryCandidates.length)}
</span>
</div>
);
}

describe("ArtifactPolicyContext", () => {
it("computes one primary host per message and resolves tool cards by args identity", () => {
const readArgs = { path: "/Users/test/project-a/notes.md" };
Expand Down Expand Up @@ -105,4 +131,58 @@ describe("ArtifactPolicyContext", () => {
).toBeGreaterThan(0);
expect(screen.getByTestId("cloned-role")).toHaveTextContent("none");
});

it("promotes the first allowed candidate to primary when top-ranked is out of scope", () => {
// Tool call writes to two paths: one outside roots, one inside.
// The outside path appears first (higher rank) but should be demoted.
const writeArgs = {
paths: [
"/etc/secrets/leaked.txt",
"/Users/test/project-a/output/report.md",
],
};
const messages: Message[] = [
{
id: "assistant-1",
role: "assistant",
created: Date.now(),
content: [
{
type: "toolRequest",
id: "tool-1",
name: "write_file",
arguments: writeArgs,
status: "completed",
},
{
type: "toolResponse",
id: "tool-1",
name: "write_file",
result: "Created files",
isError: false,
},
],
},
];

render(
<ArtifactPolicyProvider
messages={messages}
allowedRoots={["/Users/test/project-a"]}
>
<AllowedProbe args={writeArgs} name="write_file" />
</ArtifactPolicyProvider>,
);

expect(screen.getByTestId("role")).toHaveTextContent("primary_host");
// The allowed in-scope path should be primary
expect(screen.getByTestId("primary-path")).toHaveTextContent(
"/Users/test/project-a/output/report.md",
);
expect(screen.getByTestId("primary-allowed")).toHaveTextContent("true");
// The out-of-scope path should be in secondary
expect(
Number(screen.getByTestId("secondary-count").textContent),
).toBeGreaterThanOrEqual(1);
});
});
133 changes: 133 additions & 0 deletions src/features/chat/lib/__tests__/toolLabelUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect } from "vitest";
import {
getToolType,
getToolDetail,
getToolVerb,
getToolLabel,
extractFileDetail,
} from "../toolLabelUtils";

describe("getToolType", () => {
it("recognizes write-oriented names", () => {
expect(getToolType("Write")).toBe("write");
expect(getToolType("Write foo.md")).toBe("write");
expect(getToolType("write")).toBe("write");
});

it("recognizes read-oriented names", () => {
expect(getToolType("Read")).toBe("read");
expect(getToolType("readFile")).toBe("read");
expect(getToolType("read_file")).toBe("read");
});

it("recognizes shell commands", () => {
expect(getToolType("bash")).toBe("shell");
expect(getToolType("python3 script.py")).toBe("shell");
expect(getToolType("ls -la")).toBe("shell");
});

it("recognizes search tools", () => {
expect(getToolType("Glob")).toBe("search");
expect(getToolType("Grep")).toBe("search");
expect(getToolType("web_search")).toBe("search");
});

it("returns undefined for unrecognized names", () => {
expect(getToolType("Delegate")).toBeUndefined();
expect(getToolType("Create PDF about whales")).toBeUndefined();
});
});

describe("getToolDetail", () => {
it("extracts detail after type alias keyword", () => {
expect(getToolDetail("Write foo.md")).toBe("foo.md");
expect(getToolDetail("Read /tmp/bar.txt")).toBe("/tmp/bar.txt");
});

it("returns full command for shell commands", () => {
expect(getToolDetail("python3 create.py")).toBe("python3 create.py");
expect(getToolDetail("ls -la /tmp")).toBe("ls -la /tmp");
});

it("returns undefined for single-token names", () => {
expect(getToolDetail("Write")).toBeUndefined();
expect(getToolDetail("Shell")).toBeUndefined();
expect(getToolDetail("ls")).toBeUndefined();
});
});

describe("getToolVerb", () => {
it("returns verb for known types", () => {
expect(getToolVerb("write")).toBe("Edited");
expect(getToolVerb("edit")).toBe("Edited");
expect(getToolVerb("read")).toBe("Read");
expect(getToolVerb("shell")).toBe("Ran");
expect(getToolVerb("search")).toBe("Searched");
});

it("returns undefined for unknown types", () => {
expect(getToolVerb(undefined)).toBeUndefined();
expect(getToolVerb("unknown")).toBeUndefined();
});
});

describe("getToolLabel", () => {
it("builds verb + detail for single items", () => {
expect(getToolLabel("write", 1, "foo.md")).toBe("Edited foo.md");
});

it("builds verb + count + noun for groups", () => {
expect(getToolLabel("write", 3)).toBe("Edited 3 files");
expect(getToolLabel("shell", 2)).toBe("Ran 2 commands");
});

it("returns just verb when no detail", () => {
expect(getToolLabel("write", 1)).toBe("Edited");
expect(getToolLabel("shell", 1)).toBe("Ran");
});

it("falls back to raw name for unknown types", () => {
expect(getToolLabel(undefined, 1, undefined, "Delegate")).toBe("Delegate");
expect(getToolLabel(undefined, 1)).toBe("Tool");
});
});

describe("extractFileDetail", () => {
it("extracts from tool name", () => {
expect(extractFileDetail("Write report.md")).toBe("report.md");
});

it("extracts from args path keys", () => {
expect(extractFileDetail("Write", { file_path: "/tmp/notes.md" })).toBe(
"notes.md",
);
expect(extractFileDetail("Write", { path: "/foo/bar.txt" })).toBe(
"bar.txt",
);
});

it("extracts from result text", () => {
expect(
extractFileDetail(
"Write",
{},
"Created /Users/test/project/output.md (55 lines)",
),
).toBe("output.md");
});

it("prefers tool name over args", () => {
expect(
extractFileDetail("Write inline.md", { path: "/tmp/other.md" }),
).toBe("inline.md");
});

it("skips paths without file extension", () => {
expect(extractFileDetail("Write", { path: "/tmp" })).toBeUndefined();
});

it("returns undefined when nothing found", () => {
expect(extractFileDetail("Shell")).toBeUndefined();
expect(extractFileDetail("Shell", {})).toBeUndefined();
});
});
Loading
Loading