replace artifact heuristics/regexes with protocol messages (#8996)

This commit is contained in:
Jack Amadeo
2026-05-04 15:00:22 -04:00
committed by GitHub
parent 4a443feae8
commit ebe3315bdd
26 changed files with 484 additions and 2208 deletions
+8 -16
View File
@@ -367,14 +367,6 @@ fn get_requested_line(arguments: Option<&rmcp::model::JsonObject>) -> Option<u32
.map(|l| l as u32)
}
fn create_tool_location(path: &str, line: Option<u32>) -> ToolCallLocation {
let mut loc = ToolCallLocation::new(path);
if let Some(l) = line {
loc = loc.line(l);
}
loc
}
fn is_developer_file_tool(tool_name: &str) -> bool {
matches!(tool_name, "read" | "write" | "edit")
}
@@ -391,7 +383,7 @@ fn extract_locations_from_meta(
.filter_map(|entry| {
let path = entry.get("path")?.as_str()?;
let line = entry.get("line").and_then(|v| v.as_u64()).map(|l| l as u32);
Some(create_tool_location(path, line))
Some(ToolCallLocation::new(path).line(line))
})
.collect::<Vec<_>>();
if locations.is_empty() {
@@ -422,12 +414,12 @@ fn extract_tool_locations(
if let Some(path_str) = path_str {
if matches!(tool_name, "read") {
let line = get_requested_line(tool_call.arguments.as_ref());
locations.push(create_tool_location(path_str, line));
locations.push(ToolCallLocation::new(path_str).line(line));
return locations;
}
if matches!(tool_name, "write" | "edit") {
locations.push(create_tool_location(path_str, Some(1)));
locations.push(ToolCallLocation::new(path_str).line(1));
return locations;
}
@@ -447,19 +439,19 @@ fn extract_tool_locations(
let line = extract_view_line_range(text)
.map(|range| range.0 as u32)
.or(Some(1));
locations.push(create_tool_location(path_str, line));
locations.push(ToolCallLocation::new(path_str).line(line));
}
Some("str_replace") | Some("insert") => {
let line = extract_first_line_number(text)
.map(|l| l as u32)
.or(Some(1));
locations.push(create_tool_location(path_str, line));
locations.push(ToolCallLocation::new(path_str).line(line));
}
Some("write") => {
locations.push(create_tool_location(path_str, Some(1)));
locations.push(ToolCallLocation::new(path_str).line(1));
}
_ => {
locations.push(create_tool_location(path_str, Some(1)));
locations.push(ToolCallLocation::new(path_str).line(1));
}
}
break;
@@ -468,7 +460,7 @@ fn extract_tool_locations(
}
if locations.is_empty() {
locations.push(create_tool_location(path_str, Some(1)));
locations.push(ToolCallLocation::new(path_str).line(1));
}
}
}
-1
View File
@@ -219,7 +219,6 @@ Additional tooling notes:
- Unit/component tests use Vitest and Testing Library via `just test` or `pnpm test`.
- E2E tests use Playwright via `just test-e2e` and `just test-e2e-all`.
- File size enforcement runs through `pnpm check:file-sizes` and is included in `just check`.
- Before handing off a change, run the smallest relevant verification step. Use `just ci` when you need the full local gate.
- GitHub Actions also runs desktop-oriented checks, including Playwright coverage, that are broader than the local pre-push hook.
+1 -2
View File
@@ -8,10 +8,9 @@
"dev": "vite",
"build": "tsc && vite build",
"typecheck": "tsc --noEmit",
"check:file-sizes": "node ./scripts/check-file-sizes.mjs",
"check:i18n": "node ./scripts/check-i18n-strings.mjs",
"lint": "biome lint .",
"check": "biome check . && pnpm check:file-sizes && pnpm check:i18n",
"check": "biome check . && pnpm check:i18n",
"format": "biome format --write .",
"preview": "vite preview",
"tauri": "tauri",
-186
View File
@@ -1,186 +0,0 @@
import { readFileSync, readdirSync } from "node:fs";
import { join, relative } from "node:path";
const DEFAULT_LIMIT = 500;
// Add narrowly scoped exceptions here with justification
const EXCEPTIONS = {
"src/features/sidebar/ui/SidebarProjectsSection.tsx": {
limit: 570,
justification:
"Drag-and-drop handlers for session-to-project moves and project reorder, plus activeProjectId highlight.",
},
"src/features/chat/ui/ChatView.tsx": {
limit: 570,
justification:
"ACP prewarm guards, project-aware working dir selection, working context sync, chat bootstrapping, context-ring compaction wiring, and gated [perf:chatview] logging via perfLog (dev-only by default).",
},
"src/features/chat/hooks/useChat.ts": {
limit: 510,
justification:
"Session preparation, provider/model handoff, persona-aware sends, cancellation, and compaction replay still live in one chat lifecycle hook.",
},
"src/shared/api/acpNotificationHandler.ts": {
limit: 570,
justification:
"ACP replay/live update handling, pending session buffering, model/config propagation, MCP structured tool output, and streaming perf tracking still share one notification entrypoint.",
},
"src/shared/api/__tests__/acpNotificationHandler.test.ts": {
limit: 540,
justification:
"Notification handler regression coverage spans live streaming, replay ordering, MCP app payload attachment, and structured tool output preservation in one integration-style suite.",
},
"src/features/chat/ui/__tests__/ContextPanel.test.tsx": {
limit: 550,
justification:
"Workspace widget integration tests cover branch switching, worktree creation, dirty-state dialogs, and picker interactions.",
},
"src/features/sidebar/ui/Sidebar.tsx": {
limit: 580,
justification:
"Search-as-you-type filtering and draft-aware sidebar highlight logic.",
},
"src/app/AppShell.tsx": {
limit: 780,
justification:
"Shell still coordinates ACP session loading, replay-buffer cleanup on load failure, project reassignment, home-session restoration, app-level chat routing, restored project-draft reuse, and app-level compaction settings deep links. Includes gated [perf:load]/[perf:newtab] logging via perfLog (dev-only by default).",
},
"src/features/chat/hooks/useChatSessionController.ts": {
limit: 840,
justification:
"Controller now centralizes home-to-chat pending state transfer, workspace/project preparation, provider/model/persona handoff, Goose cross-provider model selection sequencing with rollback, context-usage readiness resets, queued-target compaction gating, and auto-compaction-aware send orchestration pending a later decomposition pass.",
},
"src/features/chat/hooks/__tests__/useChatSessionController.test.ts": {
limit: 520,
justification:
"Controller regression coverage now spans model/provider rollback, stale usage resets, compact-before-send, and queued-persona auto-compaction support checks in one hook suite.",
},
"src/features/chat/stores/chatStore.ts": {
limit: 520,
justification:
"Chat runtime state, queued-message persistence, replay loading flags, and usage snapshot tracking still live together in one Zustand store.",
},
"src/features/chat/ui/AgentModelPicker.tsx": {
limit: 570,
justification:
"Agent-first picker currently keeps the full trigger, recommended-model view, searchable full-model view, and ACP/goose-specific labeling logic in one component pending later extraction.",
},
"src/features/chat/stores/__tests__/chatSessionStore.test.ts": {
limit: 540,
justification:
"ACP session overlay regressions currently need one broad integration-style store suite.",
},
"src/features/chat/stores/chatSessionStore.ts": {
limit: 640,
justification:
"ACP-backed session overlay persistence, draft migration, and sidebar-facing session merge logic live together for now.",
},
"src/features/chat/ui/ChatInput.tsx": {
limit: 510,
justification:
"Voice dictation send/stop guards, attachment handling, and mention/picker coordination still share one chat composer component.",
},
"src/features/chat/ui/MessageBubble.tsx": {
limit: 580,
justification:
"Bubble rendering still owns assistant identity, grouped tool output, attachments, inline MCP app tool/result wiring, app-initiated message plumbing, full-width inline layout handling, inline auto-scroll callback plumbing, and the inline actions tray pending a later extraction pass.",
},
"src/features/chat/ui/__tests__/MessageBubble.test.tsx": {
limit: 520,
justification:
"Message bubble regression coverage still keeps copy state, action tray layout, provider/persona identity, tool chains, and shared rendering behavior in one suite while the MCP app-specific assertions live in a companion test file.",
},
"src/features/skills/ui/SkillsView.tsx": {
limit: 620,
justification:
"SkillsView currently centralizes list/detail state, project-aware skill hydration, category/source filtering, import/export flows, and detail-page action wiring pending a later decomposition.",
},
"src/features/chat/ui/__tests__/ChatInput.test.tsx": {
limit: 570,
justification:
"Composer regression coverage spans personas, queueing, attachments, voice-input edge cases, and the compaction popover/settings ingress in one interaction-heavy suite.",
},
"src-tauri/src/commands/projects.rs": {
limit: 520,
justification:
"Project CRUD plus reorder_projects command for sidebar drag-and-drop ordering.",
},
"src-tauri/src/commands/system.rs": {
limit: 640,
justification:
"Desktop system commands still centralize file mentions, attachment inspection, platform-aware path dedupe, guarded image loading, and export helpers in one Tauri command surface.",
},
};
// Directories excluded from size checks (imported library code)
const EXCLUDED_DIRS = [
"src/shared/ui",
"src/components/ai-elements",
"src/hooks",
];
const DIRS_TO_CHECK = [
{ dir: "src/app", glob: /\.[jt]sx?$/ },
{ dir: "src/features", glob: /\.[jt]sx?$/ },
{ dir: "src/shared", glob: /\.[jt]sx?$/ },
{ dir: "src/components", glob: /\.[jt]sx?$/ },
{ dir: "src/hooks", glob: /\.[jt]sx?$/ },
{ dir: "src-tauri/src", glob: /\.rs$/ },
];
function countLines(filePath) {
const content = readFileSync(filePath, "utf8");
return content.split("\n").length;
}
function isExcluded(filePath) {
const rel = relative(".", filePath);
return EXCLUDED_DIRS.some((dir) => rel.startsWith(dir));
}
function walkDir(dir, pattern) {
const results = [];
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkDir(fullPath, pattern));
} else if (pattern.test(entry.name)) {
results.push(fullPath);
}
}
return results;
}
const violations = [];
for (const { dir, glob } of DIRS_TO_CHECK) {
const files = walkDir(dir, glob);
for (const file of files) {
if (isExcluded(file)) continue;
const rel = relative(".", file);
const limit = EXCEPTIONS[rel]?.limit ?? DEFAULT_LIMIT;
const lines = countLines(file);
if (lines > limit) {
violations.push({ file: rel, lines, limit });
}
}
}
if (violations.length > 0) {
console.error("Desktop file size check failed:");
for (const v of violations) {
console.error(` - ${v.file}: ${v.lines} lines (limit ${v.limit})`);
}
console.error(
"\nSplit the file or add a narrowly scoped exception in `scripts/check-file-sizes.mjs`.",
);
process.exit(1);
} else {
console.log("File size check passed.");
}
@@ -7,20 +7,17 @@ import {
useRef,
type ReactNode,
} from "react";
import type { Message } from "@/shared/types/messages";
import type {
Message,
ToolCallLocation,
ToolKind,
} from "@/shared/types/messages";
import { pathExists } from "@/shared/api/system";
import {
buildArtifactsIndexForMessages,
inferHomeDirFromRoots,
isWriteOrientedTool,
resolveMarkdownLocalHref,
type ArtifactPathCandidate,
} from "@/features/chat/lib/artifactPathPolicy";
export interface ToolCardDisplay {
role: "primary_host" | "none";
primaryCandidate: ArtifactPathCandidate | null;
secondaryCandidates: ArtifactPathCandidate[];
export interface ArtifactLinkCandidate {
resolvedPath: string;
rawPath: string;
line?: number | null;
}
export interface SessionArtifact {
@@ -33,28 +30,18 @@ export interface SessionArtifact {
lastTouchedAt: number;
kind: "file" | "folder" | "path";
toolName: string | null;
toolKind?: ToolKind;
line?: number | null;
}
interface ArtifactPolicyContextValue {
resolveToolCardDisplay: (
args: Record<string, unknown>,
name: string,
result?: string,
) => ToolCardDisplay;
resolveMarkdownHref: (href: string) => ArtifactPathCandidate | null;
resolveMarkdownHref: (href: string) => ArtifactLinkCandidate | null;
pathExists: (path: string) => Promise<boolean>;
openResolvedPath: (path: string) => Promise<void>;
getAllSessionArtifacts: () => SessionArtifact[];
}
const EMPTY_DISPLAY: ToolCardDisplay = {
role: "none",
primaryCandidate: null,
secondaryCandidates: [],
};
const DEFAULT_CONTEXT_VALUE: ArtifactPolicyContextValue = {
resolveToolCardDisplay: () => EMPTY_DISPLAY,
resolveMarkdownHref: () => null,
pathExists: async () => false,
openResolvedPath: async () => {},
@@ -65,11 +52,12 @@ const ArtifactPolicyContext = createContext<ArtifactPolicyContextValue>(
DEFAULT_CONTEXT_VALUE,
);
function shortenPath(fullPath: string, homeDir: string | null): string {
if (homeDir && fullPath.startsWith(homeDir)) {
return `~${fullPath.slice(homeDir.length)}`;
}
return fullPath;
function normalizePath(path: string): string {
return path.replace(/\\/g, "/").trim();
}
function normalizeComparablePath(path: string): string {
return normalizePath(path).replace(/\/+$/, "").toLowerCase();
}
function parentDir(path: string): string {
@@ -83,73 +71,118 @@ function basenameOf(path: string): string {
return parts[parts.length - 1] ?? path;
}
function hasExtension(path: string): boolean {
const name = basenameOf(path);
const dot = name.lastIndexOf(".");
return dot > 0 && dot < name.length - 1;
}
function inferPathKind(path: string): SessionArtifact["kind"] {
const normalized = normalizePath(path);
if (normalized.endsWith("/")) return "folder";
if (hasExtension(normalized)) return "file";
return "path";
}
function isExternalHref(href: string): boolean {
return (
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href) &&
!href.toLowerCase().startsWith("file://")
);
}
function isAbsolutePath(path: string): boolean {
return path.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(path);
}
function resolveRelativeToBase(base: string, relativePath: string): string {
const normalizedBase = normalizePath(base).replace(/\/+$/, "");
const normalizedRelative = normalizePath(relativePath).replace(/^\.\/+/, "");
if (!normalizedRelative || normalizedRelative === ".") return normalizedBase;
const stack = normalizedBase.split("/").filter(Boolean);
const hasWindowsDriveRoot = /^[a-zA-Z]:$/.test(stack[0] ?? "");
for (const segment of normalizedRelative.split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (stack.length > 0) stack.pop();
continue;
}
stack.push(segment);
}
const resolved = stack.join("/");
if (hasWindowsDriveRoot) return resolved;
return `/${resolved}`;
}
function resolvePath(path: string, sessionCwd: string | null): string {
const normalized = normalizePath(path);
if (!normalized) return "";
if (normalized.toLowerCase().startsWith("file://")) {
return normalized.slice("file://".length);
}
if (isAbsolutePath(normalized)) {
return normalized;
}
return sessionCwd
? resolveRelativeToBase(sessionCwd, normalized)
: normalized;
}
function isNonEmptyLocation(
location: ToolCallLocation,
): location is ToolCallLocation & { path: string } {
return typeof location.path === "string" && location.path.trim().length > 0;
}
export function ArtifactPolicyProvider({
messages,
allowedRoots,
sessionCwd,
children,
}: {
messages: Message[];
allowedRoots: string[];
sessionCwd: string | null;
children: ReactNode;
}) {
const normalizedRoots = useMemo(
() => [...new Set(allowedRoots.map((root) => root.trim()).filter(Boolean))],
[allowedRoots],
const normalizedSessionCwd = useMemo(
() => sessionCwd?.trim() || null,
[sessionCwd],
);
const lastOpenAtByPathRef = useRef(new Map<string, number>());
const artifactsIndex = useMemo(
() => buildArtifactsIndexForMessages(messages, normalizedRoots),
[messages, normalizedRoots],
);
const { argsToToolCallId, toolCardDisplayByToolCallId } = useMemo(() => {
const displayByToolCallId = new Map<string, ToolCardDisplay>();
for (const ranking of artifactsIndex.byMessageId.values()) {
if (!ranking.primaryToolCallId || !ranking.primaryCandidate) continue;
if (
!ranking.primaryCandidate.toolName ||
!isWriteOrientedTool(ranking.primaryCandidate.toolName)
) {
continue;
}
displayByToolCallId.set(ranking.primaryToolCallId, {
role: "primary_host",
primaryCandidate: ranking.primaryCandidate,
secondaryCandidates: ranking.secondaryCandidates,
});
}
return {
argsToToolCallId: artifactsIndex.argsToToolCallId,
toolCardDisplayByToolCallId: displayByToolCallId,
};
}, [artifactsIndex]);
const resolveToolCardDisplay = useCallback(
(args: Record<string, unknown>, _name: string, _result?: string) => {
const toolCallId = argsToToolCallId.get(args);
if (!toolCallId) return EMPTY_DISPLAY;
return toolCardDisplayByToolCallId.get(toolCallId) ?? EMPTY_DISPLAY;
},
[argsToToolCallId, toolCardDisplayByToolCallId],
);
const resolveMarkdownHref = useCallback(
(href: string) => resolveMarkdownLocalHref(href, normalizedRoots),
[normalizedRoots],
(href: string): ArtifactLinkCandidate | null => {
const trimmed = href.trim();
if (!trimmed || trimmed.startsWith("#")) return null;
if (trimmed.toLowerCase().startsWith("javascript:")) return null;
if (isExternalHref(trimmed)) return null;
const withoutHash = trimmed.split("#")[0];
const withoutQuery = withoutHash.split("?")[0];
if (!withoutQuery) return null;
return {
rawPath: withoutQuery,
resolvedPath: resolvePath(withoutQuery, normalizedSessionCwd),
};
},
[normalizedSessionCwd],
);
const resolveOpenTarget = useCallback(
async (path: string): Promise<string | null> => {
if (await pathExists(path)) {
return path;
const resolvedPath = resolvePath(path, normalizedSessionCwd);
if (await pathExists(resolvedPath)) {
return resolvedPath;
}
return null;
},
[],
[normalizedSessionCwd],
);
const checkPathExists = useCallback(
@@ -161,7 +194,8 @@ export function ArtifactPolicyProvider({
async (path: string) => {
const resolvedTarget = await resolveOpenTarget(path);
if (!resolvedTarget) {
throw new Error(`File not found: ${path}`);
const cwdMessage = normalizedSessionCwd ?? "<none>";
throw new Error(`File not found: ${path} (session cwd: ${cwdMessage})`);
}
const key = resolvedTarget.trim().toLowerCase();
@@ -173,52 +207,50 @@ export function ArtifactPolicyProvider({
lastOpenAtByPathRef.current.set(key, now);
await openPath(resolvedTarget);
},
[resolveOpenTarget],
[resolveOpenTarget, normalizedSessionCwd],
);
const getAllSessionArtifacts = useCallback((): SessionArtifact[] => {
const homeDir =
normalizedRoots.length > 0
? inferHomeDirFromRoots(normalizedRoots)
: null;
const artifactMap = new Map<string, SessionArtifact>();
for (const [messageId, ranking] of artifactsIndex.byMessageId.entries()) {
const message = messages.find((m) => m.id === messageId);
const timestamp = message?.created ?? 0;
for (const message of messages) {
if (message.role !== "assistant") continue;
if (message.metadata?.userVisible === false) continue;
for (const block of message.content) {
if (block.type !== "toolRequest") continue;
const locations = block.locations?.filter(isNonEmptyLocation) ?? [];
for (const location of locations) {
const resolvedPath = resolvePath(location.path, normalizedSessionCwd);
const key = normalizeComparablePath(resolvedPath);
if (!key) continue;
for (const candidates of ranking.candidatesByToolCallId.values()) {
for (const candidate of candidates) {
if (!candidate.allowed) continue;
if (!candidate.toolName || !isWriteOrientedTool(candidate.toolName)) {
continue;
}
const key = candidate.resolvedPath.trim().toLowerCase();
const existing = artifactMap.get(key);
if (existing) {
existing.versionCount += 1;
if (timestamp > existing.lastTouchedAt) {
existing.lastTouchedAt = timestamp;
existing.toolName = candidate.toolName;
if (message.created > existing.lastTouchedAt) {
existing.lastTouchedAt = message.created;
existing.toolName = block.toolName ?? block.name;
existing.toolKind = block.toolKind;
existing.line = location.line;
}
} else {
artifactMap.set(key, {
resolvedPath: candidate.resolvedPath,
displayPath: shortenPath(candidate.resolvedPath, homeDir),
filename: basenameOf(candidate.resolvedPath),
directoryPath: shortenPath(
parentDir(candidate.resolvedPath),
homeDir,
),
resolvedDirectoryPath: parentDir(candidate.resolvedPath),
versionCount: 1,
lastTouchedAt: timestamp,
kind: candidate.kind,
toolName: candidate.toolName,
});
continue;
}
artifactMap.set(key, {
resolvedPath,
displayPath: resolvedPath,
filename: basenameOf(resolvedPath),
directoryPath: parentDir(resolvedPath),
resolvedDirectoryPath: parentDir(resolvedPath),
versionCount: 1,
lastTouchedAt: message.created,
kind: inferPathKind(resolvedPath),
toolName: block.toolName ?? block.name,
toolKind: block.toolKind,
line: location.line,
});
}
}
}
@@ -226,11 +258,10 @@ export function ArtifactPolicyProvider({
return Array.from(artifactMap.values()).sort(
(a, b) => b.lastTouchedAt - a.lastTouchedAt,
);
}, [messages, normalizedRoots, artifactsIndex]);
}, [messages, normalizedSessionCwd]);
const contextValue = useMemo<ArtifactPolicyContextValue>(
() => ({
resolveToolCardDisplay,
resolveMarkdownHref,
pathExists: checkPathExists,
openResolvedPath,
@@ -241,7 +272,6 @@ export function ArtifactPolicyProvider({
getAllSessionArtifacts,
openResolvedPath,
resolveMarkdownHref,
resolveToolCardDisplay,
],
);
@@ -6,96 +6,39 @@ import {
useArtifactPolicyContext,
} from "../ArtifactPolicyContext";
import { openPath } from "@tauri-apps/plugin-opener";
const mockPathExists = vi.fn<(path: string) => Promise<boolean>>();
vi.mock("@/shared/api/system", () => ({
pathExists: (path: string) => mockPathExists(path),
}));
function Probe({
readArgs,
writeArgs,
clonedWriteArgs,
}: {
readArgs: Record<string, unknown>;
writeArgs: Record<string, unknown>;
clonedWriteArgs: Record<string, unknown>;
}) {
const { resolveToolCardDisplay } = useArtifactPolicyContext();
const readDisplay = resolveToolCardDisplay(readArgs, "read_file");
const writeDisplay = resolveToolCardDisplay(writeArgs, "write_file");
const clonedDisplay = resolveToolCardDisplay(clonedWriteArgs, "write_file");
return (
<div>
<span data-testid="read-role">{readDisplay.role}</span>
<span data-testid="write-role">{writeDisplay.role}</span>
<span data-testid="write-primary">
{writeDisplay.primaryCandidate?.resolvedPath ?? ""}
</span>
<span data-testid="write-secondary-count">
{String(writeDisplay.secondaryCandidates.length)}
</span>
<span data-testid="cloned-role">{clonedDisplay.role}</span>
</div>
);
}
function TextFollowupProbe({
writeArgs,
}: {
writeArgs: Record<string, unknown>;
}) {
const { resolveToolCardDisplay, getAllSessionArtifacts } =
useArtifactPolicyContext();
const display = resolveToolCardDisplay(
writeArgs,
"writing markdown file about alphabet history",
);
function ArtifactsProbe() {
const { getAllSessionArtifacts } = useArtifactPolicyContext();
const artifacts = getAllSessionArtifacts();
return (
<div>
<span data-testid="text-followup-role">{display.role}</span>
<span data-testid="text-followup-path">
{display.primaryCandidate?.resolvedPath ?? ""}
</span>
<span data-testid="text-followup-artifacts">
<span data-testid="artifact-paths">
{artifacts.map((artifact) => artifact.resolvedPath).join(",")}
</span>
<span data-testid="artifact-count">{String(artifacts.length)}</span>
</div>
);
}
function ReadOnlyProbe({ readArgs }: { readArgs: Record<string, unknown> }) {
const { resolveToolCardDisplay, getAllSessionArtifacts } =
useArtifactPolicyContext();
const display = resolveToolCardDisplay(readArgs, "read_file");
const artifacts = getAllSessionArtifacts();
function LinkProbe({ href }: { href: string }) {
const { resolveMarkdownHref } = useArtifactPolicyContext();
const candidate = resolveMarkdownHref(href);
return (
<div>
<span data-testid="read-only-role">{display.role}</span>
<span data-testid="read-only-artifacts">
{artifacts.map((artifact) => artifact.resolvedPath).join(",")}
</span>
<span data-testid="link-path">{candidate?.resolvedPath ?? ""}</span>
</div>
);
}
describe("ArtifactPolicyContext", () => {
it("computes one primary host per message and resolves tool cards by args identity", () => {
mockPathExists.mockReset();
vi.mocked(openPath).mockReset();
const readArgs = { path: "/Users/test/project-a/notes.md" };
const writeArgs = {
paths: [
"/Users/test/project-a/output/final_report.md",
"/Users/test/project-a/output/notes.md",
],
};
it("uses reported ACP tool locations as session artifacts", () => {
const messages: Message[] = [
{
id: "assistant-1",
@@ -106,28 +49,16 @@ describe("ArtifactPolicyContext", () => {
type: "toolRequest",
id: "tool-1",
name: "read_file",
arguments: readArgs,
arguments: {},
status: "completed",
toolKind: "read",
locations: [{ path: "/Users/test/project-a/notes.md" }],
},
{
type: "toolResponse",
id: "tool-1",
name: "read_file",
result: "Read /Users/test/project-a/notes.md",
isError: false,
},
{
type: "toolRequest",
id: "tool-2",
name: "write_file",
arguments: writeArgs,
status: "completed",
},
{
type: "toolResponse",
id: "tool-2",
name: "write_file",
result: "Created /Users/test/project-a/output/final_report.md",
result: "Read notes",
isError: false,
},
],
@@ -137,96 +68,33 @@ describe("ArtifactPolicyContext", () => {
render(
<ArtifactPolicyProvider
messages={messages}
allowedRoots={["/Users/test/project-a", "/Users/test"]}
sessionCwd="/Users/test/project-a"
>
<Probe
readArgs={readArgs}
writeArgs={writeArgs}
clonedWriteArgs={{ ...writeArgs }}
/>
<ArtifactsProbe />
</ArtifactPolicyProvider>,
);
expect(screen.getByTestId("read-role")).toHaveTextContent("none");
expect(screen.getByTestId("write-role")).toHaveTextContent("primary_host");
expect(screen.getByTestId("write-primary")).toHaveTextContent(
"/Users/test/project-a/output/final_report.md",
expect(screen.getByTestId("artifact-count")).toHaveTextContent("1");
expect(screen.getByTestId("artifact-paths")).toHaveTextContent(
"/Users/test/project-a/notes.md",
);
expect(
Number(screen.getByTestId("write-secondary-count").textContent),
).toBeGreaterThan(0);
expect(screen.getByTestId("cloned-role")).toHaveTextContent("none");
});
it("does not treat read-only tool paths as session artifacts", () => {
mockPathExists.mockReset();
vi.mocked(openPath).mockReset();
const readArgs = { path: "/Users/test/project-a/notes.md" };
const messages: Message[] = [
{
id: "assistant-read-only",
role: "assistant",
created: Date.now(),
content: [
{
type: "toolRequest",
id: "tool-read",
name: "read_file",
arguments: readArgs,
status: "completed",
},
{
type: "toolResponse",
id: "tool-read",
name: "read_file",
result: "Read /Users/test/project-a/notes.md",
isError: false,
},
],
},
];
render(
<ArtifactPolicyProvider
messages={messages}
allowedRoots={["/Users/test/project-a", "/Users/test"]}
>
<ReadOnlyProbe readArgs={readArgs} />
</ArtifactPolicyProvider>,
);
expect(screen.getByTestId("read-only-role")).toHaveTextContent("none");
expect(screen.getByTestId("read-only-artifacts")).toHaveTextContent("");
});
it("uses assistant text after a tool call to populate file actions and the Files tab", () => {
mockPathExists.mockReset();
vi.mocked(openPath).mockReset();
const writeArgs = {};
it("does not filter reported locations outside allowed roots", () => {
const messages: Message[] = [
{
id: "assistant-1",
role: "assistant",
created: Date.now(),
metadata: { userVisible: true, agentVisible: true },
content: [
{
type: "toolRequest",
id: "tool-1",
name: "writing markdown file about alphabet history",
arguments: writeArgs,
name: "write_file",
arguments: {},
status: "completed",
},
{
type: "toolResponse",
id: "tool-1",
name: "writing markdown file about alphabet history",
result: "completed",
isError: false,
},
{
type: "text",
text: "The file alpha.md has been created at /Users/test/alpha.md.",
toolKind: "edit",
locations: [{ path: "/tmp/outside.md" }],
},
],
},
@@ -235,20 +103,26 @@ describe("ArtifactPolicyContext", () => {
render(
<ArtifactPolicyProvider
messages={messages}
allowedRoots={["/Users/test"]}
sessionCwd="/Users/test/project-a"
>
<TextFollowupProbe writeArgs={writeArgs} />
<ArtifactsProbe />
</ArtifactPolicyProvider>,
);
expect(screen.getByTestId("text-followup-role")).toHaveTextContent(
"primary_host",
expect(screen.getByTestId("artifact-paths")).toHaveTextContent(
"/tmp/outside.md",
);
expect(screen.getByTestId("text-followup-path")).toHaveTextContent(
"/Users/test/alpha.md",
});
it("resolves local markdown hrefs relative to the session cwd", () => {
render(
<ArtifactPolicyProvider messages={[]} sessionCwd="/Users/test/app">
<LinkProbe href="output/report.md" />
</ArtifactPolicyProvider>,
);
expect(screen.getByTestId("text-followup-artifacts")).toHaveTextContent(
"/Users/test/alpha.md",
expect(screen.getByTestId("link-path")).toHaveTextContent(
"/Users/test/app/output/report.md",
);
});
});
@@ -1,52 +1,33 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { ArtifactPathCandidate } from "@/features/chat/lib/artifactPathPolicy";
// ── mocks ────────────────────────────────────────────────────────────
import type { ArtifactLinkCandidate } from "@/features/chat/hooks/ArtifactPolicyContext";
const mockResolveMarkdownHref =
vi.fn<(href: string) => ArtifactPathCandidate | null>();
vi.fn<(href: string) => ArtifactLinkCandidate | null>();
const mockOpenResolvedPath = vi.fn<(path: string) => Promise<void>>();
vi.mock("@/features/chat/hooks/ArtifactPolicyContext", () => ({
useArtifactPolicyContext: () => ({
resolveToolCardDisplay: () => ({
role: "none",
primaryCandidate: null,
secondaryCandidates: [],
}),
resolveMarkdownHref: mockResolveMarkdownHref,
pathExists: async () => false,
openResolvedPath: mockOpenResolvedPath,
getAllSessionArtifacts: () => [],
}),
}));
import { useArtifactLinkHandler } from "../useArtifactLinkHandler";
// ── helpers ──────────────────────────────────────────────────────────
function makeCandidate(
overrides: Partial<ArtifactPathCandidate> = {},
): ArtifactPathCandidate {
overrides: Partial<ArtifactLinkCandidate> = {},
): ArtifactLinkCandidate {
return {
id: "md-1",
rawPath: "/project/report.md",
resolvedPath: "/Users/test/project/report.md",
source: "arg_key",
confidence: "high",
kind: "file",
allowed: true,
blockedReason: null,
toolCallId: null,
toolName: null,
toolCallIndex: 0,
appearanceIndex: 0,
...overrides,
};
}
/** Renders a container with the click handler and an anchor link inside. */
function Harness({ href, label }: { href: string; label: string }) {
const { handleContentClick, pathNotice } = useArtifactLinkHandler();
return (
@@ -59,7 +40,6 @@ function Harness({ href, label }: { href: string; label: string }) {
);
}
/** Renders a container with a non-link element. */
function HarnessNoLink() {
const { handleContentClick, pathNotice } = useArtifactLinkHandler();
return (
@@ -72,15 +52,13 @@ function HarnessNoLink() {
);
}
// ── tests ────────────────────────────────────────────────────────────
describe("useArtifactLinkHandler", () => {
beforeEach(() => {
mockResolveMarkdownHref.mockReset();
mockOpenResolvedPath.mockReset();
});
it("calls resolveMarkdownHref and openResolvedPath for allowed local links", async () => {
it("opens resolved local links", async () => {
const user = userEvent.setup();
const candidate = makeCandidate();
mockResolveMarkdownHref.mockReturnValue(candidate);
@@ -93,24 +71,24 @@ describe("useArtifactLinkHandler", () => {
expect(mockOpenResolvedPath).toHaveBeenCalledWith(candidate.resolvedPath);
});
it("shows blocked notice for disallowed paths", async () => {
it("shows opener errors", async () => {
const user = userEvent.setup();
const blocked = makeCandidate({
allowed: false,
blockedReason: "Path is outside allowed roots.",
});
mockResolveMarkdownHref.mockReturnValue(blocked);
mockResolveMarkdownHref.mockReturnValue(
makeCandidate({ resolvedPath: "/secret/data.md" }),
);
mockOpenResolvedPath.mockRejectedValue(
new Error("File not found: /secret/data.md"),
);
render(<Harness href="/secret/data.md" label="Secret" />);
await user.click(screen.getByText("Secret"));
expect(mockOpenResolvedPath).not.toHaveBeenCalled();
expect(screen.getByTestId("notice")).toHaveTextContent(
"Path is outside allowed roots.",
"File not found: /secret/data.md",
);
});
it("does not intercept external URLs (defers to MarkdownLink's LinkSafetyModal)", async () => {
it("does not intercept external URLs", async () => {
const user = userEvent.setup();
render(<Harness href="https://example.com" label="External" />);
@@ -129,20 +107,4 @@ describe("useArtifactLinkHandler", () => {
expect(mockResolveMarkdownHref).not.toHaveBeenCalled();
expect(mockOpenResolvedPath).not.toHaveBeenCalled();
});
it("shows default blocked reason when blockedReason is null", async () => {
const user = userEvent.setup();
const blocked = makeCandidate({
allowed: false,
blockedReason: null,
});
mockResolveMarkdownHref.mockReturnValue(blocked);
render(<Harness href="/outside/file.md" label="Blocked" />);
await user.click(screen.getByText("Blocked"));
expect(screen.getByTestId("notice")).toHaveTextContent(
"Path is outside allowed roots.",
);
});
});
@@ -28,13 +28,6 @@ export function useArtifactLinkHandler() {
const resolved = resolveMarkdownHref(href);
if (!resolved) return;
if (!resolved.allowed) {
setPathNotice(
resolved.blockedReason || "Path is outside allowed roots.",
);
return;
}
setPathNotice(null);
void openResolvedPath(resolved.resolvedPath).catch((err) => {
setPathNotice(err instanceof Error ? err.message : String(err));
@@ -14,7 +14,6 @@ import { resolveAgentProviderCatalogIdStrict } from "@/features/providers/provid
import {
buildProjectSystemPrompt,
composeSystemPrompt,
getProjectArtifactRoots,
resolveProjectDefaultArtifactRoot,
} from "@/features/projects/lib/chatProjectContext";
import { setStoredModelPreference } from "../lib/modelPreferences";
@@ -107,10 +106,10 @@ export function useChatSessionController({
const selectedPersona = personas.find(
(persona) => persona.id === selectedPersonaId,
);
const projectArtifactRoots = useMemo(
() => getProjectArtifactRoots(project),
[project],
);
const sessionCwd =
activeWorkspace?.path ??
session?.workingDir ??
resolveProjectDefaultArtifactRoot(project);
const projectDefaultArtifactRoot = useMemo(
() => resolveProjectDefaultArtifactRoot(project),
[project],
@@ -118,13 +117,9 @@ export function useChatSessionController({
const projectMetadataPending = Boolean(
effectiveProjectId && !projectDefaultArtifactRoot && projectsLoading,
);
const allowedArtifactRoots = useMemo(
() => [
...new Set(
projectArtifactRoots.map((path) => path.trim()).filter(Boolean),
),
],
[projectArtifactRoots],
const sessionArtifactCwd = useMemo(
() => sessionCwd?.trim() || null,
[sessionCwd],
);
const availableProjects = useMemo(
() =>
@@ -792,7 +787,7 @@ export function useChatSessionController({
return {
session,
project,
allowedArtifactRoots,
sessionArtifactCwd,
messages,
chatState,
tokenState: resolvedTokenState,
@@ -1,390 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildArtifactsIndexForMessages,
dedupeAndRankCandidates,
evaluatePathScope,
extractToolCallCandidates,
rankMessageToolArtifacts,
resolvePathCandidate,
} from "../artifactPathPolicy";
const roots = ["/Users/test/project-a", "/Users/test/project-b", "/Users/test"];
describe("artifactPathPolicy", () => {
it("prefers the latest write-oriented tool call over earlier tool calls", () => {
const ranking = rankMessageToolArtifacts(
[
{
toolCallId: "read-1",
toolName: "read_file",
args: { path: "/Users/test/project-a/notes.md" },
toolCallIndex: 0,
},
{
toolCallId: "write-1",
toolName: "write_file",
args: { path: "/Users/test/project-a/result.md" },
toolCallIndex: 1,
},
],
roots,
);
expect(ranking.primaryToolCallId).toBe("write-1");
expect(ranking.primaryCandidate?.resolvedPath).toBe(
"/Users/test/project-a/result.md",
);
});
it("boosts filename and output-directory signals", () => {
const ranking = rankMessageToolArtifacts(
[
{
toolCallId: "write-1",
toolName: "write_file",
args: {
paths: [
"/Users/test/project-a/notes.md",
"/Users/test/project-a/output/final_report.md",
],
},
toolCallIndex: 0,
},
],
roots,
);
expect(ranking.primaryCandidate?.resolvedPath).toBe(
"/Users/test/project-a/output/final_report.md",
);
});
it("uses appearance order as tie-breaker when signals are equal", () => {
const ranking = rankMessageToolArtifacts(
[
{
toolCallId: "write-1",
toolName: "write_file",
args: {
paths: [
"/Users/test/project-a/a.txt",
"/Users/test/project-a/b.txt",
],
},
toolCallIndex: 0,
},
],
roots,
);
expect(ranking.primaryCandidate?.resolvedPath).toBe(
"/Users/test/project-a/b.txt",
);
});
it("dedupes equivalent resolved paths", () => {
const candidates = extractToolCallCandidates(
{
toolCallId: "write-1",
toolName: "write_file",
args: { path: "/Users/test/project-a/report.md" },
result: "Wrote /Users/test/project-a/report.md successfully",
toolCallIndex: 0,
},
roots,
);
const deduped = dedupeAndRankCandidates(candidates);
expect(deduped).toHaveLength(1);
expect(deduped[0].resolvedPath).toBe("/Users/test/project-a/report.md");
});
it("allows paths inside any configured root and blocks others", () => {
const resolvedAllowed = resolvePathCandidate(
"/Users/test/project-b/output/summary.md",
roots,
);
const allowed = evaluatePathScope(resolvedAllowed, roots);
expect(allowed.allowed).toBe(true);
expect(allowed.blockedReason).toBeNull();
const blocked = evaluatePathScope("/Users/other/outside/file.md", roots);
expect(blocked.allowed).toBe(false);
expect(blocked.blockedReason).toContain("outside allowed");
});
it("allows explicit write outputs outside default roots", () => {
const ranking = rankMessageToolArtifacts(
[
{
toolCallId: "write-1",
toolName: "Write coffee_shop_inventory.csv",
args: {},
result: "/Users/test/Desktop/coffee_shop_inventory.csv (new)",
toolCallIndex: 0,
},
],
["/Users/test"],
);
expect(ranking.primaryCandidate?.resolvedPath).toBe(
"/Users/test/Desktop/coffee_shop_inventory.csv",
);
expect(ranking.primaryCandidate?.allowed).toBe(true);
expect(ranking.primaryCandidate?.blockedReason).toBeNull();
});
it("keeps write arg-key paths outside default roots blocked until a result confirms them", () => {
const candidates = extractToolCallCandidates(
{
toolCallId: "write-1",
toolName: "write_file",
args: { path: "/Users/test/Desktop/coffee_shop_inventory.csv" },
toolCallIndex: 0,
},
["/Users/test/project-a"],
);
expect(candidates).toHaveLength(1);
expect(candidates[0].resolvedPath).toBe(
"/Users/test/Desktop/coffee_shop_inventory.csv",
);
expect(candidates[0].allowed).toBe(false);
expect(candidates[0].blockedReason).toContain("outside allowed");
});
it("keeps write-origin candidates and drops noisy non-write regex candidates", () => {
const ranking = rankMessageToolArtifacts(
[
{
toolCallId: "ls-1",
toolName: "ls /Users/test",
args: {},
result: "small_business_issues_report.md\nrandom.json",
toolCallIndex: 0,
},
{
toolCallId: "write-1",
toolName: "Write weather-dashboard.html",
args: {},
result: "Created weather-dashboard.html",
toolCallIndex: 1,
},
],
roots,
);
expect(ranking.primaryToolCallId).toBe("write-1");
expect(ranking.primaryCandidate?.resolvedPath).toContain(
"weather-dashboard.html",
);
expect(
ranking.secondaryCandidates.some((candidate) =>
candidate.resolvedPath.includes("small_business_issues_report.md"),
),
).toBe(false);
});
it("extracts command-style output paths from tool titles", () => {
const ranking = rankMessageToolArtifacts(
[
{
toolCallId: "cmd-1",
toolName:
"python3 scripts/build_dashboard.py --output output/table.csv",
args: {},
toolCallIndex: 0,
},
],
roots,
);
expect(ranking.primaryCandidate?.resolvedPath).toBe(
"/Users/test/project-a/output/table.csv",
);
expect(ranking.primaryCandidate?.allowed).toBe(true);
});
it("extracts command-style output paths from command args", () => {
const ranking = rankMessageToolArtifacts(
[
{
toolCallId: "cmd-1",
toolName: "functions.exec_command",
args: {
cmd: "python3 scripts/export.py > output/weather-dashboard.html",
},
toolCallIndex: 0,
},
],
roots,
);
expect(ranking.primaryCandidate?.resolvedPath).toBe(
"/Users/test/project-a/output/weather-dashboard.html",
);
expect(ranking.primaryCandidate?.allowed).toBe(true);
});
it("does not treat html tags as local paths", () => {
const candidates = extractToolCallCandidates(
{
toolCallId: "write-1",
toolName: "write_file",
args: {},
result:
"</html>\n</body>\n<script>\nCreated /Users/test/project-a/output/report.html",
toolCallIndex: 0,
},
roots,
);
expect(
candidates.some((candidate) => candidate.rawPath.includes("</html>")),
).toBe(false);
expect(
candidates.some((candidate) => candidate.rawPath.includes("</body>")),
).toBe(false);
expect(
candidates.some((candidate) =>
candidate.resolvedPath.includes("/output/report.html"),
),
).toBe(true);
});
it("prefers explicit absolute path from write result when it is allowed", () => {
const ranking = rankMessageToolArtifacts(
[
{
toolCallId: "write-1",
toolName: "Write button-interactions.html",
args: {},
result:
"The file has been created at `/Users/test/button-interactions.html`.",
toolCallIndex: 0,
},
],
["/Users/test", "/Users/test"],
);
expect(ranking.primaryCandidate?.resolvedPath).toBe(
"/Users/test/button-interactions.html",
);
});
it("extracts artifact paths from assistant text that follows tool calls", () => {
const result = buildArtifactsIndexForMessages(
[
{
id: "assistant-1",
role: "assistant",
created: Date.now(),
metadata: { userVisible: true, agentVisible: true },
content: [
{
type: "toolRequest",
id: "tool-1",
name: "writing markdown file about alphabet history",
arguments: {},
status: "completed",
},
{
type: "toolResponse",
id: "tool-1",
name: "writing markdown file about alphabet history",
result: "completed",
isError: false,
},
{
type: "text",
text: "The file alpha.md has been created at /Users/test/alpha.md.",
},
],
},
],
["/Users/test"],
);
const ranking = result.byMessageId.get("assistant-1");
expect(ranking?.primaryToolCallId).toBe("tool-1");
expect(ranking?.primaryCandidate?.resolvedPath).toBe(
"/Users/test/alpha.md",
);
expect(ranking?.primaryCandidate?.allowed).toBe(true);
});
it("uses semantic tool names instead of display titles for write detection", () => {
const result = buildArtifactsIndexForMessages(
[
{
id: "assistant-1",
role: "assistant",
created: Date.now(),
metadata: { userVisible: true, agentVisible: true },
content: [
{
type: "toolRequest",
id: "tool-1",
name: "Writing project summary",
toolName: "write_file",
arguments: { path: "/Users/test/project-a/summary.md" },
status: "completed",
},
{
type: "toolResponse",
id: "tool-1",
name: "Writing project summary",
result: "Created /Users/test/project-a/summary.md",
isError: false,
},
],
},
],
roots,
);
const ranking = result.byMessageId.get("assistant-1");
expect(ranking?.primaryToolCallId).toBe("tool-1");
expect(ranking?.primaryCandidate?.toolName).toBe("write_file");
expect(ranking?.primaryCandidate?.resolvedPath).toBe(
"/Users/test/project-a/summary.md",
);
});
it("prefers an allowed candidate as primary when top-ranked candidate is blocked", () => {
const ranking = rankMessageToolArtifacts(
[
{
toolCallId: "write-1",
toolName: "Write button-interactions.html",
args: {},
result:
"Created `/Users/test/button-interactions.html` successfully.",
toolCallIndex: 0,
},
],
["/Users/test"],
);
expect(ranking.primaryCandidate?.allowed).toBe(true);
expect(ranking.primaryCandidate?.resolvedPath).toBe(
"/Users/test/button-interactions.html",
);
});
it("treats unix absolute paths outside the previous whitelist as absolute", () => {
const resolved = resolvePathCandidate("/opt/workspace/report.md", roots);
expect(resolved).toBe("/opt/workspace/report.md");
});
it("preserves Windows drive roots when resolving relative paths", () => {
const windowsRoots = ["C:/Users/test/project-a", "C:/Users/test"];
const resolved = resolvePathCandidate(
"output/final_report.md",
windowsRoots,
);
expect(resolved).toBe("C:/Users/test/project-a/output/final_report.md");
expect(evaluatePathScope(resolved, windowsRoots).allowed).toBe(true);
});
});
@@ -1,139 +0,0 @@
type ToolNamePathCandidate = {
rawPath: string;
source: "arg_key" | "result_regex";
confidence: "high" | "low";
fromResultText: boolean;
};
const COMMAND_ARG_KEYS = ["cmd", "command"] as const;
function stripEnclosingQuotes(token: string): string {
const trimmed = token.trim();
if (!trimmed) return "";
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith("`") && trimmed.endsWith("`"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function splitCommandTokens(command: string): string[] {
// Preserve quoted segments so paths with spaces stay intact.
const tokens = command.match(/"[^"]*"|'[^']*'|`[^`]*`|\S+/g);
return tokens ?? [];
}
function extractCommandPathCandidates(
command: string,
isLikelyLocalPath: (candidate: string) => boolean,
stripTokenPunctuation: (token: string) => string,
): string[] {
const tokens = splitCommandTokens(command);
if (tokens.length === 0) return [];
const candidates: string[] = [];
const pushIfLocalPath = (rawToken?: string) => {
if (!rawToken) return;
const normalized = stripTokenPunctuation(stripEnclosingQuotes(rawToken));
if (!isLikelyLocalPath(normalized)) return;
candidates.push(normalized);
};
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
const normalizedToken = token.toLowerCase();
if (/^(?:\d?>|>>|\d?>>)$/.test(token)) {
pushIfLocalPath(tokens[index + 1]);
continue;
}
if (
normalizedToken === "-o" ||
normalizedToken === "--output" ||
normalizedToken === "--out" ||
normalizedToken === "--save" ||
normalizedToken === "--file" ||
normalizedToken === "--path" ||
normalizedToken === "--output-file"
) {
pushIfLocalPath(tokens[index + 1]);
continue;
}
const equalsMatch = token.match(
/^--(?:output|out|save|file|path|output-file)=(.+)$/i,
);
if (equalsMatch) {
pushIfLocalPath(equalsMatch[1]);
}
}
return candidates;
}
export function extractToolNamePathCandidates(
toolName: string,
isLikelyLocalPath: (candidate: string) => boolean,
stripTokenPunctuation: (token: string) => string,
): ToolNamePathCandidate[] {
const matches: ToolNamePathCandidate[] = [];
const trimmed = toolName.trim();
if (!trimmed) return matches;
const match = trimmed.match(/^(write|create|save)\s+(.+)$/i);
if (match) {
const remainder = match[2].trim();
if (remainder) {
const quoted = remainder.match(/^["'`](.+?)["'`]$/);
const token = quoted ? quoted[1] : remainder.split(/\s+/)[0];
const cleaned = stripTokenPunctuation(token);
if (isLikelyLocalPath(cleaned)) {
matches.push({
rawPath: cleaned,
source: "arg_key",
confidence: "high",
fromResultText: false,
});
}
}
}
for (const rawPath of extractCommandPathCandidates(
trimmed,
isLikelyLocalPath,
stripTokenPunctuation,
)) {
matches.push({
rawPath,
source: "result_regex",
confidence: "low",
fromResultText: false,
});
}
return matches;
}
export function collectCommandArgPathCandidates(
args: Record<string, unknown>,
isLikelyLocalPath: (candidate: string) => boolean,
stripTokenPunctuation: (token: string) => string,
): string[] {
const values: string[] = [];
for (const key of COMMAND_ARG_KEYS) {
const value = args[key];
if (typeof value !== "string" || !value.trim()) continue;
values.push(
...extractCommandPathCandidates(
value,
isLikelyLocalPath,
stripTokenPunctuation,
),
);
}
return values;
}
@@ -1,270 +0,0 @@
import type { Message } from "@/shared/types/messages";
import {
confidenceRank,
extractToolCallCandidates,
filenameSignalBoost,
isWriteOrientedTool,
normalizeComparablePath,
outputPathSignalBoost,
sourceRank,
type ArtifactPathCandidate,
type BuildArtifactsIndexResult,
type MessageArtifactsRanking,
type ToolCallArtifactInput,
} from "./artifactPathPolicyCore";
export {
evaluatePathScope,
extractToolCallCandidates,
inferHomeDirFromRoots,
isWriteOrientedTool,
normalizePath,
resolveMarkdownLocalHref,
resolvePathCandidate,
type ArtifactCandidateConfidence,
type ArtifactCandidateKind,
type ArtifactCandidateSource,
type ArtifactPathCandidate,
type BuildArtifactsIndexResult,
type MessageArtifactsRanking,
type ToolCallArtifactInput,
} from "./artifactPathPolicyCore";
function compareCandidates(
left: ArtifactPathCandidate,
right: ArtifactPathCandidate,
latestWriteToolCallIndex: number,
): number {
const leftLatestWriteBoost =
left.toolCallIndex === latestWriteToolCallIndex &&
left.toolName &&
isWriteOrientedTool(left.toolName)
? 1
: 0;
const rightLatestWriteBoost =
right.toolCallIndex === latestWriteToolCallIndex &&
right.toolName &&
isWriteOrientedTool(right.toolName)
? 1
: 0;
if (leftLatestWriteBoost !== rightLatestWriteBoost) {
return rightLatestWriteBoost - leftLatestWriteBoost;
}
const leftFilenameBoost = filenameSignalBoost(left.resolvedPath);
const rightFilenameBoost = filenameSignalBoost(right.resolvedPath);
if (leftFilenameBoost !== rightFilenameBoost) {
return rightFilenameBoost - leftFilenameBoost;
}
const leftPathBoost = outputPathSignalBoost(left.resolvedPath);
const rightPathBoost = outputPathSignalBoost(right.resolvedPath);
if (leftPathBoost !== rightPathBoost) {
return rightPathBoost - leftPathBoost;
}
if (left.appearanceIndex !== right.appearanceIndex) {
return right.appearanceIndex - left.appearanceIndex;
}
const leftConfidence = confidenceRank(left.confidence);
const rightConfidence = confidenceRank(right.confidence);
if (leftConfidence !== rightConfidence) {
return rightConfidence - leftConfidence;
}
return sourceRank(right.source) - sourceRank(left.source);
}
export function dedupeAndRankCandidates(
candidates: ArtifactPathCandidate[],
): ArtifactPathCandidate[] {
const hasWriteCandidates = candidates.some(
(candidate) =>
candidate.toolName && isWriteOrientedTool(candidate.toolName),
);
const candidatePool = hasWriteCandidates
? candidates.filter(
(candidate) =>
candidate.toolName && isWriteOrientedTool(candidate.toolName),
)
: candidates;
const latestWriteToolCallIndex = candidates
.filter(
(candidate) =>
candidate.toolName && isWriteOrientedTool(candidate.toolName),
)
.reduce((max, candidate) => Math.max(max, candidate.toolCallIndex), -1);
const ranked = [...candidatePool].sort((left, right) =>
compareCandidates(left, right, latestWriteToolCallIndex),
);
const deduped: ArtifactPathCandidate[] = [];
const seenPaths = new Set<string>();
for (const candidate of ranked) {
const key = normalizeComparablePath(candidate.resolvedPath);
if (!key || seenPaths.has(key)) continue;
seenPaths.add(key);
deduped.push(candidate);
}
return deduped;
}
export function rankMessageToolArtifacts(
toolCalls: ToolCallArtifactInput[],
allowedRoots: string[],
): MessageArtifactsRanking {
let appearanceIndex = 0;
const allCandidates: ArtifactPathCandidate[] = [];
const argsByToolCallId = new Map<string, Record<string, unknown>>();
for (const toolCall of toolCalls) {
argsByToolCallId.set(toolCall.toolCallId, toolCall.args);
const candidates = extractToolCallCandidates(
toolCall,
allowedRoots,
appearanceIndex,
);
appearanceIndex += candidates.length;
allCandidates.push(...candidates);
}
const ranked = dedupeAndRankCandidates(allCandidates);
const firstAllowedIndex = ranked.findIndex((candidate) => candidate.allowed);
const primaryIndex = firstAllowedIndex === -1 ? 0 : firstAllowedIndex;
const primaryCandidate = ranked[primaryIndex] ?? null;
const primaryToolCallId = primaryCandidate?.toolCallId ?? null;
const secondaryCandidates = ranked.filter(
(_candidate, index) => index !== primaryIndex,
);
const candidatesByToolCallId = new Map<string, ArtifactPathCandidate[]>();
for (const toolCall of toolCalls) {
candidatesByToolCallId.set(toolCall.toolCallId, []);
}
for (const candidate of ranked) {
if (!candidate.toolCallId) continue;
if (!candidatesByToolCallId.has(candidate.toolCallId)) {
candidatesByToolCallId.set(candidate.toolCallId, []);
}
candidatesByToolCallId.get(candidate.toolCallId)?.push(candidate);
}
return {
primaryToolCallId,
primaryCandidate,
secondaryCandidates,
candidatesByToolCallId,
argsByToolCallId,
};
}
function toSafeRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
function findPreferredToolCallIdForText(
orderedIds: string[],
byId: Map<string, ToolCallArtifactInput>,
): string | null {
for (let index = orderedIds.length - 1; index >= 0; index -= 1) {
const toolCallId = orderedIds[index];
const toolCall = byId.get(toolCallId);
if (toolCall?.toolName && isWriteOrientedTool(toolCall.toolName)) {
return toolCallId;
}
}
return orderedIds[orderedIds.length - 1] ?? null;
}
function extractToolCallsFromMessage(
message: Message,
): ToolCallArtifactInput[] {
const byId = new Map<string, ToolCallArtifactInput>();
const orderedIds: string[] = [];
let toolCallIndex = 0;
for (const block of message.content) {
if (block.type === "toolRequest") {
const toolName = block.toolName ?? block.name;
if (!byId.has(block.id)) {
orderedIds.push(block.id);
byId.set(block.id, {
toolCallId: block.id,
toolName,
args: toSafeRecord(block.arguments),
toolCallIndex,
});
toolCallIndex += 1;
} else {
const existing = byId.get(block.id);
if (existing) {
existing.toolName = toolName || existing.toolName;
existing.args = toSafeRecord(block.arguments);
}
}
continue;
}
if (block.type === "toolResponse") {
if (!byId.has(block.id)) {
orderedIds.push(block.id);
byId.set(block.id, {
toolCallId: block.id,
toolName: block.name,
args: {},
result: block.result,
toolCallIndex,
});
toolCallIndex += 1;
} else {
const existing = byId.get(block.id);
if (existing) {
existing.toolName = existing.toolName || block.name;
existing.result = block.result;
}
}
}
if (block.type === "text") {
const targetToolCallId = findPreferredToolCallIdForText(orderedIds, byId);
if (!targetToolCallId) continue;
const existing = byId.get(targetToolCallId);
if (!existing) continue;
existing.result = existing.result
? `${existing.result}\n${block.text}`
: block.text;
}
}
return orderedIds
.map((toolCallId) => byId.get(toolCallId))
.filter((value): value is ToolCallArtifactInput => Boolean(value));
}
export function buildArtifactsIndexForMessages(
messages: Message[],
allowedRoots: string[],
): BuildArtifactsIndexResult {
const byMessageId = new Map<string, MessageArtifactsRanking>();
const argsToToolCallId = new WeakMap<Record<string, unknown>, string>();
for (const message of messages) {
if (message.role !== "assistant") continue;
if (message.metadata?.userVisible === false) continue;
const toolCalls = extractToolCallsFromMessage(message);
if (toolCalls.length === 0) continue;
const ranking = rankMessageToolArtifacts(toolCalls, allowedRoots);
byMessageId.set(message.id, ranking);
for (const [toolCallId, args] of ranking.argsByToolCallId.entries()) {
argsToToolCallId.set(args, toolCallId);
}
}
return { byMessageId, argsToToolCallId };
}
@@ -1,485 +0,0 @@
import {
collectCommandArgPathCandidates,
extractToolNamePathCandidates,
} from "@/features/chat/lib/artifactPathCommandExtraction";
import { isExternalHref } from "@/shared/lib/isExternalHref";
export type ArtifactCandidateSource =
| "arg_key"
| "result_regex"
| "markdown_href";
export type ArtifactCandidateConfidence = "high" | "low";
export type ArtifactCandidateKind = "file" | "folder" | "path";
export interface ArtifactPathCandidate {
id: string;
rawPath: string;
resolvedPath: string;
source: ArtifactCandidateSource;
confidence: ArtifactCandidateConfidence;
kind: ArtifactCandidateKind;
allowed: boolean;
blockedReason: string | null;
toolCallId: string | null;
toolName: string | null;
toolCallIndex: number;
appearanceIndex: number;
}
export interface ToolCallArtifactInput {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
result?: string;
toolCallIndex: number;
}
export interface MessageArtifactsRanking {
primaryToolCallId: string | null;
primaryCandidate: ArtifactPathCandidate | null;
secondaryCandidates: ArtifactPathCandidate[];
candidatesByToolCallId: Map<string, ArtifactPathCandidate[]>;
argsByToolCallId: Map<string, Record<string, unknown>>;
}
export interface BuildArtifactsIndexResult {
byMessageId: Map<string, MessageArtifactsRanking>;
argsToToolCallId: WeakMap<Record<string, unknown>, string>;
}
const PATH_ARG_KEYS = [
"path",
"file_path",
"filepath",
"output_path",
"target",
"to",
"destination",
] as const;
const PATH_LIST_ARG_KEYS = ["paths", "files"] as const;
const ROUTE_SEGMENTS = new Set([
"agents",
"chat",
"extensions",
"home",
"project",
"projects",
"settings",
"session",
"skills",
"tasks",
]);
const FILENAME_SIGNAL_WORDS = [
"report",
"summary",
"result",
"output",
"final",
"analysis",
];
function shortToolName(toolName: string): string {
const normalized = toolName.trim().toLowerCase();
if (normalized.includes(" ")) {
return normalized;
}
if (normalized.includes("__")) {
return normalized.slice(normalized.lastIndexOf("__") + 2);
}
if (normalized.includes(".")) {
return normalized.slice(normalized.lastIndexOf(".") + 1);
}
return normalized;
}
export function isWriteOrientedTool(toolName: string): boolean {
const normalized = toolName.trim().toLowerCase();
if (
/^(write|writing|create|creating|save|saving|edit|editing|update|updating|modify|modifying|patch|patching)\b/.test(
normalized,
)
) {
return true;
}
const shortName = shortToolName(toolName);
return (
shortName.includes("write_file") ||
shortName.includes("create_file") ||
shortName.includes("save_file") ||
shortName.includes("edit_file") ||
shortName.includes("update_file") ||
shortName.includes("modify_file")
);
}
export function sourceRank(source: ArtifactCandidateSource): number {
switch (source) {
case "arg_key":
return 3;
case "markdown_href":
return 2;
case "result_regex":
return 1;
default:
return 0;
}
}
export function confidenceRank(
confidence: ArtifactCandidateConfidence,
): number {
return confidence === "high" ? 2 : 1;
}
export function normalizePath(path: string): string {
return path.replace(/\\/g, "/").trim();
}
export function normalizeComparablePath(path: string): string {
return normalizePath(path).replace(/\/+$/, "").toLowerCase();
}
function hasKnownScheme(value: string): boolean {
return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value);
}
function isLikelyAbsoluteFilesystemPath(candidate: string): boolean {
if (/^[a-zA-Z]:[\\/]/.test(candidate)) return true;
if (!candidate.startsWith("/")) return false;
const firstSegment = candidate.replace(/^\/+/, "").split("/")[0];
if (!firstSegment) return true;
if (ROUTE_SEGMENTS.has(firstSegment.toLowerCase())) return false;
return true;
}
function isLikelyLocalPath(candidate: string): boolean {
if (!candidate) return false;
if (candidate === "." || candidate === "..") return false;
if (candidate.includes("<") || candidate.includes(">")) return false;
if (/^<\/?[a-zA-Z][^>]*>$/.test(candidate)) return false;
if (candidate.startsWith("~/")) return true;
if (candidate.startsWith("./") || candidate.startsWith("../")) return true;
if (candidate.startsWith("artifacts/") || candidate.startsWith("output/")) {
return true;
}
if (isLikelyAbsoluteFilesystemPath(candidate)) return true;
if (candidate.includes("/") || candidate.includes("\\")) return true;
const lastSegment = candidate.split(/[\\/]/).pop() ?? "";
return /\.[a-zA-Z0-9]{1,12}$/.test(lastSegment);
}
function basename(path: string): string {
const normalized = normalizePath(path);
const parts = normalized.split("/").filter(Boolean);
return parts[parts.length - 1] ?? normalized;
}
function hasExtension(path: string): boolean {
const name = basename(path);
const dot = name.lastIndexOf(".");
return dot > 0 && dot < name.length - 1;
}
function inferPathKind(rawPath: string): ArtifactCandidateKind {
const normalized = normalizePath(rawPath);
if (normalized.endsWith("/")) return "folder";
if (hasExtension(normalized)) return "file";
return "path";
}
function stripTokenPunctuation(token: string): string {
return token.replace(/^[([{"'`]+/, "").replace(/[)\]}"'`.,;:!?]+$/, "");
}
function splitIntoTokens(result: string): string[] {
return result.split(/\s+/).map(stripTokenPunctuation).filter(Boolean);
}
function extractResultPathCandidates(result: string): string[] {
const tokens = splitIntoTokens(result);
const matches: string[] = [];
for (const token of tokens) {
if (hasKnownScheme(token) && !token.startsWith("file://")) continue;
if (!isLikelyLocalPath(token)) continue;
matches.push(token);
}
return matches;
}
export function inferHomeDirFromRoots(allowedRoots: string[]): string | null {
for (const root of allowedRoots) {
const normalized = normalizePath(root);
const usersMatch = normalized.match(/^\/Users\/[^/]+/);
if (usersMatch) return usersMatch[0];
const homeMatch = normalized.match(/^\/home\/[^/]+/);
if (homeMatch) return homeMatch[0];
}
return null;
}
function resolveRelativeToBase(base: string, relativePath: string): string {
const normalizedBase = normalizePath(base).replace(/\/+$/, "");
const normalizedRelative = normalizePath(relativePath).replace(/^\.\/+/, "");
if (!normalizedRelative || normalizedRelative === ".") return normalizedBase;
const stack = normalizedBase.split("/").filter(Boolean);
const hasWindowsDriveRoot = /^[a-zA-Z]:$/.test(stack[0] ?? "");
for (const segment of normalizedRelative.split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (stack.length > 0) stack.pop();
continue;
}
stack.push(segment);
}
const resolved = stack.join("/");
if (hasWindowsDriveRoot) return resolved;
return `/${resolved}`;
}
function pickBaseRoot(allowedRoots: string[]): string | null {
const normalizedRoots = allowedRoots
.map((root) => normalizePath(root))
.filter(Boolean);
return normalizedRoots[0] ?? null;
}
export function resolvePathCandidate(
rawPath: string,
allowedRoots: string[],
): string {
const normalizedRaw = normalizePath(rawPath);
if (!normalizedRaw) return "";
const homeDir = inferHomeDirFromRoots(allowedRoots);
if (normalizedRaw.startsWith("~/")) {
if (!homeDir) return normalizedRaw;
return `${homeDir}${normalizedRaw.slice(1)}`;
}
if (isLikelyAbsoluteFilesystemPath(normalizedRaw)) {
return normalizedRaw;
}
const baseRoot = pickBaseRoot(allowedRoots);
if (!baseRoot) return normalizedRaw;
return resolveRelativeToBase(baseRoot, normalizedRaw);
}
export function evaluatePathScope(
resolvedPath: string,
allowedRoots: string[],
options?: { allowOutsideRoots?: boolean },
): { allowed: boolean; blockedReason: string | null } {
if (options?.allowOutsideRoots) {
return { allowed: true, blockedReason: null };
}
const normalizedPath = normalizeComparablePath(resolvedPath);
const roots = allowedRoots
.map((root) => normalizeComparablePath(root))
.filter(Boolean);
for (const root of roots) {
if (normalizedPath === root || normalizedPath.startsWith(`${root}/`)) {
return { allowed: true, blockedReason: null };
}
}
return {
allowed: false,
blockedReason: "Path is outside allowed roots.",
};
}
function shouldAllowOutsideRootsForToolCandidate(
input: ToolCallArtifactInput,
candidate: {
fromResultText: boolean;
},
): boolean {
return isWriteOrientedTool(input.toolName) && candidate.fromResultText;
}
function collectArgPathCandidates(args: Record<string, unknown>): string[] {
const values: string[] = [];
for (const key of PATH_ARG_KEYS) {
const value = args[key];
if (typeof value === "string" && value.trim()) {
values.push(value.trim());
}
}
for (const key of PATH_LIST_ARG_KEYS) {
const value = args[key];
if (!Array.isArray(value)) continue;
for (const item of value) {
if (typeof item === "string" && item.trim()) {
values.push(item.trim());
}
}
}
return values;
}
export function filenameSignalBoost(path: string): number {
const lower = basename(path).toLowerCase();
return FILENAME_SIGNAL_WORDS.some((word) => lower.includes(word)) ? 1 : 0;
}
export function outputPathSignalBoost(path: string): number {
const normalized = normalizePath(path).toLowerCase();
return normalized.includes("/output/") || normalized.startsWith("output/")
? 1
: 0;
}
function buildCandidateId(candidate: {
toolCallId: string | null;
source: ArtifactCandidateSource;
resolvedPath: string;
appearanceIndex: number;
}): string {
return [
candidate.toolCallId ?? "markdown",
candidate.source,
normalizeComparablePath(candidate.resolvedPath),
candidate.appearanceIndex,
].join(":");
}
export function extractToolCallCandidates(
input: ToolCallArtifactInput,
allowedRoots: string[],
startingAppearanceIndex = 0,
): ArtifactPathCandidate[] {
const rawCandidates: Array<{
rawPath: string;
source: ArtifactCandidateSource;
confidence: ArtifactCandidateConfidence;
fromResultText: boolean;
}> = [];
for (const rawPath of collectArgPathCandidates(input.args)) {
rawCandidates.push({
rawPath,
source: "arg_key",
confidence: "high",
fromResultText: false,
});
}
for (const candidate of extractToolNamePathCandidates(
input.toolName,
isLikelyLocalPath,
stripTokenPunctuation,
)) {
rawCandidates.push(candidate);
}
for (const rawPath of collectCommandArgPathCandidates(
input.args,
isLikelyLocalPath,
stripTokenPunctuation,
)) {
rawCandidates.push({
rawPath,
source: "result_regex",
confidence: "low",
fromResultText: false,
});
}
if (
isWriteOrientedTool(input.toolName) &&
typeof input.result === "string" &&
input.result.trim()
) {
for (const rawPath of extractResultPathCandidates(input.result)) {
rawCandidates.push({
rawPath,
source: "result_regex",
confidence: "low",
fromResultText: true,
});
}
}
return rawCandidates.map((raw, idx) => {
const resolvedPath = resolvePathCandidate(raw.rawPath, allowedRoots);
const allowOutsideRoots = shouldAllowOutsideRootsForToolCandidate(
input,
raw,
);
const { allowed, blockedReason } = evaluatePathScope(
resolvedPath,
allowedRoots,
{ allowOutsideRoots },
);
const appearanceIndex = startingAppearanceIndex + idx;
return {
id: buildCandidateId({
toolCallId: input.toolCallId,
source: raw.source,
resolvedPath,
appearanceIndex,
}),
rawPath: raw.rawPath,
resolvedPath,
source: raw.source,
confidence: raw.confidence,
kind: inferPathKind(raw.rawPath),
allowed,
blockedReason,
toolCallId: input.toolCallId,
toolName: input.toolName,
toolCallIndex: input.toolCallIndex,
appearanceIndex,
};
});
}
export function resolveMarkdownLocalHref(
href: string,
allowedRoots: string[],
): ArtifactPathCandidate | null {
const trimmed = href.trim();
if (!trimmed || trimmed.startsWith("#")) return null;
if (isExternalHref(trimmed)) return null;
if (trimmed.toLowerCase().startsWith("javascript:")) return null;
let rawCandidate = trimmed;
if (trimmed.toLowerCase().startsWith("file://")) {
rawCandidate = trimmed.slice("file://".length);
}
const withoutHash = rawCandidate.split("#")[0];
const withoutQuery = withoutHash.split("?")[0];
if (!isLikelyLocalPath(withoutQuery)) return null;
const resolvedPath = resolvePathCandidate(withoutQuery, allowedRoots);
const { allowed, blockedReason } = evaluatePathScope(
resolvedPath,
allowedRoots,
);
return {
id: buildCandidateId({
toolCallId: null,
source: "markdown_href",
resolvedPath,
appearanceIndex: 0,
}),
rawPath: withoutQuery,
resolvedPath,
source: "markdown_href",
confidence: "high",
kind: inferPathKind(withoutQuery),
allowed,
blockedReason,
toolCallId: null,
toolName: null,
toolCallIndex: -1,
appearanceIndex: 0,
};
}
@@ -81,6 +81,7 @@ describe("chatSessionStore", () => {
personaId: "persona-1",
modelId: "gpt-4.1",
modelName: "GPT-4.1",
workingDir: "/tmp/project",
});
expect(useChatSessionStore.getState().sessions).toContainEqual(session);
});
@@ -97,6 +98,7 @@ describe("chatSessionStore", () => {
archivedAt: null,
userSetName: false,
messageCount: 4,
workingDir: "/tmp/acp-1",
providerId: "openai",
modelId: "gpt-4.1",
},
@@ -125,6 +127,7 @@ describe("chatSessionStore", () => {
expect(sessions[1].messageCount).toBe(4);
expect(sessions[1].providerId).toBe("openai");
expect(sessions[1].modelId).toBe("gpt-4.1");
expect(sessions[1].workingDir).toBe("/tmp/acp-1");
});
it("reads all metadata fields from backend response", async () => {
@@ -137,6 +140,7 @@ describe("chatSessionStore", () => {
archivedAt: null,
userSetName: true,
messageCount: 7,
workingDir: "/tmp/project-123",
projectId: "project-123",
providerId: "anthropic",
personaId: "persona-1",
@@ -156,6 +160,7 @@ describe("chatSessionStore", () => {
expect(session.messageCount).toBe(7);
expect(session.userSetName).toBe(true);
expect(session.modelId).toBe("claude-sonnet-4");
expect(session.workingDir).toBe("/tmp/project-123");
});
it("drops stale sessions that are no longer in ACP", async () => {
@@ -25,6 +25,7 @@ export interface ChatSession {
personaId?: string;
modelId?: string;
modelName?: string;
workingDir?: string | null;
createdAt: string;
updatedAt: string;
archivedAt?: string;
@@ -105,6 +106,7 @@ function acpSessionToChatSession(session: AcpSessionInfo): ChatSession {
providerId: session.providerId ?? undefined,
personaId: session.personaId ?? undefined,
modelId: session.modelId ?? undefined,
workingDir: session.workingDir ?? undefined,
createdAt: session.createdAt ?? session.updatedAt ?? now,
updatedAt: session.updatedAt ?? now,
archivedAt: session.archivedAt ?? undefined,
@@ -129,6 +131,7 @@ export function sessionToChatSession(session: Session): ChatSession {
personaId: session.personaId,
modelId: session.modelId,
modelName: session.modelName,
workingDir: session.workingDir,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
archivedAt: session.archivedAt,
@@ -165,6 +168,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
personaId: opts.personaId,
modelId: opts.modelId,
modelName: opts.modelName,
workingDir: opts.workingDir,
createdAt: now,
updatedAt: now,
messageCount: 0,
+2 -28
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AnimatePresence } from "motion/react";
import { MessageTimeline } from "./MessageTimeline";
@@ -6,7 +6,6 @@ import { ChatInput } from "./ChatInput";
import { LoadingGoose } from "./LoadingGoose";
import { ChatLoadingSkeleton } from "./ChatLoadingSkeleton";
import { useChatSessionStore } from "../stores/chatSessionStore";
import { defaultGlobalArtifactRoot } from "@/features/projects/lib/chatProjectContext";
import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext";
import { ChatContextPanel } from "./ChatContextPanel";
import { perfLog } from "@/shared/lib/perfLog";
@@ -52,9 +51,6 @@ export function ChatView({
(s) => s.contextPanelOpenBySession[sessionId] ?? false,
);
const setContextPanelOpen = useChatSessionStore((s) => s.setContextPanelOpen);
const [globalArtifactRoot, setGlobalArtifactRoot] = useState<string | null>(
null,
);
const [isLoadingIndicatorMounted, setIsLoadingIndicatorMounted] =
useState(false);
const controller = useChatSessionController({
@@ -64,34 +60,12 @@ export function ChatView({
const contextPanelLabel = isContextPanelOpen
? t("context.closePanel")
: t("context.openPanel");
const allowedArtifactRoots = [
...controller.allowedArtifactRoots,
...(globalArtifactRoot ? [globalArtifactRoot] : []),
];
useEffect(() => {
const ms = (performance.now() - mountStart.current).toFixed(1);
perfLog(`[perf:chatview] ${sessionId.slice(0, 8)} mounted in ${ms}ms`);
}, [sessionId]);
useEffect(() => {
let cancelled = false;
defaultGlobalArtifactRoot()
.then((artifactRoot) => {
if (!cancelled) {
setGlobalArtifactRoot(artifactRoot);
}
})
.catch(() => {
if (!cancelled) {
setGlobalArtifactRoot(null);
}
});
return () => {
cancelled = true;
};
}, []);
const showIndicator =
controller.chatState === "thinking" ||
controller.chatState === "streaming" ||
@@ -114,7 +88,7 @@ export function ChatView({
return (
<ArtifactPolicyProvider
messages={controller.messages}
allowedRoots={allowedArtifactRoots}
sessionCwd={controller.sessionArtifactCwd}
>
<div className="relative flex h-full min-w-0">
<div className="flex min-w-0 flex-1 flex-col pr-1">
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen, ChevronRight } from "lucide-react";
import { cn } from "@/shared/lib/cn";
@@ -11,14 +11,14 @@ import {
ToolOutput,
} from "@/shared/ui/ai-elements/tool";
import { toolStatusMap } from "../lib/toolStatusMap";
import type { ToolCallStatus } from "@/shared/types/messages";
import type { ToolCallLocation, ToolCallStatus } from "@/shared/types/messages";
import { useArtifactPolicyContext } from "@/features/chat/hooks/ArtifactPolicyContext";
import type { ArtifactPathCandidate } from "@/features/chat/lib/artifactPathPolicy";
interface ToolCallAdapterProps {
name: string;
arguments: Record<string, unknown>;
status: ToolCallStatus;
locations?: ToolCallLocation[];
result?: string;
structuredContent?: unknown;
isError?: boolean;
@@ -47,105 +47,96 @@ function useElapsedTime(status: ToolCallStatus, startedAt?: number) {
return elapsed;
}
function ArtifactActions({
args,
name,
result,
}: {
args: Record<string, unknown>;
name: string;
result?: string;
}) {
function getLocationKind(path: string): "file" | "folder" | "path" {
const normalized = path.trim();
if (normalized.endsWith("/") || normalized.endsWith("\\")) return "folder";
const name =
normalized
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? normalized;
const dot = name.lastIndexOf(".");
return dot > 0 && dot < name.length - 1 ? "file" : "path";
}
function visibleLocations(locations: ToolCallLocation[] | undefined) {
const seen = new Set<string>();
return (locations ?? []).filter(
(location): location is ToolCallLocation & { path: string } => {
if (
typeof location.path !== "string" ||
location.path.trim().length === 0
) {
return false;
}
const key = `${location.path}:${location.line ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
},
);
}
function ArtifactActions({ locations }: { locations?: ToolCallLocation[] }) {
const { t } = useTranslation(["chat", "common"]);
const [moreOutputsOpen, setMoreOutputsOpen] = useState(false);
const [openError, setOpenError] = useState<string | null>(null);
const { resolveToolCardDisplay, pathExists, openResolvedPath } =
useArtifactPolicyContext();
const { openResolvedPath } = useArtifactPolicyContext();
const artifactLocations = visibleLocations(locations);
const display = useMemo(
() => resolveToolCardDisplay(args, name, result),
[args, name, resolveToolCardDisplay, result],
);
if (display.role !== "primary_host" || !display.primaryCandidate) return null;
const openCandidate = async (
candidate: ArtifactPathCandidate,
allowFallback: boolean,
) => {
const candidates = allowFallback
? [
candidate,
...display.secondaryCandidates.filter((c) => c.id !== candidate.id),
]
: [candidate];
if (artifactLocations.length === 0) return null;
const openLocation = async (location: ToolCallLocation) => {
try {
setOpenError(null);
for (const c of candidates) {
const exists = await pathExists(c.resolvedPath);
if (c.allowed && exists) {
await openResolvedPath(c.resolvedPath);
return;
}
}
for (const c of candidates) {
const exists = await pathExists(c.resolvedPath);
if (exists && !c.allowed) {
setOpenError(c.blockedReason || t("tools.pathOutsideRoots"));
return;
}
}
const firstAllowed = candidates.find((c) => c.allowed);
if (firstAllowed) {
setOpenError(
t("tools.fileNotFound", { path: firstAllowed.resolvedPath }),
);
return;
}
setOpenError(candidate.blockedReason || t("tools.pathOutsideRoots"));
await openResolvedPath(location.path);
} catch (error) {
setOpenError(error instanceof Error ? error.message : String(error));
}
};
const primary = display.primaryCandidate;
const primary = artifactLocations[0];
const secondaryLocations = artifactLocations.slice(1);
const kindLabel: Record<string, string> = {
file: t("tools.openFile"),
folder: t("tools.openFolder"),
path: t("tools.openPath"),
};
return (
<div className="mt-1.5 ml-1 space-y-1.5">
const renderLocationButton = (
location: ToolCallLocation & { path: string },
className: string,
iconClassName: string,
) => {
const kind = getLocationKind(location.path);
return (
<Button
type="button"
variant="outline-flat"
onClick={() => void openCandidate(primary, true)}
className={cn(
"h-auto max-w-full justify-start rounded-md px-2.5 py-1 text-xs",
primary.allowed
? "border-accent/45 bg-background text-accent-foreground hover:bg-accent/55"
: "cursor-not-allowed border-red-500/30 bg-red-500/[0.04] text-red-500/70",
)}
disabled={!primary.allowed}
title={primary.resolvedPath}
onClick={() => void openLocation(location)}
className={className}
title={location.path}
>
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
<FolderOpen className={iconClassName} />
<span className="truncate">
{kindLabel[primary.kind] ?? t("common:actions.open")}
{kindLabel[kind] ?? t("common:actions.open")}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{primary.rawPath || primary.resolvedPath}
{location.path}
</span>
</Button>
{!primary.allowed && primary.blockedReason && (
<p className="text-[11px] text-destructive ml-1">
{primary.blockedReason}
</p>
);
};
return (
<div className="mt-1.5 ml-1 space-y-1.5">
{renderLocationButton(
primary,
"h-auto max-w-full justify-start rounded-md border-accent/45 bg-background px-2.5 py-1 text-xs text-accent-foreground hover:bg-accent/55",
"h-3.5 w-3.5 shrink-0",
)}
{display.secondaryCandidates.length > 0 && (
{secondaryLocations.length > 0 && (
<div className="space-y-1">
<button
type="button"
@@ -159,38 +150,20 @@ function ArtifactActions({
)}
/>
{t("tools.moreOutputs", {
count: display.secondaryCandidates.length,
count: secondaryLocations.length,
})}
</button>
{moreOutputsOpen && (
<div className="space-y-1.5 pl-4">
{display.secondaryCandidates.map((candidate) => (
<div key={candidate.id} className="space-y-0.5">
<Button
type="button"
variant="outline-flat"
onClick={() => void openCandidate(candidate, false)}
className={cn(
"h-auto max-w-full justify-start rounded-md px-2 py-1 text-[11px]",
candidate.allowed
? "border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground"
: "cursor-not-allowed border-red-500/20 bg-red-500/[0.03] text-red-500/70",
)}
disabled={!candidate.allowed}
title={candidate.resolvedPath}
>
<FolderOpen className="h-3 w-3 shrink-0" />
<span className="truncate">
{kindLabel[candidate.kind] ?? t("common:actions.open")}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{candidate.rawPath || candidate.resolvedPath}
</span>
</Button>
{!candidate.allowed && candidate.blockedReason && (
<p className="text-[11px] text-destructive">
{candidate.blockedReason}
</p>
{secondaryLocations.map((location) => (
<div
key={`${location.path}:${location.line ?? ""}`}
className="space-y-0.5"
>
{renderLocationButton(
location,
"h-auto max-w-full justify-start rounded-md border-border bg-background px-2 py-1 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground",
"h-3 w-3 shrink-0",
)}
</div>
))}
@@ -208,6 +181,7 @@ export function ToolCallAdapter({
name,
arguments: args,
status,
locations,
result,
structuredContent,
isError,
@@ -258,7 +232,7 @@ export function ToolCallAdapter({
)}
</ToolContent>
</Tool>
<ArtifactActions args={args} name={name} result={result} />
<ArtifactActions locations={locations} />
</div>
);
}
@@ -132,6 +132,7 @@ export function ToolChainCards({ toolItems }: { toolItems: ToolChainItem[] }) {
name={name}
arguments={request?.arguments ?? {}}
status={status}
locations={request?.locations}
result={response?.result}
structuredContent={response?.structuredContent}
isError={response?.isError}
@@ -109,7 +109,7 @@ describe("ChatView MCP app messaging", () => {
compactConversation: vi.fn(),
canCompactContext: false,
supportsCompactionControls: false,
allowedArtifactRoots: [],
sessionArtifactCwd: null,
project: null,
});
});
@@ -1,60 +1,19 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { ToolCardDisplay } from "@/features/chat/hooks/ArtifactPolicyContext";
import type { ArtifactPathCandidate } from "@/features/chat/lib/artifactPathPolicy";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { ToolCallAdapter } from "../ToolCallAdapter";
// ── mocks ────────────────────────────────────────────────────────────
const mockResolveToolCardDisplay =
vi.fn<
(
args: Record<string, unknown>,
name: string,
result?: string,
) => ToolCardDisplay
>();
const mockPathExists = vi.fn<(path: string) => Promise<boolean>>();
const mockOpenResolvedPath = vi.fn<(path: string) => Promise<void>>();
vi.mock("@/features/chat/hooks/ArtifactPolicyContext", () => ({
useArtifactPolicyContext: () => ({
resolveToolCardDisplay: mockResolveToolCardDisplay,
resolveMarkdownHref: () => null,
pathExists: mockPathExists,
pathExists: async () => false,
openResolvedPath: mockOpenResolvedPath,
getAllSessionArtifacts: () => [],
}),
}));
// ── helpers ──────────────────────────────────────────────────────────
const EMPTY_DISPLAY: ToolCardDisplay = {
role: "none",
primaryCandidate: null,
secondaryCandidates: [],
};
function makeCandidate(
overrides: Partial<ArtifactPathCandidate> = {},
): ArtifactPathCandidate {
return {
id: "c-1",
rawPath: "/project/output.md",
resolvedPath: "/Users/test/project/output.md",
source: "arg_key",
confidence: "high",
kind: "file",
allowed: true,
blockedReason: null,
toolCallId: "tool-1",
toolName: "write_file",
toolCallIndex: 0,
appearanceIndex: 0,
...overrides,
};
}
function renderAdapter(
overrides: Partial<Parameters<typeof ToolCallAdapter>[0]> = {},
) {
@@ -69,12 +28,12 @@ function renderAdapter(
);
}
// ── tests ────────────────────────────────────────────────────────────
describe("ToolCallAdapter — output", () => {
it("shows content and structured content together in the parent tool accordion", () => {
mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY);
beforeEach(() => {
mockOpenResolvedPath.mockReset();
});
it("shows content and structured content together in the parent tool accordion", () => {
renderAdapter({
open: true,
result: JSON.stringify({
@@ -98,8 +57,6 @@ describe("ToolCallAdapter — output", () => {
});
it("shows falsy primitive structured content", () => {
mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY);
renderAdapter({
open: true,
result: "Completed",
@@ -112,23 +69,22 @@ describe("ToolCallAdapter — output", () => {
});
describe("ToolCallAdapter — ArtifactActions", () => {
it('renders "Open file" button when primary candidate exists', () => {
const primary = makeCandidate();
mockResolveToolCardDisplay.mockReturnValue({
role: "primary_host",
primaryCandidate: primary,
secondaryCandidates: [],
});
renderAdapter();
expect(screen.getByRole("button", { name: /open file/i })).toBeEnabled();
expect(screen.getByText(primary.rawPath)).toBeInTheDocument();
beforeEach(() => {
mockOpenResolvedPath.mockReset();
});
it("does NOT render artifact actions when display role is none", () => {
mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY);
it('renders "Open file" button for reported ACP locations', () => {
renderAdapter({
locations: [{ path: "/Users/test/project/output.md" }],
});
expect(screen.getByRole("button", { name: /open file/i })).toBeEnabled();
expect(
screen.getByText("/Users/test/project/output.md"),
).toBeInTheDocument();
});
it("does NOT render artifact actions when no locations are reported", () => {
renderAdapter();
expect(
@@ -136,158 +92,55 @@ describe("ToolCallAdapter — ArtifactActions", () => {
).not.toBeInTheDocument();
});
it('shows "More outputs" toggle for secondary candidates', async () => {
it('shows "More outputs" toggle for secondary locations', async () => {
const user = userEvent.setup();
const primary = makeCandidate();
const secondary = makeCandidate({
id: "c-2",
rawPath: "/project/notes.md",
resolvedPath: "/Users/test/project/notes.md",
renderAdapter({
locations: [
{ path: "/Users/test/project/output.md" },
{ path: "/Users/test/project/notes.md" },
],
});
mockResolveToolCardDisplay.mockReturnValue({
role: "primary_host",
primaryCandidate: primary,
secondaryCandidates: [secondary],
});
renderAdapter();
const toggle = screen.getByText(/more outputs/i);
expect(toggle).toBeInTheDocument();
// Secondary button not visible initially
expect(screen.queryByText(secondary.rawPath)).not.toBeInTheDocument();
expect(
screen.queryByText("/Users/test/project/notes.md"),
).not.toBeInTheDocument();
await user.click(toggle);
// After expanding, secondary candidate is visible
expect(screen.getByText(secondary.rawPath)).toBeInTheDocument();
});
it("disables button and shows blocked reason for disallowed primary candidate", () => {
const blocked = makeCandidate({
allowed: false,
blockedReason: "Path is outside allowed roots.",
});
mockResolveToolCardDisplay.mockReturnValue({
role: "primary_host",
primaryCandidate: blocked,
secondaryCandidates: [],
});
renderAdapter();
expect(screen.getByRole("button", { name: /open file/i })).toBeDisabled();
expect(
screen.getByText("Path is outside allowed roots."),
screen.getByText("/Users/test/project/notes.md"),
).toBeInTheDocument();
});
it("shows blocked reason for disallowed secondary candidates", async () => {
it("opens reported location when primary button is clicked", async () => {
const user = userEvent.setup();
const primary = makeCandidate();
const blockedSecondary = makeCandidate({
id: "c-2",
rawPath: "/outside/secret.md",
resolvedPath: "/Users/test/outside/secret.md",
allowed: false,
blockedReason: "Path is outside allowed roots.",
});
mockResolveToolCardDisplay.mockReturnValue({
role: "primary_host",
primaryCandidate: primary,
secondaryCandidates: [blockedSecondary],
});
renderAdapter();
await user.click(screen.getByText(/more outputs/i));
const secondaryBtn = screen.getByTitle(blockedSecondary.resolvedPath);
expect(secondaryBtn).toBeDisabled();
expect(
screen.getByText("Path is outside allowed roots."),
).toBeInTheDocument();
});
it('does not show "detected" label for low-confidence primary candidate', () => {
const lowConf = makeCandidate({ confidence: "low" });
mockResolveToolCardDisplay.mockReturnValue({
role: "primary_host",
primaryCandidate: lowConf,
secondaryCandidates: [],
});
renderAdapter();
expect(screen.queryByText("detected")).not.toBeInTheDocument();
});
it('does NOT show "detected" label for high-confidence candidate', () => {
const highConf = makeCandidate({ confidence: "high" });
mockResolveToolCardDisplay.mockReturnValue({
role: "primary_host",
primaryCandidate: highConf,
secondaryCandidates: [],
});
renderAdapter();
expect(screen.queryByText("detected")).not.toBeInTheDocument();
});
it('does not show "detected" label for low-confidence secondary candidate', async () => {
const user = userEvent.setup();
const primary = makeCandidate();
const lowConfSecondary = makeCandidate({
id: "c-2",
rawPath: "/project/maybe.md",
resolvedPath: "/Users/test/project/maybe.md",
confidence: "low",
});
mockResolveToolCardDisplay.mockReturnValue({
role: "primary_host",
primaryCandidate: primary,
secondaryCandidates: [lowConfSecondary],
});
renderAdapter();
await user.click(screen.getByText(/more outputs/i));
expect(screen.queryByText("detected")).not.toBeInTheDocument();
});
it("opens file when primary button is clicked", async () => {
const user = userEvent.setup();
const primary = makeCandidate();
mockResolveToolCardDisplay.mockReturnValue({
role: "primary_host",
primaryCandidate: primary,
secondaryCandidates: [],
});
mockPathExists.mockResolvedValue(true);
mockOpenResolvedPath.mockResolvedValue(undefined);
renderAdapter();
renderAdapter({
locations: [{ path: "/Users/test/project/output.md" }],
});
await user.click(screen.getByRole("button", { name: /open file/i }));
expect(mockOpenResolvedPath).toHaveBeenCalledWith(primary.resolvedPath);
expect(mockOpenResolvedPath).toHaveBeenCalledWith(
"/Users/test/project/output.md",
);
});
it("shows file-not-found error when path does not exist", async () => {
it("shows opener errors", async () => {
const user = userEvent.setup();
const primary = makeCandidate();
mockResolveToolCardDisplay.mockReturnValue({
role: "primary_host",
primaryCandidate: primary,
secondaryCandidates: [],
});
mockPathExists.mockResolvedValue(false);
mockOpenResolvedPath.mockRejectedValue(
new Error("File not found: /Users/test/project/output.md"),
);
renderAdapter();
renderAdapter({
locations: [{ path: "/Users/test/project/output.md" }],
});
await user.click(screen.getByRole("button", { name: /open file/i }));
expect(
await screen.findByText(`File not found: ${primary.resolvedPath}`),
await screen.findByText("File not found: /Users/test/project/output.md"),
).toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
IconFile,
@@ -61,49 +61,15 @@ function getArtifactIcon(artifact: SessionArtifact) {
export function ArtifactsWidget() {
const { t } = useTranslation("chat");
const { getAllSessionArtifacts, openResolvedPath, pathExists } =
const { getAllSessionArtifacts, openResolvedPath } =
useArtifactPolicyContext();
const [existingPaths, setExistingPaths] = useState<Set<string> | null>(null);
const artifacts = useMemo(
() => getAllSessionArtifacts(),
[getAllSessionArtifacts],
);
useEffect(() => {
if (artifacts.length === 0) {
setExistingPaths((current) => {
if (current?.size === 0) return current;
return new Set<string>();
});
return;
}
let cancelled = false;
const paths = artifacts.map((a) => a.resolvedPath);
Promise.all(paths.map((p) => pathExists(p).catch(() => false))).then(
(results) => {
if (cancelled) return;
const existing = new Set<string>();
for (let i = 0; i < paths.length; i++) {
if (results[i]) existing.add(paths[i]);
}
setExistingPaths(existing);
},
);
return () => {
cancelled = true;
};
}, [artifacts, pathExists]);
const verifiedArtifacts =
existingPaths === null
? artifacts
: artifacts.filter((a) => existingPaths.has(a.resolvedPath));
if (verifiedArtifacts.length === 0) {
if (artifacts.length === 0) {
return null;
}
@@ -113,12 +79,12 @@ export function ArtifactsWidget() {
icon={<IconFileDescription className="size-3.5" />}
action={
<span className="text-xxs text-foreground-subtle">
{verifiedArtifacts.length}
{artifacts.length}
</span>
}
flush
>
{verifiedArtifacts.map((artifact) => {
{artifacts.map((artifact) => {
const Icon = getArtifactIcon(artifact);
return (
<FileContextMenu
@@ -151,6 +151,44 @@ describe("acpNotificationHandler", () => {
).toBe("assistant-1");
});
it("preserves ACP tool kind and locations on tool requests", async () => {
registerSession("local-session", "goose-session", "goose", "/Users/test");
setActiveMessageId("goose-session", "assistant-1");
await handleSessionNotification({
sessionId: "goose-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "write_file",
kind: "edit",
locations: [{ path: "/tmp/report.md", line: 7 }],
rawInput: { path: "/tmp/report.md" },
},
} as never);
await handleSessionNotification({
sessionId: "goose-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "completed",
locations: [{ path: "/tmp/report.md", line: 9 }],
},
} as never);
const [message] =
useChatStore.getState().messagesBySession["local-session"];
expect(message.content[0]).toMatchObject({
type: "toolRequest",
id: "tool-1",
arguments: { path: "/tmp/report.md" },
toolKind: "edit",
locations: [{ path: "/tmp/report.md", line: 9 }],
status: "completed",
});
});
it("preserves structured tool output when ACP provides rawOutput", async () => {
registerSession(
"local-session",
+3
View File
@@ -21,6 +21,7 @@ export interface AcpSessionInfo {
archivedAt: string | null;
userSetName: boolean;
messageCount: number;
workingDir: string | null;
projectId?: string | null;
providerId: string | null;
modelId: string | null;
@@ -74,6 +75,7 @@ export async function listSessions(): Promise<AcpSessionInfo[]> {
archivedAt: (info._meta?.archivedAt as string) ?? null,
userSetName: info._meta?.userSetName === true,
messageCount: (info._meta?.messageCount as number) ?? 0,
workingDir: info.cwd ?? null,
projectId: (info._meta?.projectId as string) ?? null,
providerId: (info._meta?.providerId as string) ?? null,
modelId: (info._meta?.modelId as string) ?? null,
@@ -108,6 +110,7 @@ export async function forkSession(sessionId: string): Promise<AcpSessionInfo> {
archivedAt: (response._meta?.archivedAt as string) ?? null,
userSetName: response._meta?.userSetName === true,
messageCount: (response._meta?.messageCount as number) ?? 0,
workingDir: null,
projectId: (response._meta?.projectId as string) ?? null,
providerId: (response._meta?.providerId as string) ?? null,
modelId: (response._meta?.modelId as string) ?? null,
@@ -9,7 +9,9 @@ import {
findLatestUnpairedToolRequest,
} from "@/features/chat/hooks/replayBuffer";
import type {
ToolCallLocation,
ToolCallStatus,
ToolKind,
ToolRequestContent,
ToolResponseContent,
} from "@/shared/types/messages";
@@ -59,6 +61,52 @@ const pendingUsageUpdates = new Map<
const toolCallStatusFromUpdate = (status: string): ToolCallStatus =>
status === "failed" ? "error" : "completed";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function rawInputToArguments(rawInput: unknown): Record<string, unknown> {
return isRecord(rawInput) ? rawInput : {};
}
function toolKindFromUpdate(update: SessionUpdate): ToolKind | undefined {
const record: Record<string, unknown> = update;
const value = record.kind;
return typeof value === "string" ? (value as ToolKind) : undefined;
}
function locationsFromUpdate(
update: SessionUpdate,
): ToolCallLocation[] | undefined {
const record: Record<string, unknown> = update;
const value = record.locations;
if (!Array.isArray(value)) return undefined;
return value
.filter(
(location): location is { path: string; line?: number | null } =>
isRecord(location) && typeof location.path === "string",
)
.map((location) => ({
path: location.path,
...(typeof location.line === "number" || location.line === null
? { line: location.line }
: {}),
}));
}
function toolCallUpdatePatch(
update: SessionUpdate,
): Partial<ToolRequestContent> {
const toolKind = toolKindFromUpdate(update);
const locations = locationsFromUpdate(update);
return {
...(toolKind ? { toolKind } : {}),
...(locations ? { locations } : {}),
};
}
subscribeToSessionRegistration((localSessionId, gooseSessionId) => {
const pendingUsage = pendingUsageUpdates.get(gooseSessionId);
if (!pendingUsage) {
@@ -203,8 +251,9 @@ function handleReplay(
id: update.toolCallId,
name: update.title,
...identity,
arguments: {},
arguments: rawInputToArguments(update.rawInput),
status: "executing",
...toolCallUpdatePatch(update),
startedAt: created ?? Date.now(),
});
break;
@@ -231,7 +280,12 @@ function handleReplay(
if (created !== undefined && !existingMsg && msg === replayMsg) {
msg.created = created;
}
if (update.title || Object.keys(identity).length > 0) {
const patch = toolCallUpdatePatch(update);
if (
update.title ||
Object.keys(identity).length > 0 ||
Object.keys(patch).length > 0
) {
const tc = msg.content.find(
(c) => c.type === "toolRequest" && c.id === update.toolCallId,
);
@@ -239,6 +293,7 @@ function handleReplay(
Object.assign(tc as ToolRequestContent, {
...(update.title ? { name: update.title } : {}),
...identity,
...patch,
});
}
}
@@ -253,6 +308,7 @@ function handleReplay(
msg.content[idx] = {
...tc,
...identity,
...toolCallUpdatePatch(update),
status: toolCallStatus,
} as ToolRequestContent;
}
@@ -327,8 +383,9 @@ function handleLive(
id: update.toolCallId,
name: update.title,
...identity,
arguments: {},
arguments: rawInputToArguments(update.rawInput),
status: "executing",
...toolCallUpdatePatch(update),
startedAt: Date.now(),
};
store.setStreamingMessageId(sessionId, messageId);
@@ -340,7 +397,12 @@ function handleLive(
const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId);
const identity = getToolCallIdentity(update);
if (update.title || Object.keys(identity).length > 0) {
const patch = toolCallUpdatePatch(update);
if (
update.title ||
Object.keys(identity).length > 0 ||
Object.keys(patch).length > 0
) {
store.updateMessage(sessionId, messageId, (msg) => ({
...msg,
content: msg.content.map((c) =>
@@ -349,6 +411,7 @@ function handleLive(
...c,
...(update.title ? { name: update.title } : {}),
...identity,
...patch,
}
: c,
),
@@ -371,6 +434,7 @@ function handleLive(
? {
...block,
...identity,
...toolCallUpdatePatch(update),
status: toolCallStatus,
}
: block,
+1
View File
@@ -60,6 +60,7 @@ export interface Session {
personaId?: string;
modelId?: string;
modelName?: string;
workingDir?: string | null;
createdAt: string;
updatedAt: string;
archivedAt?: string;
+19
View File
@@ -68,6 +68,23 @@ export type ToolCallStatus =
| "error"
| "stopped";
export type ToolKind =
| "read"
| "edit"
| "delete"
| "move"
| "search"
| "execute"
| "think"
| "fetch"
| "switch_mode"
| "other";
export interface ToolCallLocation {
path: string;
line?: number | null;
}
export type MessageCompletionStatus =
| "inProgress"
| "completed"
@@ -82,6 +99,8 @@ export interface ToolRequestContent {
extensionName?: string;
arguments: Record<string, unknown>;
status: ToolCallStatus;
toolKind?: ToolKind;
locations?: ToolCallLocation[];
/** Epoch ms when the tool call started executing (set on event receipt). */
startedAt?: number;
annotations?: ContentAnnotations;