mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
replace artifact heuristics/regexes with protocol messages (#8996)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface Session {
|
||||
personaId?: string;
|
||||
modelId?: string;
|
||||
modelName?: string;
|
||||
workingDir?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user