From 5e7bb5b54980fbf5d8cfb9b2c1afda26eed6a83a Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Sat, 12 Sep 2026 22:49:26 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=B3=A8=E5=86=8C=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E5=85=AB=E5=B7=A5=E5=85=B7=E4=B8=8E=20plan=5F*?= =?UTF-8?q?=EF=BC=8C=E6=8C=89=E4=BC=9A=E8=AF=9D=E5=B7=A5=E4=BD=9C=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=20jail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 read/write/edit/ls/grep/find/bash/powershell 和 plan_* 接到 Ports,Inspect 拦目录外路径;工作区写入 Current working directory。 Co-authored-by: Cursor --- AGENTS.md | 4 +- docs/architecture.md | 12 +- packages/core/chat/index.ts | 9 + packages/core/chat/plan.test.ts | 138 ++ packages/core/chat/plan.ts | 131 ++ packages/core/index.ts | 7 + packages/core/package.json | 2 +- packages/views/chat/conversation-timeline.tsx | 164 +- packages/views/chat/plan-preview.tsx | 99 ++ server/cmd/server/main.go | 2 +- server/go.mod | 2 + server/go.sum | 4 + server/internal/agent/coordinator.go | 10 + server/internal/agent/coordinator_test.go | 3 + server/internal/agent/tools/coding.go | 175 ++ server/internal/agent/tools/coding_test.go | 350 ++++ server/internal/agent/tools/edit.go | 176 ++ server/internal/agent/tools/edit_diff.go | 521 ++++++ server/internal/agent/tools/executor.go | 228 +++ .../internal/agent/tools/file_tools_test.go | 1480 +++++++++++++++++ server/internal/agent/tools/find.go | 146 ++ server/internal/agent/tools/grep.go | 194 +++ server/internal/agent/tools/image.go | 227 +++ server/internal/agent/tools/jail.go | 70 + server/internal/agent/tools/ls.go | 96 ++ server/internal/agent/tools/mutation_queue.go | 50 + .../agent/tools/output_accumulator.go | 181 ++ server/internal/agent/tools/path.go | 116 ++ server/internal/agent/tools/plan.go | 197 +++ server/internal/agent/tools/process_unix.go | 22 + .../internal/agent/tools/process_windows.go | 26 + server/internal/agent/tools/read.go | 196 +++ server/internal/agent/tools/register.go | 33 +- .../internal/agent/tools/search_shell_test.go | 596 +++++++ server/internal/agent/tools/shell.go | 140 ++ server/internal/agent/tools/stream.go | 67 + server/internal/agent/tools/tools_test.go | 504 +++++- server/internal/agent/tools/truncate.go | 204 +++ server/internal/agent/tools/types.go | 172 ++ server/internal/agent/tools/write.go | 38 + server/pkg/agent/context.go | 2 + server/pkg/agent/defaults.go | 20 +- server/pkg/agent/engine.go | 4 + server/pkg/agent/plane.go | 1 + server/pkg/agent/prompt.go | 13 + server/pkg/agent/tool/dispatch.go | 35 +- server/pkg/agent/tool/tool.go | 35 +- server/pkg/agent/types.go | 257 +-- 48 files changed, 6972 insertions(+), 187 deletions(-) create mode 100644 packages/core/chat/plan.test.ts create mode 100644 packages/core/chat/plan.ts create mode 100644 packages/views/chat/plan-preview.tsx create mode 100644 server/internal/agent/tools/coding.go create mode 100644 server/internal/agent/tools/coding_test.go create mode 100644 server/internal/agent/tools/edit.go create mode 100644 server/internal/agent/tools/edit_diff.go create mode 100644 server/internal/agent/tools/executor.go create mode 100644 server/internal/agent/tools/file_tools_test.go create mode 100644 server/internal/agent/tools/find.go create mode 100644 server/internal/agent/tools/grep.go create mode 100644 server/internal/agent/tools/image.go create mode 100644 server/internal/agent/tools/jail.go create mode 100644 server/internal/agent/tools/ls.go create mode 100644 server/internal/agent/tools/mutation_queue.go create mode 100644 server/internal/agent/tools/output_accumulator.go create mode 100644 server/internal/agent/tools/path.go create mode 100644 server/internal/agent/tools/plan.go create mode 100644 server/internal/agent/tools/process_unix.go create mode 100644 server/internal/agent/tools/process_windows.go create mode 100644 server/internal/agent/tools/read.go create mode 100644 server/internal/agent/tools/search_shell_test.go create mode 100644 server/internal/agent/tools/shell.go create mode 100644 server/internal/agent/tools/stream.go create mode 100644 server/internal/agent/tools/truncate.go create mode 100644 server/internal/agent/tools/types.go create mode 100644 server/internal/agent/tools/write.go diff --git a/AGENTS.md b/AGENTS.md index 52f7692..6554d0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ 修改 CodeDock 代码前,先阅读 [`docs/architecture.md`](docs/architecture.md)。该文档是当前目录归属和模块边界的依据。 -Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping` 与记忆工具。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。 +Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具与 `plan_*`。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。 ## 目录放置规则 @@ -30,7 +30,7 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - 模型由 `pkg/agent` 在 `Stream` / `CompactIfNeeded` 内按 `ModelConfig` 创建:`provider=fake` 走脚本化假模型(测试用),`provider=openai` 走 OpenAI 兼容 HTTP。不由 Runtime 注入模型实例。 - Handler 直接使用 `*sqlite.Queries` 做 CRUD、SSE 回放、Run 的 Start / Continue / Cancel 和审批裁决;领取后的 Loop 才进入 `internal/agent`。 - `internal/agent` 负责把 pkg 的计算结果持久化为 Run、Turn、消息、用量和事件。`AgentEvent` 必须先同事务写入并递增 `sessions.last_event_seq`,提交后再 `events.Bus.Publish`。 -- 示例 Tool 为 `ping`,另注册记忆工具;定义都在 `internal/agent/tools`。外部模块只实现 `Ports` 上的接口,由 `cmd/server` 在初始化时注入。Agent 绑定 `Profile.Tools.Names`;运行模式提供 `read` / `write` / `memory`,须覆盖工具全部能力才可调用。记忆工具声明 `memory`。审批由工具声明,模式决定是否暂停。一次模型回复的待批 Tool 合成一条审批,一次提交审完再流转;拒绝或单个工具失败不打死 Run。业务 Tool(文件 / Shell / Git)本阶段不实现。 +- 工具定义都在 `internal/agent/tools`(`ping`、记忆、编码八工具、`plan_*`)。外部模块只实现 `Ports` 上的接口,由 `cmd/server` 在初始化时注入。Agent 绑定 `Profile.Tools.Names`;运行模式提供 `read` / `write` / `memory`,须覆盖工具全部能力才可调用。记忆工具声明 `memory`。写类编码工具声明 `write` 并默认要审批。目录外路径在 Inspect / jail 失败,不能当成功执行。`plan_*` 只碰 `.cursor/*.md`。审批由工具声明,模式决定是否暂停。一次模型回复的待批 Tool 合成一条审批,一次提交审完再流转;拒绝或单个工具失败不打死 Run。Git 用户操作仍走 HTTP + `pkg/git`。 - `internal/agent/memory` 负责 TextMemory 的 Get / Upsert / Delete / List、`SearchMessages` 和 `IndexMessage`;用户侧只看/删目录与专题。不负责 Prompt / Context Packet / 对话压缩,不自动建专题,不定义 Tool。Loop 在新 Session / 对话压缩后装冻结目录;写 message 时 `IndexMessage`。超限目录由 Runtime 后台 `CompactIndex` 改短盖写,不改当前 Session 冻结前缀。 - 不要使用 Store 接口包装 sqlc。 - Agent 契约不得依赖 React、UI 包或路由框架。 diff --git a/docs/architecture.md b/docs/architecture.md index 038b757..7b32017 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ 本文档定义 CodeDock 当前的技术骨架。目录按能力拆分;Issue、Task、Review、Workspace 等业务目录不属于本项目的基础结构。 -Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runtime 装上下文、调模型、执行 Tool,事件先落库再经 Bus 由 SSE 消费。默认注册 `ping` 与记忆工具。 +Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runtime 装上下文、调模型、执行 Tool,事件先落库再经 Bus 由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具与 `plan_*`。 ## 总体架构 @@ -189,11 +189,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi 工具定义全部在本包。Runtime `New` 接收 `Ports`(Execute 要调用的外部实现),再 `Register`: -- 本包写工具名、入参/出参、schema、权限和编排。`ping`,以及 `memory_read` / `memory_write` / `memory_search`(依赖 `memory` 与 Session) -- Execute 若依赖外部能力,只通过 `Ports` 上的接口调用;由 `cmd/server` 在初始化时注入具体实现。未注入的字段不注册对应工具 -- 本阶段没有外部 Port(不实现文件 / Shell / Git) +- 本包写工具名、入参/出参、schema、权限和编排。`ping`,`memory_read` / `memory_write` / `memory_search`,编码八工具(`read` / `write` / `edit` / `ls` / `grep` / `find` / `bash` / `powershell`),以及 `plan_list` / `plan_read` / `plan_write` +- Execute 若依赖外部能力,只通过 `Ports` 上的接口调用;由 `cmd/server` 在初始化时注入具体实现。`Ports.WorkspaceRoot` 只是进程回落,会话级根走 `tool.Input.WorkspaceRoot` +- 编码工具经 jail 限制在会话工作目录;目录外路径 Inspect 失败。`plan_*` 只读写 `.cursor/*.md` -每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。Agent 通过 `Profile.Tools.Names` 绑定工具。运行模式提供 `read` / `write` / `memory` 能力,只有模式覆盖了工具声明的全部能力时该工具才对模型可见且可 Dispatch。记忆工具声明 `memory`。审批仍由工具声明 `RequiresApproval`,`ask_for_approval` 暂停、`auto_approve` / `yolo` 自动过。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。 +每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。Agent 通过 `Profile.Tools.Names` 绑定工具。运行模式提供 `read` / `write` / `memory` 能力,只有模式覆盖了工具声明的全部能力时该工具才对模型可见且可 Dispatch。记忆工具声明 `memory`。写类编码工具声明 `write`。审批仍由工具声明 `RequiresApproval` 或 `Effect=ask`,`ask_for_approval` 暂停、`auto_approve` / `yolo` 自动过。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。 ### `pkg/git` @@ -257,7 +257,7 @@ Worker -> pkg.Build -> pkg.Stream # 按 ModelConfig 在 pkg 内创建 fake 或 openai -> Transition:先落库再 Bus - -> pkg.Dispatch # ping、memory_* 及测试用 Tool + -> pkg.Dispatch # ping、memory_*、编码八工具、plan_* 及测试用 Tool ``` ## 配置 diff --git a/packages/core/chat/index.ts b/packages/core/chat/index.ts index 2501c8c..5a4ca69 100644 --- a/packages/core/chat/index.ts +++ b/packages/core/chat/index.ts @@ -1,5 +1,14 @@ export { AgentClient, AgentClientError, type AgentClientOptions } from "./client.ts"; export { decodeText, firstLine, parseDelta } from "./content.ts"; +export { + isPlanTool, + latestPlanDocIds, + normalizePlanName, + planPreviewFromTool, + planToolDump, + type PlanDocPreview, + type PlanPreview, +} from "./plan.ts"; export { joinQueuedTexts } from "./queue.ts"; export { applyApprovalRecord, diff --git a/packages/core/chat/plan.test.ts b/packages/core/chat/plan.test.ts new file mode 100644 index 0000000..2fecd70 --- /dev/null +++ b/packages/core/chat/plan.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { isPlanTool, latestPlanDocIds, normalizePlanName, planPreviewFromTool, planToolDump } from "./plan.ts"; + +test("isPlanTool recognizes plan_* only", () => { + assert.equal(isPlanTool("plan_list"), true); + assert.equal(isPlanTool("plan_read"), true); + assert.equal(isPlanTool("plan_write"), true); + assert.equal(isPlanTool("read"), false); +}); + +test("plan_write previews arguments before output arrives", () => { + const preview = planPreviewFromTool({ + name: "plan_write", + arguments: { name: "notes", content: "# 目标\n\n写完预览" }, + }); + assert.deepEqual(preview, { + kind: "doc", + name: "notes.md", + content: "# 目标\n\n写完预览", + source: "write", + }); +}); + +test("plan_write prefers output name and content", () => { + const preview = planPreviewFromTool({ + name: "plan_write", + arguments: { name: "draft", content: "old" }, + output: { name: "notes.md", content: "new" }, + }); + assert.deepEqual(preview, { + kind: "doc", + name: "notes.md", + content: "new", + source: "write", + }); +}); + +test("plan_read uses output and ignores empty arguments", () => { + const preview = planPreviewFromTool({ + name: "plan_read", + arguments: { name: "notes.md" }, + output: { name: "notes.md", content: "## 步骤" }, + }); + assert.deepEqual(preview, { + kind: "doc", + name: "notes.md", + content: "## 步骤", + source: "read", + }); +}); + +test("plan_read without content still returns a named card", () => { + const preview = planPreviewFromTool({ + name: "plan_read", + arguments: { name: "notes.md" }, + }); + assert.deepEqual(preview, { + kind: "doc", + name: "notes.md", + content: "", + source: "read", + }); +}); + +test("plan_list is not previewed", () => { + assert.equal( + planPreviewFromTool({ name: "plan_list", output: { names: ["a.md", "b.md"] } }), + null, + ); +}); + +test("unknown tools have no preview", () => { + assert.equal(planPreviewFromTool({ name: "read", arguments: { path: "a.ts" } }), null); +}); + +test("parses JSON string payloads", () => { + const preview = planPreviewFromTool({ + name: "plan_write", + arguments: '{"name":"notes.md","content":"hello"}', + }); + assert.deepEqual(preview, { + kind: "doc", + name: "notes.md", + content: "hello", + source: "write", + }); +}); + +test("planToolDump strips document content but keeps other tools intact", () => { + assert.deepEqual( + planToolDump({ + name: "plan_write", + arguments: { name: "notes.md", content: "# long" }, + output: { name: "notes.md", content: "# long" }, + }), + { + input: { name: "notes.md" }, + output: { name: "notes.md" }, + }, + ); + const ping = { name: "ping", arguments: { value: 1 }, output: { pong: true } }; + assert.deepEqual(planToolDump(ping), { input: ping.arguments, output: ping.output }); +}); + +test("normalizePlanName adds .md and keeps basename", () => { + assert.equal(normalizePlanName("notes"), "notes.md"); + assert.equal(normalizePlanName(".cursor/notes.md"), "notes.md"); +}); + +test("write to .cursor/*.md is a plan preview; other writes are not", () => { + assert.deepEqual( + planPreviewFromTool({ + name: "write", + arguments: { path: ".cursor/preview-smoke.md", content: "# 新稿" }, + }), + { + kind: "doc", + name: "preview-smoke.md", + content: "# 新稿", + source: "write", + }, + ); + assert.equal( + planPreviewFromTool({ name: "write", arguments: { path: "main.go", content: "package main" } }), + null, + ); +}); + +test("latestPlanDocIds keeps only the current plan", () => { + const ids = latestPlanDocIds([ + { id: "t1", name: "plan_write", arguments: { name: "notes.md", content: "v1" } }, + { id: "t2", name: "plan_list", output: { names: ["notes.md", "other.md"] } }, + { id: "t3", name: "plan_write", arguments: { name: "other.md", content: "x" } }, + ]); + assert.deepEqual([...ids], ["t3"]); +}); diff --git a/packages/core/chat/plan.ts b/packages/core/chat/plan.ts new file mode 100644 index 0000000..73b4a3d --- /dev/null +++ b/packages/core/chat/plan.ts @@ -0,0 +1,131 @@ +export type PlanDocPreview = { + kind: "doc"; + name: string; + content: string; + source: "write" | "read"; +}; + +export type PlanPreview = PlanDocPreview; + +const PLAN_DOC_TOOLS = new Set(["plan_read", "plan_write"]); + +export function isPlanTool(name: string): boolean { + return name === "plan_list" || PLAN_DOC_TOOLS.has(name); +} + +export function normalizePlanName(name: string): string { + const base = name.trim().split(/[\\/]/).pop()?.trim() ?? ""; + if (!base) { + return "plan.md"; + } + return /\.md$/i.test(base) ? base : `${base}.md`; +} + +export function planPreviewFromTool(input: { + name: string; + arguments?: unknown; + output?: unknown; +}): PlanPreview | null { + if (input.name === "write") { + const args = asRecord(input.arguments); + const name = planNameFromWritePath(stringField(args, "path")); + if (!name) { + return null; + } + return { + kind: "doc", + name, + content: stringField(args, "content"), + source: "write", + }; + } + if (!PLAN_DOC_TOOLS.has(input.name)) { + return null; + } + const output = asRecord(input.output); + const args = asRecord(input.arguments); + return { + kind: "doc", + name: normalizePlanName(stringField(output, "name") || stringField(args, "name")), + content: stringField(output, "content") || stringField(args, "content"), + source: input.name === "plan_write" ? "write" : "read", + }; +} + +export function latestPlanDocIds( + tools: Array<{ id: string; name: string; arguments?: unknown; output?: unknown }>, +): Set { + let current: string | undefined; + for (const tool of tools) { + if (planPreviewFromTool(tool)) { + current = tool.id; + } + } + return current ? new Set([current]) : new Set(); +} + +export function planToolDump(input: { + name: string; + arguments?: unknown; + output?: unknown; +}): { input: unknown; output: unknown } { + if (!planPreviewFromTool(input)) { + return { input: input.arguments, output: input.output }; + } + return { + input: omitContent(input.arguments), + output: omitContent(input.output), + }; +} + +function planNameFromWritePath(path: string): string | null { + const parts = path.trim().replace(/\\/g, "/").split("/").filter(Boolean); + if (parts.length < 2) { + return null; + } + const file = parts[parts.length - 1] ?? ""; + const dir = parts[parts.length - 2]; + if (dir !== ".cursor" || !/\.md$/i.test(file)) { + return null; + } + return file; +} + +function asRecord(value: unknown): Record | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed.startsWith("{")) { + return null; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return null; + } + return null; +} + +function stringField(record: Record | null, key: string): string { + if (!record) { + return ""; + } + const value = record[key]; + return typeof value === "string" ? value : ""; +} + +function omitContent(value: unknown): unknown { + const record = asRecord(value); + if (!record || !("content" in record)) { + return value; + } + const { content: _content, ...rest } = record; + return rest; +} diff --git a/packages/core/index.ts b/packages/core/index.ts index d3d32a7..1a5cb81 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -13,10 +13,15 @@ export { hydrate, indexMessages, joinQueuedTexts, + isPlanTool, + latestPlanDocIds, isRecoverableRun, isTerminalRun, isThinkingPhase, parseDelta, + normalizePlanName, + planPreviewFromTool, + planToolDump, parseSSEBlock, parseSSEChunk, RECOVERABLE_RUN_STATUSES, @@ -58,6 +63,8 @@ export type { EventType, Message, PageInfo, + PlanDocPreview, + PlanPreview, Run, RunStatus, Session, diff --git a/packages/core/package.json b/packages/core/package.json index 0bf1103..c01f05d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,7 +9,7 @@ "./git": "./git/index.ts" }, "scripts": { - "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts git/client.test.ts" + "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts git/client.test.ts" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/views/chat/conversation-timeline.tsx b/packages/views/chat/conversation-timeline.tsx index a7b856a..b51d060 100644 --- a/packages/views/chat/conversation-timeline.tsx +++ b/packages/views/chat/conversation-timeline.tsx @@ -1,6 +1,13 @@ "use client"; -import type { SessionState, ThinkingPhase, TimelineItem } from "@codedock/core/chat"; +import { + latestPlanDocIds, + planPreviewFromTool, + planToolDump, + type SessionState, + type ThinkingPhase, + type TimelineItem, +} from "@codedock/core/chat"; import { cn, Conversation, @@ -24,20 +31,26 @@ import { } from "@codedock/ui"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { PlanPreviewCard } from "./plan-preview.tsx"; + const thinkingCopy: Record = { queued: "排队中", loading_context: "正在装载上下文", running_llm: "正在思考", }; +type ToolItem = Extract; + export function ConversationTimeline({ state, loading = false, scrollKey, + emptyDescription, }: { state: SessionState; loading?: boolean; scrollKey?: string; + emptyDescription?: string; }) { const items = state.items.filter( (item) => @@ -55,14 +68,19 @@ export function ConversationTimeline({ } return ( - + ); } const rows = groupTimeline(items); const sections = sectionizeTimeline(rows); + const latestDocs = latestPlanDocIds(items.filter((item): item is ToolItem => item.kind === "tool")); const lastRow = rows[rows.length - 1]; - const followKey = lastRow ? (lastRow.kind === "tools" ? lastRow.id : lastRow.item.id) : undefined; + const followKey = lastRow + ? lastRow.kind === "tools" + ? toolsFollowKey(lastRow) + : lastRow.item.id + : undefined; const streaming = items.some( (item) => item.kind === "thinking" || (item.kind === "assistant" && item.streaming), ); @@ -75,7 +93,7 @@ export function ConversationTimeline({ {section.map((row, rowIndex) => { const latest = sectionIndex === sections.length - 1 && rowIndex === section.length - 1; return row.kind === "tools" ? ( - + ) : ( ); @@ -145,8 +163,6 @@ function TimelineRow({ } } -type ToolItem = Extract; - type TimelineRowModel = | { kind: "item"; item: Exclude } | { kind: "tools"; id: string; tools: ToolItem[] }; @@ -185,27 +201,70 @@ function rollupToolState(tools: ToolItem[]): ToolState { return order.find((state) => tools.some((tool) => tool.state === state)) ?? "completed"; } -function ToolCallsRow({ tools, latest = false }: { tools: ToolItem[]; latest?: boolean }) { +function ToolCallsRow({ + tools, + latest = false, + latestDocIds, +}: { + tools: ToolItem[]; + latest?: boolean; + latestDocIds: Set; +}) { + const previews = tools.flatMap((item) => { + const preview = planPreviewFromTool(item); + if (!preview) { + return []; + } + if (!latestDocIds.has(item.id)) { + return []; + } + return [{ item, preview }]; + }); return ( -
+
0 ? "flex flex-col gap-3" : undefined} + {...(latest ? { "data-conversation-latest": "" } : {})} + > - {tools.map((item) => ( - - - - - - - - ))} + {tools.map((item) => { + const dump = planToolDump(item); + return ( + + + + + + + + ); + })} + {previews.map(({ item, preview }) => ( + + ))}
); } +function toolsFollowKey(row: Extract): string { + const parts = row.tools.map((tool) => { + const preview = planPreviewFromTool(tool); + if (!preview) { + return tool.state; + } + return `${tool.state}:${preview.content.length}`; + }); + return `${row.id}:${parts.join(",")}`; +} + function sectionizeTimeline(rows: TimelineRowModel[]): TimelineRowModel[][] { const sections: TimelineRowModel[][] = []; let current: TimelineRowModel[] = []; @@ -243,6 +302,27 @@ function scrollParent(node: HTMLElement | null): HTMLElement | null { return null; } +/** 折叠时前三行保持清晰,第四行是渐隐区。 */ +const USER_FOLD_LINES = 3; + +function lineHeightPx(el: HTMLElement): number { + const { lineHeight, fontSize } = getComputedStyle(el); + const parsed = Number.parseFloat(lineHeight); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + const size = Number.parseFloat(fontSize); + return Number.isFinite(size) && size > 0 ? size * 1.25 : 20; +} + +function userTextOverflows(clip: HTMLElement): boolean { + const text = clip.querySelector("p"); + if (!text) { + return false; + } + return text.scrollHeight > lineHeightPx(text) * USER_FOLD_LINES + 0.5; +} + function UserRow({ item, latest = false, @@ -255,8 +335,10 @@ function UserRow({ const clipRef = useRef(null); const [stuck, setStuck] = useState(false); const [open, setOpen] = useState(false); - const [overflow, setOverflow] = useState(false); + const [overflow, setOverflow] = useState(() => item.text.split("\n").length > USER_FOLD_LINES); const compact = !open; + const folded = compact && overflow; + const expandable = overflow || open; useEffect(() => { const sentinel = sentinelRef.current; @@ -291,11 +373,24 @@ function UserRow({ useLayoutEffect(() => { const el = clipRef.current; - if (!el || open) { - setOverflow(false); + if (!el) { return; } - setOverflow(el.scrollHeight > el.clientHeight + 1); + const measure = () => { + if (open) { + return; + } + setOverflow(userTextOverflows(el)); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + const text = el.querySelector("p"); + if (text) { + observer.observe(text); + } + void document.fonts?.ready.then(measure); + return () => observer.disconnect(); }, [item.text, open]); return ( @@ -309,17 +404,20 @@ function UserRow({ > { - if (overflow || open) { + if (expandable) { setOpen((current) => !current); } }} onKeyDown={(event) => { - if (!overflow && !open) { + if (!expandable) { return; } if (event.key === "Enter" || event.key === " ") { @@ -334,9 +432,19 @@ function UserRow({ ) : null}

{item.text}

+ {folded ? ( +
+ ) : null}
diff --git a/packages/views/chat/plan-preview.tsx b/packages/views/chat/plan-preview.tsx new file mode 100644 index 0000000..8d4ad11 --- /dev/null +++ b/packages/views/chat/plan-preview.tsx @@ -0,0 +1,99 @@ +"use client"; + +import type { PlanPreview, ToolItemState } from "@codedock/core/chat"; +import { + cn, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + MessageResponse, +} from "@codedock/ui"; +import { ChevronDownIcon, FileText } from "lucide-react"; +import { useState } from "react"; + +const stateLabel: Record>> = { + pending: { write: "正在写入", read: "正在读取", doc: "处理中" }, + running: { write: "正在写入", read: "正在读取", doc: "处理中" }, + completed: { write: "已写入", read: "已读取", doc: "已完成" }, + error: { write: "写入失败", read: "读取失败", doc: "失败" }, + denied: { write: "已拒绝", read: "已拒绝", doc: "已拒绝" }, +}; + +export function PlanPreviewCard({ + preview, + state = "completed", + error, + className, +}: { + preview: PlanPreview; + state?: ToolItemState; + error?: string; + className?: string; +}) { + const [open, setOpen] = useState(true); + const status = stateLabel[state][preview.source] ?? stateLabel[state].doc ?? state; + + return ( + +
+ + + 计划 + {preview.name} + {status} + + + + {error ?

{error}

: null} + +
+
+
+ ); +} + +function PlanDocBody({ + content, + emptyHint, + animating, +}: { + content: string; + emptyHint: string; + animating: boolean; +}) { + if (!content.trim()) { + return

{emptyHint}

; + } + return ( + + {content} + + ); +} + +function emptyDocHint(source: "write" | "read", state: ToolItemState): string { + if (source === "read" && (state === "pending" || state === "running")) { + return "正在读取…"; + } + if (source === "write" && (state === "pending" || state === "running")) { + return "正在写入…"; + } + return "空计划"; +} diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 240b119..0ff79c8 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -55,7 +55,7 @@ func main() { Model: cfg.LLMModel, Options: modelOptions(cfg), } - runtime := agent.New(client, queries, bus, nil, logger.NewLogger("agent"), agenttools.Ports{}) + runtime := agent.New(client, queries, bus, nil, logger.NewLogger("agent"), agenttools.Ports{WorkspaceRoot: cfg.DefaultRoot()}) runtime.SetModel(model) runtime.SetConcurrency(cfg.LLMConcurrency, cfg.ToolConcurrency) log.Info("concurrency", "llm", cfg.LLMConcurrency, "tool", cfg.ToolConcurrency) diff --git a/server/go.mod b/server/go.mod index 414da21..d1fbe13 100644 --- a/server/go.mod +++ b/server/go.mod @@ -15,7 +15,9 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/image v0.30.0 // indirect golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.30.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/server/go.sum b/server/go.sum index c966506..0cbb4f0 100644 --- a/server/go.sum +++ b/server/go.sum @@ -20,12 +20,16 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= +golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= diff --git a/server/internal/agent/coordinator.go b/server/internal/agent/coordinator.go index ba5c828..74da8c0 100644 --- a/server/internal/agent/coordinator.go +++ b/server/internal/agent/coordinator.go @@ -224,6 +224,7 @@ func (r *Runtime) LoadAgentState(ctx context.Context, runID string) (pkgagent.Ag SessionID: run.SessionID, RunID: run.ID, TurnID: run.CurrentTurnID, + WorkspaceRoot: sessionWorkspaceRoot(sess.WorkspaceID), Status: run.Status, Config: run.Config, CancelRequested: run.CancelRequested, @@ -297,6 +298,7 @@ func (r *Runtime) LoadAgentState(ctx context.Context, runID string) (pkgagent.Ag Messages: messages, Tools: tools, Prompt: prompt, + WorkspaceRoot: sessionWorkspaceRoot(sess.WorkspaceID), MemoryIndexes: r.loadMemoryIndexes(ctx, sess.UserID, sess.WorkspaceID), } return state, hist, nil @@ -1025,6 +1027,14 @@ func turnStatusFor(status pkgagent.RunStatus) string { } } +func sessionWorkspaceRoot(workspaceID string) string { + root := strings.TrimSpace(workspaceID) + if root == "" || strings.EqualFold(root, "default") { + return "" + } + return root +} + // clipSessionSummary 取用户正文首行并截断,用作 Session 摘要。 func clipSessionSummary(content string) string { content = strings.TrimSpace(content) diff --git a/server/internal/agent/coordinator_test.go b/server/internal/agent/coordinator_test.go index 0804c3e..a1060a7 100644 --- a/server/internal/agent/coordinator_test.go +++ b/server/internal/agent/coordinator_test.go @@ -77,6 +77,9 @@ func TestCreateClaimLoadAppend(t *testing.T) { if state.Status != pkgagent.RunQueued || state.StepIndex != 0 { t.Fatalf("state=%+v", state) } + if state.WorkspaceRoot != "" { + t.Fatalf("implicit default workspace should fall back, got %q", state.WorkspaceRoot) + } if hist.Run.TriggerMessageID == "" || len(hist.Messages) != 1 { t.Fatalf("history messages=%d trigger=%s", len(hist.Messages), hist.Run.TriggerMessageID) } diff --git a/server/internal/agent/tools/coding.go b/server/internal/agent/tools/coding.go new file mode 100644 index 0000000..7a157c9 --- /dev/null +++ b/server/internal/agent/tools/coding.go @@ -0,0 +1,175 @@ +package tools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "codedock/pkg/agent/tool" +) + +type codingTool struct { + name string + prompt string + effect tool.Effect + schema json.RawMessage + ports Ports +} + +func (t codingTool) Definition() tool.Definition { + return tool.Definition{ + Name: t.name, + Prompt: t.prompt, + ParametersSchema: t.schema, + Permission: codingPermission(t.name, t.effect), + SupportsCancel: t.name == ToolBash || t.name == ToolPowerShell, + SupportsRetry: t.name == ToolRead || t.name == ToolGrep || t.name == ToolFind || t.name == ToolLS, + Version: "1", + } +} + +func (t codingTool) Inspect(_ context.Context, input tool.Input) error { + return inspectCodingPathAt(t.ports, t.name, input.Call.Arguments, workspaceOf(input, t.ports)) +} + +func (t codingTool) ResolveEffect(_ context.Context, input tool.Input) tool.Effect { + if (t.name == ToolWrite || t.name == ToolEdit) && isWorkspacePlanFile(t.ports, t.name, input.Call.Arguments, workspaceOf(input, t.ports)) { + return tool.EffectAllow + } + return t.effect +} + +func (t codingTool) Execute(ctx context.Context, input tool.Input) (tool.Result, error) { + if err := ctx.Err(); err != nil { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, err + } + exec := t.ports.executor() + result, err := exec.Execute(ctx, t.name, workspaceOf(input, t.ports), input.Call.Arguments) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, err + } + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, nil + } + raw, err := json.Marshal(result) + if err != nil { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, nil + } + return tool.Result{CallID: input.Call.ID, Name: t.name, Output: raw, Success: true}, nil +} + +func codingPermission(name string, effect tool.Effect) tool.Permission { + perm := tool.Permission{Effect: effect} + switch name { + case ToolWrite, ToolEdit, ToolBash, ToolPowerShell: + perm.Capabilities = []tool.Capability{tool.CapabilityWrite} + perm.RequiresApproval = effect != tool.EffectAllow + default: + perm.Capabilities = []tool.Capability{tool.CapabilityRead} + } + return perm +} + +func inspectCodingPath(ports Ports, name string, raw json.RawMessage) error { + return inspectCodingPathAt(ports, name, raw, ports.WorkspaceRoot) +} + +func inspectCodingPathAt(ports Ports, name string, raw json.RawMessage, root string) error { + path, err := codingPath(name, raw) + if err != nil || path == "" { + return err + } + if strings.TrimSpace(root) == "" { + return fmt.Errorf("workspace root is required") + } + exec := ports.executor() + resolved, err := exec.resolveToCWD(path, root) + if err != nil { + return err + } + _, err = jailPath(root, resolved, exec.FS) + return err +} + +func codingPath(name string, raw json.RawMessage) (string, error) { + switch name { + case ToolRead: + var input ReadInput + if err := json.Unmarshal(nonzeroJSON(raw), &input); err != nil { + return "", err + } + return input.Path, nil + case ToolWrite: + var input WriteInput + if err := json.Unmarshal(nonzeroJSON(raw), &input); err != nil { + return "", err + } + return input.Path, nil + case ToolEdit: + input, err := decodeEditInput(nonzeroJSON(raw)) + if err != nil { + return "", err + } + return input.Path, nil + case ToolGrep: + var input GrepInput + if err := json.Unmarshal(nonzeroJSON(raw), &input); err != nil { + return "", err + } + if input.Path != nil { + return *input.Path, nil + } + return ".", nil + case ToolFind: + var input FindInput + if err := json.Unmarshal(nonzeroJSON(raw), &input); err != nil { + return "", err + } + if input.Path != nil { + return *input.Path, nil + } + return ".", nil + case ToolLS: + var input LSInput + if err := json.Unmarshal(nonzeroJSON(raw), &input); err != nil { + return "", err + } + if input.Path != nil { + return *input.Path, nil + } + return ".", nil + case ToolBash, ToolPowerShell: + var input ShellInput + if err := json.Unmarshal(nonzeroJSON(raw), &input); err != nil { + return "", err + } + if input.Command == "" { + return "", fmt.Errorf("command is required") + } + return "", nil + default: + return "", nil + } +} + +func nonzeroJSON(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage("{}") + } + return raw +} + +func codingTools(ports Ports) []tool.Tool { + return []tool.Tool{ + codingTool{name: ToolRead, prompt: "读取工作区内的文本或图片文件。", effect: tool.EffectAllow, schema: schemaOf[ReadInput](), ports: ports}, + codingTool{name: ToolGrep, prompt: "在工作区内用 ripgrep 搜索文本。", effect: tool.EffectAllow, schema: schemaOf[GrepInput](), ports: ports}, + codingTool{name: ToolFind, prompt: "在工作区内用 fd 按文件名查找。", effect: tool.EffectAllow, schema: schemaOf[FindInput](), ports: ports}, + codingTool{name: ToolLS, prompt: "列出工作区内一个目录的条目。", effect: tool.EffectAllow, schema: schemaOf[LSInput](), ports: ports}, + codingTool{name: ToolWrite, prompt: "写入工作区内的文件,必要时创建目录。", effect: tool.EffectAsk, schema: schemaOf[WriteInput](), ports: ports}, + codingTool{name: ToolEdit, prompt: "按 old/new 文本块改写工作区内的文件。", effect: tool.EffectAsk, schema: schemaOf[EditInput](), ports: ports}, + codingTool{name: ToolBash, prompt: "在工作区根目录执行 bash 命令。", effect: tool.EffectAsk, schema: schemaOf[ShellInput](), ports: ports}, + codingTool{name: ToolPowerShell, prompt: "在工作区根目录执行 PowerShell 命令。", effect: tool.EffectAsk, schema: schemaOf[ShellInput](), ports: ports}, + } +} diff --git a/server/internal/agent/tools/coding_test.go b/server/internal/agent/tools/coding_test.go new file mode 100644 index 0000000..518bca9 --- /dev/null +++ b/server/internal/agent/tools/coding_test.go @@ -0,0 +1,350 @@ +package tools + +import ( + "codedock/pkg/agent/tool" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCodingExecuteAndWorkspaceInspect(t *testing.T) { + root := t.TempDir() + ports := Ports{WorkspaceRoot: root} + reg := tool.NewRegistry() + Register(reg, nil, nil, ports) + write, err := reg.Get(tool.Reference{Name: ToolWrite}) + if err != nil { + t.Fatal(err) + } + out, err := write.Execute(context.Background(), tool.Input{Call: tool.Call{ + ID: "w1", + Name: ToolWrite, + Arguments: json.RawMessage(`{"path":"hello.txt","content":"hi"}`), + }}) + if err != nil || !out.Success { + t.Fatalf("write %v %+v", err, out) + } + body, err := os.ReadFile(filepath.Join(root, "hello.txt")) + if err != nil || string(body) != "hi" { + t.Fatalf("disk %q %v", body, err) + } + read, err := reg.Get(tool.Reference{Name: ToolRead}) + if err != nil { + t.Fatal(err) + } + got, err := read.Execute(context.Background(), tool.Input{Call: tool.Call{ + ID: "r1", + Name: ToolRead, + Arguments: json.RawMessage(`{"path":"hello.txt"}`), + }}) + if err != nil || !got.Success { + t.Fatalf("read %v %+v", err, got) + } + + inspector := read.(tool.Inspector) + if err := inspector.Inspect(context.Background(), tool.Input{Call: tool.Call{ + Arguments: json.RawMessage(`{"path":"hello.txt"}`), + }}); err != nil { + t.Fatal(err) + } + if err := inspector.Inspect(context.Background(), tool.Input{Call: tool.Call{ + Arguments: json.RawMessage(`{"path":"../outside.txt"}`), + }}); err == nil || !errors.Is(err, tool.ErrOutsideWorkspace) { + t.Fatalf("outside %v", err) + } + empty := codingTool{name: ToolRead, ports: Ports{}} + if err := empty.Inspect(context.Background(), tool.Input{Call: tool.Call{ + Arguments: json.RawMessage(`{"path":"hello.txt"}`), + }}); err == nil { + t.Fatal("empty workspace should fail inspect") + } +} + +func TestCodingPathVariants(t *testing.T) { + root := t.TempDir() + ports := Ports{WorkspaceRoot: root, LookPath: func(string) (string, error) { return "/bin/true", nil }} + if _, err := codingPath(ToolWrite, json.RawMessage(`{"path":"a.txt","content":"x"}`)); err != nil { + t.Fatal(err) + } + if _, err := codingPath(ToolEdit, json.RawMessage(`{"path":"a.txt","edits":[{"oldText":"a","newText":"b"}]}`)); err != nil { + t.Fatal(err) + } + path := "." + if _, err := codingPath(ToolGrep, mustRaw(GrepInput{Pattern: "x", Path: &path})); err != nil { + t.Fatal(err) + } + if _, err := codingPath(ToolFind, mustRaw(FindInput{Pattern: "*", Path: &path})); err != nil { + t.Fatal(err) + } + if _, err := codingPath(ToolLS, mustRaw(LSInput{Path: &path})); err != nil { + t.Fatal(err) + } + if _, err := codingPath(ToolBash, json.RawMessage(`{"command":"echo hi"}`)); err != nil { + t.Fatal(err) + } + if _, err := codingPath(ToolBash, json.RawMessage(`{}`)); err == nil { + t.Fatal("empty command") + } + if _, err := codingPath(ToolPowerShell, json.RawMessage(`{"command":"dir"}`)); err != nil { + t.Fatal(err) + } + _ = ports +} + +func mustRaw(v any) json.RawMessage { + body, err := json.Marshal(v) + if err != nil { + panic(err) + } + return body +} + +func TestWritePlanFileSkipsApproval(t *testing.T) { + root := t.TempDir() + ports := Ports{WorkspaceRoot: root} + reg := tool.NewRegistry() + Register(reg, nil, nil, ports) + + planWrite, err := tool.Dispatch(context.Background(), tool.Invocation{ + WorkspaceRoot: root, + AgentMode: "ask_for_approval", + Registry: reg, + Calls: []tool.Call{{ + ID: "p1", + Name: "write", + Arguments: json.RawMessage(`{"path":".cursor/notes.md","content":"# next"}`), + }}, + }) + if err != nil || planWrite.WaitingApproval || len(planWrite.Results) != 1 || !planWrite.Results[0].Success { + t.Fatalf("plan file write should skip approval: %v %+v", err, planWrite) + } + if _, err := os.Stat(filepath.Join(root, ".cursor", "notes.md")); err != nil { + t.Fatal(err) + } + + codeWrite, err := tool.Dispatch(context.Background(), tool.Invocation{ + WorkspaceRoot: root, + AgentMode: "ask_for_approval", + Registry: reg, + Calls: []tool.Call{{ + ID: "c1", + Name: "write", + Arguments: json.RawMessage(`{"path":"main.go","content":"package main"}`), + }}, + }) + if err != nil || !codeWrite.WaitingApproval { + t.Fatalf("regular write still needs approval: %v %+v", err, codeWrite) + } +} + +func TestPlanToolsAndJail(t *testing.T) { + root := t.TempDir() + ports := Ports{WorkspaceRoot: root} + reg := tool.NewRegistry() + Register(reg, nil, nil, ports) + + write, err := reg.Get(tool.Reference{Name: "plan_write"}) + if err != nil { + t.Fatal(err) + } + out, err := write.Execute(context.Background(), tool.Input{Call: tool.Call{ + ID: "w1", + Name: "plan_write", + Arguments: json.RawMessage(`{"name":"demo","content":"# hi"}`), + }}) + if err != nil || !out.Success { + t.Fatalf("write %v %+v", err, out) + } + if _, err := os.Stat(filepath.Join(root, ".cursor", "demo.md")); err != nil { + t.Fatal(err) + } + + list, err := reg.Get(tool.Reference{Name: "plan_list"}) + if err != nil { + t.Fatal(err) + } + listed, err := list.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "l1", Name: "plan_list", Arguments: json.RawMessage(`{}`)}}) + if err != nil || !listed.Success || !strings.Contains(string(listed.Output), "demo.md") { + t.Fatalf("list %v %+v", err, listed) + } + + read, err := reg.Get(tool.Reference{Name: "plan_read"}) + if err != nil { + t.Fatal(err) + } + got, err := read.Execute(context.Background(), tool.Input{Call: tool.Call{ + ID: "r1", + Name: "plan_read", + Arguments: json.RawMessage(`{"name":"demo.md"}`), + }}) + if err != nil || !got.Success || !strings.Contains(string(got.Output), "# hi") { + t.Fatalf("read %v %+v", err, got) + } + + inspector, ok := write.(tool.Inspector) + if !ok { + t.Fatal("plan_write should inspect") + } + if err := inspector.Inspect(context.Background(), tool.Input{Call: tool.Call{ + Arguments: json.RawMessage(`{"name":"../escape","content":"x"}`), + }}); err == nil { + t.Fatal("expected invalid plan name") + } +} + +func TestCodingInspectEscapesWorkspace(t *testing.T) { + root := t.TempDir() + ports := Ports{WorkspaceRoot: root} + item := codingTools(ports)[0] + inspector := item.(tool.Inspector) + err := inspector.Inspect(context.Background(), tool.Input{Call: tool.Call{ + Arguments: json.RawMessage(`{"path":"../outside.txt"}`), + }}) + if err == nil || !errors.Is(err, tool.ErrOutsideWorkspace) { + t.Fatalf("expected outside workspace, got %v", err) + } + if err := inspector.Inspect(context.Background(), tool.Input{Call: tool.Call{ + Arguments: json.RawMessage(`{"path":"inside.txt"}`), + }}); err != nil { + t.Fatal(err) + } + other := t.TempDir() + if err := inspector.Inspect(context.Background(), tool.Input{ + WorkspaceRoot: other, + Call: tool.Call{Arguments: json.RawMessage(`{"path":"inside.txt"}`)}, + }); err != nil { + t.Fatal(err) + } +} + +func TestSanitizePlanName(t *testing.T) { + got, err := sanitizePlanName("notes") + if err != nil || got != "notes.md" { + t.Fatalf("got %q %v", got, err) + } + if _, err := sanitizePlanName("../x.md"); err == nil { + t.Fatal("expected deny") + } + if _, err := sanitizePlanName("a/b.md"); err == nil { + t.Fatal("expected deny") + } +} + +func TestJailSymlinkAndResolve(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + link := filepath.Join(root, "out") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + if _, err := jailPath(root, link, nil); err == nil { + t.Fatal("symlink escape") + } + if insideRoot("/a", "/a/b") && !insideRoot("/a", "/b") { + // ok + } + ports := Ports{WorkspaceRoot: root, FS: osFileSystem{}} + _ = ports.executor() + _ = inspectCodingPath(ports, ToolRead, json.RawMessage(`{"path":"out"}`)) + if err := inspectCodingPath(ports, ToolRead, json.RawMessage(`{"path":"file://%zz"}`)); err == nil { + t.Fatal("bad file url") + } + + dir := t.TempDir() + origAbs, origRel := pathAbs, pathRel + t.Cleanup(func() { pathAbs, pathRel = origAbs, origRel }) + pathAbs = func(string) (string, error) { return "", os.ErrInvalid } + if _, err := jailPath(dir, "a.txt", nil); err == nil { + t.Fatal("jail abs") + } + n := 0 + pathAbs = func(p string) (string, error) { + n++ + if n >= 2 { + return "", os.ErrInvalid + } + return origAbs(p) + } + if _, err := jailPath(dir, "a.txt", nil); err == nil { + t.Fatal("target abs") + } + pathAbs = func(string) (string, error) { return "", os.ErrInvalid } + if _, err := planDir(dir); err == nil { + t.Fatal("planDir jail") + } + pathAbs = origAbs + pathRel = func(string, string) (string, error) { return "", os.ErrInvalid } + if insideRoot(dir, dir) { + t.Fatal("rel fail") + } + pathRel = origRel +} + +func TestPlanCursorIsFileAndFindPowerShell(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".cursor"), []byte("nope"), 0o644); err != nil { + t.Fatal(err) + } + ports := Ports{WorkspaceRoot: root} + list := planTool{name: "plan_list", ports: ports} + got, err := list.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "l"}}) + if err != nil || got.Success { + t.Fatalf("list file %v %+v", err, got) + } + write := planTool{name: "plan_write", ports: ports} + got, err = write.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "w", Arguments: json.RawMessage(`{"name":"a.md","content":"x"}`)}}) + if err != nil || got.Success { + t.Fatalf("write file %v %+v", err, got) + } + + okRoot := t.TempDir() + okPorts := Ports{ + WorkspaceRoot: okRoot, + LookPath: func(string) (string, error) { return "/bin/echo", nil }, + RunCommand: func(ctx context.Context, name string, args []string, dir string, env []string, onStdout, onStderr func([]byte)) (CommandResult, error) { + return CommandResult{}, nil + }, + } + reg := tool.NewRegistry() + Register(reg, nil, nil, okPorts) + find, _ := reg.Get(tool.Reference{Name: ToolFind}) + got, err = find.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "f", Arguments: json.RawMessage(`{"pattern":"*"}`)}}) + if err != nil { + t.Fatal(err) + } + ps, _ := reg.Get(tool.Reference{Name: ToolPowerShell}) + got, err = ps.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "p", Arguments: json.RawMessage(`{"command":"dir"}`)}}) + if err != nil { + t.Fatal(err) + } + grep, _ := reg.Get(tool.Reference{Name: ToolGrep}) + _, _ = grep.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "g", Arguments: json.RawMessage(`{"pattern":"x"}`)}}) +} + +func TestImageSniffAndDiffEdges(t *testing.T) { + if isSupportedPNG(nil) || isSupportedBMP(nil) { + t.Fatal("empty") + } + png := []byte("\x89PNG\r\n\x1a\n") + if isSupportedPNG(png) { + t.Fatal("short png") + } + if isSupportedBMP([]byte("BM")) { + t.Fatal("short bmp") + } + diff, _ := generateDiffString("", "a\n", 1) + if diff == "" { + t.Fatal("diff") + } + diff, _ = generateDiffString("a\n", "a\n", 1) + _ = diff + diff, _ = generateDiffString("a\nb\nc\n", "a\nX\nc\n", 0) + if diff == "" { + t.Fatal("no context") + } + _ = mergeDiffOperations(nil) +} diff --git a/server/internal/agent/tools/edit.go b/server/internal/agent/tools/edit.go new file mode 100644 index 0000000..cb2e7fd --- /dev/null +++ b/server/internal/agent/tools/edit.go @@ -0,0 +1,176 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" +) + +func decodeEditInput(rawInput json.RawMessage) (EditInput, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(rawInput, &fields); err != nil { + return EditInput{}, err + } + if fields == nil { + return EditInput{}, errors.New("tool input must be a JSON object") + } + pathJSON, found := fields["path"] + if !found { + return EditInput{}, fmt.Errorf("missing required property: path") + } + if bytes.Equal(bytes.TrimSpace(pathJSON), []byte("null")) { + return EditInput{}, errors.New("property path must not be null") + } + var path string + if err := json.Unmarshal(pathJSON, &path); err != nil { + return EditInput{}, err + } + + input := EditInput{Path: path} + if editsJSON, found := fields["edits"]; found { + if bytes.Equal(bytes.TrimSpace(editsJSON), []byte("null")) { + return EditInput{}, errors.New("property edits must not be null") + } + edits, err := decodeEditReplacements(editsJSON) + if err != nil { + return EditInput{}, err + } + input.Edits = edits + } + oldTextJSON, hasOldText := fields["oldText"] + newTextJSON, hasNewText := fields["newText"] + var oldText string + var newText string + oldTextValid := hasOldText && json.Unmarshal(oldTextJSON, &oldText) == nil && + !bytes.Equal(bytes.TrimSpace(oldTextJSON), []byte("null")) + newTextValid := hasNewText && json.Unmarshal(newTextJSON, &newText) == nil && + !bytes.Equal(bytes.TrimSpace(newTextJSON), []byte("null")) + if oldTextValid && newTextValid { + input.Edits = append(input.Edits, EditReplacement{ + OldText: oldText, + NewText: newText, + }) + } + return input, nil +} + +func decodeEditReplacements(raw json.RawMessage) ([]EditReplacement, error) { + var encoded string + if err := json.Unmarshal(raw, &encoded); err == nil { + return decodeEditReplacements(json.RawMessage(encoded)) + } + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + items = []json.RawMessage{raw} + } + replacements := make([]EditReplacement, 0, len(items)) + for _, item := range items { + var fields map[string]json.RawMessage + if err := json.Unmarshal(item, &fields); err != nil || fields == nil { + if err == nil { + err = errors.New("edit replacement must be a JSON object") + } + return nil, err + } + oldTextJSON, hasOldText := fields["oldText"] + newTextJSON, hasNewText := fields["newText"] + if !hasOldText || !hasNewText { + return nil, errors.New("edit replacement requires oldText and newText") + } + oldText, err := decodeRequiredString(oldTextJSON, "oldText") + if err != nil { + return nil, err + } + newText, err := decodeRequiredString(newTextJSON, "newText") + if err != nil { + return nil, err + } + replacements = append(replacements, EditReplacement{OldText: oldText, NewText: newText}) + } + return replacements, nil +} + +func decodeRequiredString(raw json.RawMessage, field string) (string, error) { + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return "", fmt.Errorf("property %s must not be null", field) + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", err + } + return value, nil +} + +func (executor *Executor) Edit(ctx context.Context, cwd string, input EditInput) (ToolResult, error) { + executor = executor.withDefaults() + if len(input.Edits) == 0 { + return ToolResult{}, fmt.Errorf("Edit tool input is invalid. edits must contain at least one replacement.") + } + absolutePath, err := executor.resolveToCWD(input.Path, cwd) + if err != nil { + return ToolResult{}, err + } + return executor.withFileMutation(absolutePath, func() (ToolResult, error) { + if err := contextOperationError(ctx); err != nil { + return ToolResult{}, err + } + if err := executor.FS.Access(absolutePath); err != nil { + return ToolResult{}, fmt.Errorf("Could not edit file: %s. %s.", input.Path, formatFileError(err)) + } + rawContent, err := executor.FS.ReadFile(absolutePath) + if err != nil { + return ToolResult{}, fmt.Errorf("Could not edit file: %s. %s.", input.Path, formatFileError(err)) + } + if err := contextOperationError(ctx); err != nil { + return ToolResult{}, err + } + + bom := []byte(nil) + if len(rawContent) >= 3 && rawContent[0] == 0xef && rawContent[1] == 0xbb && rawContent[2] == 0xbf { + bom = []byte{0xef, 0xbb, 0xbf} + rawContent = rawContent[3:] + } + content := stringsToValidUTF8(rawContent) + lineEnding := detectLineEnding(content) + baseContent, newContent, err := applyEditsToNormalizedContent(normalizeToLF(content), input.Edits, input.Path) + if err != nil { + return ToolResult{}, err + } + diff, firstChangedLine := generateDiffString(baseContent, newContent, 4) + details := &ResultDetails{ + Diff: diff, + Patch: generateUnifiedPatch(input.Path, baseContent, newContent), + FirstChangedLine: firstChangedLine, + } + finalContent := append(bom, []byte(restoreLineEndings(newContent, lineEnding))...) + if err := executor.FS.WriteFile(absolutePath, finalContent, 0o644); err != nil { + return ToolResult{}, fmt.Errorf("Could not edit file: %s. %s.", input.Path, formatFileError(err)) + } + if err := contextOperationError(ctx); err != nil { + return ToolResult{}, err + } + + return textResult( + fmt.Sprintf("Successfully replaced %d block(s) in %s.", len(input.Edits), input.Path), + details, + ), nil + }) +} + +func formatFileError(err error) string { + switch { + case errors.Is(err, fs.ErrNotExist): + return "Error code: ENOENT" + case errors.Is(err, fs.ErrPermission): + return "Error code: EACCES" + default: + return fmt.Sprintf("Error: %s", err) + } +} + +func stringsToValidUTF8(data []byte) string { + return string([]rune(string(data))) +} diff --git a/server/internal/agent/tools/edit_diff.go b/server/internal/agent/tools/edit_diff.go new file mode 100644 index 0000000..ba04f23 --- /dev/null +++ b/server/internal/agent/tools/edit_diff.go @@ -0,0 +1,521 @@ +package tools + +import ( + "fmt" + "strings" + "unicode" + + "golang.org/x/text/unicode/norm" +) + +type matchedEdit struct { + editIndex int + matchIndex int + matchLength int + newText string +} + +type lineSpan struct { + start int + end int +} + +type diffOperation struct { + kind byte + lines []string +} + +func detectLineEnding(content string) string { + lf := strings.Index(content, "\n") + crlf := strings.Index(content, "\r\n") + if lf == -1 || crlf == -1 || crlf >= lf { + return "\n" + } + return "\r\n" +} + +func normalizeToLF(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + return strings.ReplaceAll(text, "\r", "\n") +} + +func restoreLineEndings(text string, ending string) string { + if ending == "\r\n" { + return strings.ReplaceAll(text, "\n", "\r\n") + } + return text +} + +func normalizeForFuzzyMatch(text string) string { + text = norm.NFKC.String(text) + lines := strings.Split(text, "\n") + for index := range lines { + lines[index] = strings.TrimRightFunc(lines[index], unicode.IsSpace) + } + text = strings.Join(lines, "\n") + + replacer := strings.NewReplacer( + "\u2018", "'", "\u2019", "'", "\u201a", "'", "\u201b", "'", + "\u201c", `"`, "\u201d", `"`, "\u201e", `"`, "\u201f", `"`, + "\u2010", "-", "\u2011", "-", "\u2012", "-", "\u2013", "-", + "\u2014", "-", "\u2015", "-", "\u2212", "-", + "\u00a0", " ", "\u2002", " ", "\u2003", " ", "\u2004", " ", + "\u2005", " ", "\u2006", " ", "\u2007", " ", "\u2008", " ", + "\u2009", " ", "\u200a", " ", "\u202f", " ", "\u205f", " ", + "\u3000", " ", + ) + return replacer.Replace(text) +} + +func splitLinesWithEndings(content string) []string { + if content == "" { + return nil + } + lines := strings.SplitAfter(content, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +func getLineSpans(content string) []lineSpan { + lines := splitLinesWithEndings(content) + spans := make([]lineSpan, 0, len(lines)) + offset := 0 + for _, line := range lines { + spans = append(spans, lineSpan{start: offset, end: offset + len(line)}) + offset += len(line) + } + return spans +} + +func applyReplacements(content string, replacements []matchedEdit, offset int) string { + result := content + for index := len(replacements) - 1; index >= 0; index-- { + replacement := replacements[index] + start := replacement.matchIndex - offset + result = result[:start] + replacement.newText + result[start+replacement.matchLength:] + } + return result +} + +func applyReplacementsPreservingUnchangedLines( + originalContent string, + baseContent string, + replacements []matchedEdit, +) (string, error) { + originalLines := splitLinesWithEndings(originalContent) + baseLines := getLineSpans(baseContent) + if len(originalLines) != len(baseLines) { + return "", fmt.Errorf("cannot preserve unchanged lines because the base content has a different line count") + } + + type replacementGroup struct { + startLine int + endLine int + replacements []matchedEdit + } + groups := make([]replacementGroup, 0, len(replacements)) + for _, replacement := range replacements { + replacementStart := replacement.matchIndex + replacementEnd := replacement.matchIndex + replacement.matchLength + startLine := -1 + for index, line := range baseLines { + if replacementStart >= line.start && replacementStart < line.end { + startLine = index + break + } + } + if startLine == -1 { + return "", fmt.Errorf("replacement range is outside the base content") + } + endLine := startLine + for endLine < len(baseLines) && baseLines[endLine].end < replacementEnd { + endLine++ + } + if endLine >= len(baseLines) { + return "", fmt.Errorf("replacement range is outside the base content") + } + endLine++ + + if len(groups) > 0 && startLine < groups[len(groups)-1].endLine { + current := &groups[len(groups)-1] + current.endLine = max(current.endLine, endLine) + current.replacements = append(current.replacements, replacement) + } else { + groups = append(groups, replacementGroup{ + startLine: startLine, + endLine: endLine, + replacements: []matchedEdit{replacement}, + }) + } + } + + var result strings.Builder + originalLineIndex := 0 + for _, group := range groups { + result.WriteString(strings.Join(originalLines[originalLineIndex:group.startLine], "")) + startOffset := baseLines[group.startLine].start + endOffset := baseLines[group.endLine-1].end + result.WriteString(applyReplacements(baseContent[startOffset:endOffset], group.replacements, startOffset)) + originalLineIndex = group.endLine + } + result.WriteString(strings.Join(originalLines[originalLineIndex:], "")) + return result.String(), nil +} + +func applyEditsToNormalizedContent( + normalizedContent string, + edits []EditReplacement, + path string, +) (string, string, error) { + normalizedEdits := make([]EditReplacement, len(edits)) + for index, edit := range edits { + normalizedEdits[index] = EditReplacement{ + OldText: normalizeToLF(edit.OldText), + NewText: normalizeToLF(edit.NewText), + } + if normalizedEdits[index].OldText == "" { + if len(edits) == 1 { + return "", "", fmt.Errorf("oldText must not be empty in %s.", path) + } + return "", "", fmt.Errorf("edits[%d].oldText must not be empty in %s.", index, path) + } + } + + usedFuzzy := false + for _, edit := range normalizedEdits { + if !strings.Contains(normalizedContent, edit.OldText) && + strings.Contains(normalizeForFuzzyMatch(normalizedContent), normalizeForFuzzyMatch(edit.OldText)) { + usedFuzzy = true + break + } + } + replacementBase := normalizedContent + if usedFuzzy { + replacementBase = normalizeForFuzzyMatch(normalizedContent) + } + + matches := make([]matchedEdit, 0, len(edits)) + for index, edit := range normalizedEdits { + oldText := edit.OldText + if usedFuzzy { + oldText = normalizeForFuzzyMatch(oldText) + } + matchIndex := strings.Index(replacementBase, oldText) + if matchIndex == -1 { + if len(edits) == 1 { + return "", "", fmt.Errorf( + "Could not find the exact text in %s. The old text must match exactly including all whitespace and newlines.", + path, + ) + } + return "", "", fmt.Errorf( + "Could not find edits[%d] in %s. The oldText must match exactly including all whitespace and newlines.", + index, + path, + ) + } + occurrences := strings.Count(normalizeForFuzzyMatch(replacementBase), normalizeForFuzzyMatch(oldText)) + if occurrences > 1 { + if len(edits) == 1 { + return "", "", fmt.Errorf( + "Found %d occurrences of the text in %s. The text must be unique. Please provide more context to make it unique.", + occurrences, + path, + ) + } + return "", "", fmt.Errorf( + "Found %d occurrences of edits[%d] in %s. Each oldText must be unique. Please provide more context to make it unique.", + occurrences, + index, + path, + ) + } + matches = append(matches, matchedEdit{ + editIndex: index, + matchIndex: matchIndex, + matchLength: len(oldText), + newText: edit.NewText, + }) + } + + for left := 0; left < len(matches); left++ { + for right := left + 1; right < len(matches); right++ { + if matches[right].matchIndex < matches[left].matchIndex { + matches[left], matches[right] = matches[right], matches[left] + } + } + } + for index := 1; index < len(matches); index++ { + previous := matches[index-1] + current := matches[index] + if previous.matchIndex+previous.matchLength > current.matchIndex { + return "", "", fmt.Errorf( + "edits[%d] and edits[%d] overlap in %s. Merge them into one edit or target disjoint regions.", + previous.editIndex, + current.editIndex, + path, + ) + } + } + + newContent := "" + var err error + if usedFuzzy { + newContent, err = applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBase, matches) + } else { + newContent = applyReplacements(replacementBase, matches, 0) + } + if err != nil { + return "", "", err + } + if normalizedContent == newContent { + if len(edits) == 1 { + return "", "", fmt.Errorf( + "No changes made to %s. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.", + path, + ) + } + return "", "", fmt.Errorf("No changes made to %s. The replacements produced identical content.", path) + } + return normalizedContent, newContent, nil +} + +func splitDiffLines(content string) []string { + return splitLinesWithEndings(content) +} + +func computeLineDiff(oldContent string, newContent string) []diffOperation { + oldLines := splitDiffLines(oldContent) + newLines := splitDiffLines(newContent) + return diffLineSequences(oldLines, newLines) +} + +func diffLineSequences(oldLines []string, newLines []string) []diffOperation { + if len(oldLines) == 0 { + return newDiffOperation('+', newLines) + } + if len(newLines) == 0 { + return newDiffOperation('-', oldLines) + } + prefixLength := 0 + for prefixLength < len(oldLines) && + prefixLength < len(newLines) && + oldLines[prefixLength] == newLines[prefixLength] { + prefixLength++ + } + suffixLength := 0 + for suffixLength < len(oldLines)-prefixLength && + suffixLength < len(newLines)-prefixLength && + oldLines[len(oldLines)-1-suffixLength] == newLines[len(newLines)-1-suffixLength] { + suffixLength++ + } + if prefixLength > 0 || suffixLength > 0 { + oldMiddleEnd := len(oldLines) - suffixLength + newMiddleEnd := len(newLines) - suffixLength + return mergeDiffOperations( + newDiffOperation(' ', oldLines[:prefixLength]), + diffLineSequences(oldLines[prefixLength:oldMiddleEnd], newLines[prefixLength:newMiddleEnd]), + newDiffOperation(' ', oldLines[oldMiddleEnd:]), + ) + } + if len(oldLines) == 1 { + for index, line := range newLines { + if oldLines[0] == line { + return mergeDiffOperations( + newDiffOperation('+', newLines[:index]), + newDiffOperation(' ', oldLines), + newDiffOperation('+', newLines[index+1:]), + ) + } + } + return mergeDiffOperations(newDiffOperation('-', oldLines), newDiffOperation('+', newLines)) + } + + middle := len(oldLines) / 2 + leftLengths := lcsPrefixLengths(oldLines[:middle], newLines) + rightLengths := lcsSuffixLengths(oldLines[middle:], newLines) + split := 0 + best := -1 + for index := 0; index <= len(newLines); index++ { + score := leftLengths[index] + rightLengths[index] + if score > best { + best = score + split = index + } + } + leftLengths = nil + rightLengths = nil + return mergeDiffOperations( + diffLineSequences(oldLines[:middle], newLines[:split]), + diffLineSequences(oldLines[middle:], newLines[split:]), + ) +} + +func lcsPrefixLengths(left []string, right []string) []int { + previous := make([]int, len(right)+1) + for _, leftLine := range left { + current := make([]int, len(right)+1) + for index, rightLine := range right { + if leftLine == rightLine { + current[index+1] = previous[index] + 1 + } else { + current[index+1] = max(previous[index+1], current[index]) + } + } + previous = current + } + return previous +} + +func lcsSuffixLengths(left []string, right []string) []int { + next := make([]int, len(right)+1) + for leftIndex := len(left) - 1; leftIndex >= 0; leftIndex-- { + current := make([]int, len(right)+1) + for rightIndex := len(right) - 1; rightIndex >= 0; rightIndex-- { + if left[leftIndex] == right[rightIndex] { + current[rightIndex] = next[rightIndex+1] + 1 + } else { + current[rightIndex] = max(next[rightIndex], current[rightIndex+1]) + } + } + next = current + } + return next +} + +func newDiffOperation(kind byte, lines []string) []diffOperation { + if len(lines) == 0 { + return nil + } + return []diffOperation{{kind: kind, lines: append([]string(nil), lines...)}} +} + +func mergeDiffOperations(groups ...[]diffOperation) []diffOperation { + merged := make([]diffOperation, 0) + for _, group := range groups { + for _, operation := range group { + if len(operation.lines) == 0 { + continue + } + if len(merged) > 0 && merged[len(merged)-1].kind == operation.kind { + merged[len(merged)-1].lines = append(merged[len(merged)-1].lines, operation.lines...) + } else { + merged = append(merged, operation) + } + } + } + return merged +} + +func displayDiffLine(line string) string { + return strings.TrimSuffix(line, "\n") +} + +func generateDiffString(oldContent string, newContent string, contextLines int) (string, *int) { + operations := computeLineDiff(oldContent, newContent) + oldLineNumber, newLineNumber := 1, 1 + maxLineNumber := max(len(splitDiffLines(oldContent)), len(splitDiffLines(newContent))) + width := len(fmt.Sprint(max(1, maxLineNumber))) + lastWasChange := false + var firstChangedLine *int + output := make([]string, 0) + + for index, operation := range operations { + if operation.kind == '+' || operation.kind == '-' { + if firstChangedLine == nil { + firstChangedLine = intPointer(newLineNumber) + } + for _, line := range operation.lines { + line = displayDiffLine(line) + if operation.kind == '+' { + output = append(output, fmt.Sprintf("+%*d %s", width, newLineNumber, line)) + newLineNumber++ + } else { + output = append(output, fmt.Sprintf("-%*d %s", width, oldLineNumber, line)) + oldLineNumber++ + } + } + lastWasChange = true + continue + } + + nextIsChange := index+1 < len(operations) && + (operations[index+1].kind == '+' || operations[index+1].kind == '-') + lines := operation.lines + switch { + case lastWasChange && nextIsChange && len(lines) <= contextLines*2: + for _, line := range lines { + output = append(output, fmt.Sprintf(" %*d %s", width, oldLineNumber, displayDiffLine(line))) + oldLineNumber++ + newLineNumber++ + } + case lastWasChange && nextIsChange: + for _, line := range lines[:contextLines] { + output = append(output, fmt.Sprintf(" %*d %s", width, oldLineNumber, displayDiffLine(line))) + oldLineNumber++ + newLineNumber++ + } + skipped := len(lines) - contextLines*2 + output = append(output, " "+strings.Repeat(" ", width)+" ...") + oldLineNumber += skipped + newLineNumber += skipped + for _, line := range lines[len(lines)-contextLines:] { + output = append(output, fmt.Sprintf(" %*d %s", width, oldLineNumber, displayDiffLine(line))) + oldLineNumber++ + newLineNumber++ + } + case lastWasChange: + shown := min(contextLines, len(lines)) + for _, line := range lines[:shown] { + output = append(output, fmt.Sprintf(" %*d %s", width, oldLineNumber, displayDiffLine(line))) + oldLineNumber++ + newLineNumber++ + } + if skipped := len(lines) - shown; skipped > 0 { + output = append(output, " "+strings.Repeat(" ", width)+" ...") + oldLineNumber += skipped + newLineNumber += skipped + } + case nextIsChange: + skipped := max(0, len(lines)-contextLines) + if skipped > 0 { + output = append(output, " "+strings.Repeat(" ", width)+" ...") + oldLineNumber += skipped + newLineNumber += skipped + } + for _, line := range lines[skipped:] { + output = append(output, fmt.Sprintf(" %*d %s", width, oldLineNumber, displayDiffLine(line))) + oldLineNumber++ + newLineNumber++ + } + default: + oldLineNumber += len(lines) + newLineNumber += len(lines) + } + lastWasChange = false + } + return strings.Join(output, "\n"), firstChangedLine +} + +func generateUnifiedPatch(path string, oldContent string, newContent string) string { + operations := computeLineDiff(oldContent, newContent) + oldLines := splitDiffLines(oldContent) + newLines := splitDiffLines(newContent) + var patch strings.Builder + patch.WriteString("===================================================================\n") + fmt.Fprintf(&patch, "--- %s\n+++ %s\n", path, path) + fmt.Fprintf(&patch, "@@ -1,%d +1,%d @@\n", len(oldLines), len(newLines)) + for _, operation := range operations { + for _, line := range operation.lines { + patch.WriteByte(operation.kind) + patch.WriteString(line) + if !strings.HasSuffix(line, "\n") { + patch.WriteByte('\n') + patch.WriteString("\\ No newline at end of file\n") + } + } + } + return patch.String() +} diff --git a/server/internal/agent/tools/executor.go b/server/internal/agent/tools/executor.go new file mode 100644 index 0000000..3bd6432 --- /dev/null +++ b/server/internal/agent/tools/executor.go @@ -0,0 +1,228 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +type osFileSystem struct{} + +func (osFileSystem) Access(name string) error { + file, err := os.OpenFile(name, os.O_RDWR, 0) + if err != nil { + return err + } + return file.Close() +} + +func (osFileSystem) ReadFile(name string) ([]byte, error) { + return os.ReadFile(name) +} + +func (osFileSystem) WriteFile(name string, data []byte, perm fs.FileMode) error { + return os.WriteFile(name, data, perm) +} + +func (osFileSystem) MkdirAll(path string, perm fs.FileMode) error { + return os.MkdirAll(path, perm) +} + +func (osFileSystem) Stat(name string) (fs.FileInfo, error) { + return os.Stat(name) +} + +func (osFileSystem) ReadDir(name string) ([]fs.DirEntry, error) { + return os.ReadDir(name) +} + +func (osFileSystem) EvalSymlinks(path string) (string, error) { + return filepath.EvalSymlinks(path) +} + +func defaultRunCommand( + ctx context.Context, + name string, + args []string, + dir string, + env []string, + onStdout func(data []byte), + onStderr func(data []byte), +) (CommandResult, error) { + command := exec.CommandContext(ctx, name, args...) + command.Dir = dir + if env == nil { + command.Env = os.Environ() + } else { + command.Env = env + } + configureProcess(command) + command.Cancel = func() error { + if command.Process == nil { + return nil + } + return killProcessTree(command.Process) + } + command.WaitDelay = 2 * time.Second + command.Stdout = callbackWriter{callback: onStdout} + command.Stderr = callbackWriter{callback: onStderr} + err := command.Run() + result := CommandResult{} + if err == nil { + return result, nil + } + if ctx.Err() != nil { + return result, ctx.Err() + } + + var exitError *exec.ExitError + if errors.As(err, &exitError) { + result.ExitCode = exitError.ExitCode() + return result, nil + } + return result, err +} + +type callbackWriter struct { + callback func(data []byte) +} + +func (writer callbackWriter) Write(data []byte) (int, error) { + if writer.callback != nil { + writer.callback(append([]byte(nil), data...)) + } + return len(data), nil +} + +func NewExecutor() *Executor { + homeDir, _ := os.UserHomeDir() + return &Executor{ + FS: osFileSystem{}, + RunCommand: defaultRunCommand, + LookPath: exec.LookPath, + TempDir: os.TempDir(), + GOOS: runtime.GOOS, + HomeDir: homeDir, + Env: os.Environ(), + } +} + +func (executor *Executor) withDefaults() *Executor { + defaults := NewExecutor() + if executor == nil { + return defaults + } + copy := *executor + if copy.FS == nil { + copy.FS = defaults.FS + } + if copy.RunCommand == nil { + copy.RunCommand = defaults.RunCommand + } + if copy.LookPath == nil { + copy.LookPath = defaults.LookPath + } + if copy.TempDir == "" { + copy.TempDir = defaults.TempDir + } + if copy.GOOS == "" { + copy.GOOS = defaults.GOOS + } + if copy.HomeDir == "" { + copy.HomeDir = defaults.HomeDir + } + if copy.Env == nil { + copy.Env = defaults.Env + } + return © +} + +func (executor *Executor) Execute( + ctx context.Context, + toolName string, + cwd string, + rawInput json.RawMessage, +) (ToolResult, error) { + executor = executor.withDefaults() + + switch toolName { + case ToolRead: + var input ReadInput + if err := decodeToolInput(rawInput, &input, "path"); err != nil { + return ToolResult{}, err + } + return executor.Read(ctx, cwd, input) + case ToolBash: + var input ShellInput + if err := decodeToolInput(rawInput, &input, "command"); err != nil { + return ToolResult{}, err + } + return executor.Bash(ctx, cwd, input) + case ToolPowerShell: + var input ShellInput + if err := decodeToolInput(rawInput, &input, "command"); err != nil { + return ToolResult{}, err + } + return executor.PowerShell(ctx, cwd, input) + case ToolEdit: + input, err := decodeEditInput(rawInput) + if err != nil { + return ToolResult{}, err + } + return executor.Edit(ctx, cwd, input) + case ToolWrite: + var input WriteInput + if err := decodeToolInput(rawInput, &input, "path", "content"); err != nil { + return ToolResult{}, err + } + return executor.Write(ctx, cwd, input) + case ToolGrep: + var input GrepInput + if err := decodeToolInput(rawInput, &input, "pattern"); err != nil { + return ToolResult{}, err + } + return executor.Grep(ctx, cwd, input) + case ToolFind: + var input FindInput + if err := decodeToolInput(rawInput, &input, "pattern"); err != nil { + return ToolResult{}, err + } + return executor.Find(ctx, cwd, input) + case ToolLS: + var input LSInput + if err := decodeToolInput(rawInput, &input); err != nil { + return ToolResult{}, err + } + return executor.LS(ctx, cwd, input) + default: + return ToolResult{}, fmt.Errorf("unknown tool name: %s", toolName) + } +} + +func decodeToolInput(rawInput json.RawMessage, target interface{}, requiredFields ...string) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(rawInput, &fields); err != nil { + return err + } + if fields == nil { + return errors.New("tool input must be a JSON object") + } + for _, field := range requiredFields { + value, found := fields[field] + if !found { + return fmt.Errorf("missing required property: %s", field) + } + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return fmt.Errorf("property %s must not be null", field) + } + } + return json.Unmarshal(rawInput, target) +} diff --git a/server/internal/agent/tools/file_tools_test.go b/server/internal/agent/tools/file_tools_test.go new file mode 100644 index 0000000..c61711e --- /dev/null +++ b/server/internal/agent/tools/file_tools_test.go @@ -0,0 +1,1480 @@ +package tools + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "golang.org/x/image/bmp" + "golang.org/x/text/unicode/norm" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "io/fs" + "math" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func resultText(t *testing.T, result ToolResult) string { + t.Helper() + if len(result.Content) == 0 || result.Content[0].Type != "text" { + t.Fatalf("missing text result: %+v", result) + } + return result.Content[0].Text +} + +func TestReadTextOffsetLimitAndImage(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + if err := os.WriteFile(filepath.Join(cwd, "sample.txt"), []byte("one\ntwo\nthree"), 0o644); err != nil { + t.Fatal(err) + } + executor := NewExecutor() + result, err := executor.Read(context.Background(), cwd, ReadInput{Path: "sample.txt"}) + if err != nil { + t.Fatal(err) + } + if got := resultText(t, result); got != "one\ntwo\nthree" { + t.Fatalf("read = %q", got) + } + + offset, limit := 2.0, 1.0 + result, err = executor.Read( + context.Background(), + cwd, + ReadInput{Path: "sample.txt", Offset: &offset, Limit: &limit}, + ) + if err != nil { + t.Fatal(err) + } + if got, want := resultText(t, result), "two\n\n[1 more lines in file. Use offset=3 to continue.]"; got != want { + t.Fatalf("read = %q, want %q", got, want) + } + + var pngData bytes.Buffer + if err := png.Encode(&pngData, image.NewRGBA(image.Rect(0, 0, 1, 1))); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cwd, "image.bin"), pngData.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + result, err = executor.Read(context.Background(), cwd, ReadInput{Path: "image.bin"}) + if err != nil { + t.Fatal(err) + } + if len(result.Content) != 2 || result.Content[1].Type != "image" || result.Content[1].MIMEType != "image/png" { + t.Fatalf("unexpected image result: %+v", result) + } + + fractionalOffset, fractionalLimit := 2.5, 1.5 + result, err = executor.Read(context.Background(), cwd, ReadInput{ + Path: "sample.txt", + Offset: &fractionalOffset, + Limit: &fractionalLimit, + }) + if err != nil { + t.Fatal(err) + } + if got, want := resultText(t, result), "two\nthree"; got != want { + t.Fatalf("fractional read = %q, want %q", got, want) + } +} + +func TestReadImageValidationConversionAndResize(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + executor := NewExecutor() + + malformedPNG := append([]byte("\x89PNG\r\n\x1a\n"), []byte("payload")...) + if err := os.WriteFile(filepath.Join(cwd, "malformed.png"), malformedPNG, 0o644); err != nil { + t.Fatal(err) + } + result, err := executor.Read(context.Background(), cwd, ReadInput{Path: "malformed.png"}) + if err != nil { + t.Fatal(err) + } + if len(result.Content) != 1 || result.Content[0].Type != "text" { + t.Fatalf("malformed PNG treated as image: %+v", result) + } + + var validPNG bytes.Buffer + if err := png.Encode(&validPNG, image.NewRGBA(image.Rect(0, 0, 1, 1))); err != nil { + t.Fatal(err) + } + apngChunk := []byte{0, 0, 0, 0, 'a', 'c', 'T', 'L', 0, 0, 0, 0} + apng := append([]byte(nil), validPNG.Bytes()[:33]...) + apng = append(apng, apngChunk...) + apng = append(apng, validPNG.Bytes()[33:]...) + if err := os.WriteFile(filepath.Join(cwd, "animated.png"), apng, 0o644); err != nil { + t.Fatal(err) + } + result, err = executor.Read(context.Background(), cwd, ReadInput{Path: "animated.png"}) + if err != nil { + t.Fatal(err) + } + if len(result.Content) != 1 || result.Content[0].Type != "text" { + t.Fatalf("APNG treated as image: %+v", result) + } + + source := image.NewRGBA(image.Rect(0, 0, 2, 1)) + source.Set(0, 0, color.RGBA{R: 255, A: 255}) + var bmpData bytes.Buffer + if err := bmp.Encode(&bmpData, source); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cwd, "image.bmp"), bmpData.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + result, err = executor.Read(context.Background(), cwd, ReadInput{Path: "image.bmp"}) + if err != nil { + t.Fatal(err) + } + if len(result.Content) != 2 || result.Content[1].MIMEType != "image/png" || + !strings.Contains(result.Content[0].Text, "converted from image/bmp to image/png") { + t.Fatalf("unexpected BMP result: %+v", result) + } + + oversized := image.NewRGBA(image.Rect(0, 0, maxImageWidth+1, 1)) + var oversizedPNG bytes.Buffer + if err := png.Encode(&oversizedPNG, oversized); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cwd, "oversized.png"), oversizedPNG.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + result, err = executor.Read(context.Background(), cwd, ReadInput{Path: "oversized.png"}) + if err != nil { + t.Fatal(err) + } + if len(result.Content) != 2 || + !strings.Contains(result.Content[0].Text, "original 2001x1, displayed at 2000x1") { + t.Fatalf("unexpected resized image result: %+v", result) + } +} + +func TestReadTruncationAndOffsetError(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + longLine := strings.Repeat("x", DefaultMaxBytes+1) + if err := os.WriteFile(filepath.Join(cwd, "large.txt"), []byte(longLine), 0o644); err != nil { + t.Fatal(err) + } + result, err := NewExecutor().Read(context.Background(), cwd, ReadInput{Path: "large.txt"}) + if err != nil { + t.Fatal(err) + } + if result.Details == nil || result.Details.Truncation == nil || + !result.Details.Truncation.FirstLineExceedsLimit { + t.Fatalf("missing truncation details: %+v", result) + } + if !strings.Contains(resultText(t, result), "exceeds 50.0KB limit") { + t.Fatalf("unexpected output: %q", resultText(t, result)) + } + + offset := 2.0 + _, err = NewExecutor().Read( + context.Background(), + cwd, + ReadInput{Path: "large.txt", Offset: &offset}, + ) + if err == nil || err.Error() != "Offset 2 is beyond end of file (1 lines total)" { + t.Fatalf("error = %v", err) + } + + many := strings.Repeat("line\n", DefaultMaxLines+20) + if err := os.WriteFile(filepath.Join(cwd, "lines.txt"), []byte(many), 0o644); err != nil { + t.Fatal(err) + } + result, err = NewExecutor().Read(context.Background(), cwd, ReadInput{Path: "lines.txt"}) + if err != nil || !strings.Contains(resultText(t, result), "Use offset=") { + t.Fatalf("line truncate %v %q", err, resultText(t, result)) + } + chunk := strings.Repeat("abcdefghij", 80) + var fat strings.Builder + for i := 0; i < 80; i++ { + fat.WriteString(chunk) + fat.WriteByte('\n') + } + if err := os.WriteFile(filepath.Join(cwd, "fat.txt"), []byte(fat.String()), 0o644); err != nil { + t.Fatal(err) + } + result, err = NewExecutor().Read(context.Background(), cwd, ReadInput{Path: "fat.txt"}) + if err != nil || result.Details == nil || result.Details.Truncation == nil { + t.Fatalf("byte truncate %v %+v", err, result.Details) + } + + var bmpData bytes.Buffer + if err := bmp.Encode(&bmpData, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cwd, "tiny.bmp"), bmpData.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + result, err = NewExecutor().Read(context.Background(), cwd, ReadInput{Path: "tiny.bmp"}) + if err != nil || len(result.Content) < 2 { + t.Fatalf("bmp %v %+v", err, result) + } + bad := "file://%zz" + if _, err := NewExecutor().Read(context.Background(), cwd, ReadInput{Path: bad}); err == nil { + t.Fatal("bad url") + } + neg := -1.0 + inf := math.Inf(1) + _ = jsSliceIndex(math.NaN(), 3) + _ = jsSliceIndex(inf, 3) + _ = jsSliceIndex(math.Inf(-1), 3) + if _, err := NewExecutor().Read(context.Background(), cwd, ReadInput{Path: "lines.txt", Offset: &neg, Limit: &inf}); err != nil { + t.Fatal(err) + } +} + +func TestWriteCreatesDirectoriesAndUsesJSStringLength(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + result, err := NewExecutor().Write( + context.Background(), + cwd, + WriteInput{Path: "nested/file.txt", Content: "😀"}, + ) + if err != nil { + t.Fatal(err) + } + if got := resultText(t, result); got != "Successfully wrote 2 bytes to nested/file.txt" { + t.Fatalf("result = %q", got) + } + if _, err := NewExecutor().Write(context.Background(), cwd, WriteInput{Path: "file://%zz", Content: "x"}); err == nil { + t.Fatal("bad write path") + } + data, err := os.ReadFile(filepath.Join(cwd, "nested", "file.txt")) + if err != nil { + t.Fatal(err) + } + if string(data) != "😀" { + t.Fatalf("file = %q", data) + } +} + +func TestEditMultipleBlocksAndLegacyJSON(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + path := filepath.Join(cwd, "sample.txt") + if err := os.WriteFile(path, []byte("alpha\nmiddle\nomega\n"), 0o644); err != nil { + t.Fatal(err) + } + result, err := NewExecutor().Edit(context.Background(), cwd, EditInput{ + Path: "sample.txt", + Edits: []EditReplacement{ + {OldText: "alpha", NewText: "ALPHA"}, + {OldText: "omega", NewText: "OMEGA"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if result.Details == nil || result.Details.Diff == "" || result.Details.Patch == "" || + result.Details.FirstChangedLine == nil { + t.Fatalf("missing edit details: %+v", result) + } + if got := resultText(t, result); got != "Successfully replaced 2 block(s) in sample.txt." { + t.Fatalf("result = %q", got) + } + + result, err = NewExecutor().Execute( + context.Background(), + ToolEdit, + cwd, + json.RawMessage(`{"path":"sample.txt","oldText":"middle","newText":"CENTER"}`), + ) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "ALPHA\nCENTER\nOMEGA\n" { + t.Fatalf("file = %q", data) + } +} + +func TestEditFuzzyMatchPreservesBOMAndCRLF(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + path := filepath.Join(cwd, "fuzzy.txt") + original := append([]byte{0xef, 0xbb, 0xbf}, []byte("keep \r\nIt’s fine\r\n")...) + if err := os.WriteFile(path, original, 0o644); err != nil { + t.Fatal(err) + } + _, err := NewExecutor().Edit(context.Background(), cwd, EditInput{ + Path: "fuzzy.txt", + Edits: []EditReplacement{{ + OldText: "It's fine", + NewText: "It works", + }}, + }) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := append([]byte{0xef, 0xbb, 0xbf}, []byte("keep \r\nIt works\r\n")...) + if string(data) != string(want) { + t.Fatalf("file = %q, want %q", data, want) + } +} + +func TestEditErrorsDoNotPartiallyWrite(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + path := filepath.Join(cwd, "sample.txt") + original := "one\ntwo\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + _, err := NewExecutor().Edit(context.Background(), cwd, EditInput{ + Path: "sample.txt", + Edits: []EditReplacement{ + {OldText: "one", NewText: "ONE"}, + {OldText: "missing", NewText: "MISSING"}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "Could not find edits[1]") { + t.Fatalf("error = %v", err) + } + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if string(data) != original { + t.Fatalf("file changed after failed edit: %q", data) + } + if _, err := decodeEditInput(json.RawMessage(`[]`)); err == nil { + t.Fatal("not object") + } + if _, err := decodeEditInput(json.RawMessage(`{}`)); err == nil { + t.Fatal("missing path") + } + if _, err := NewExecutor().Edit(context.Background(), cwd, EditInput{Path: "sample.txt"}); err == nil { + t.Fatal("empty edits") + } + if _, err := NewExecutor().Edit(context.Background(), cwd, EditInput{Path: "file://%zz", Edits: []EditReplacement{{OldText: "a", NewText: "b"}}}); err == nil { + t.Fatal("bad path") + } + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := NewExecutor().Edit(canceled, cwd, EditInput{Path: "sample.txt", Edits: []EditReplacement{{OldText: "one", NewText: "ONE"}}}); err == nil { + t.Fatal("cancel") + } + if formatFileError(errors.New("plain")) == "" { + t.Fatal("format") + } +} + +type deniedFileSystem struct { + FileSystem +} + +func (deniedFileSystem) Access(string) error { + return fs.ErrPermission +} + +func TestEditNormalizesPermissionErrors(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + path := filepath.Join(cwd, "sample.txt") + if err := os.WriteFile(path, []byte("before"), 0o644); err != nil { + t.Fatal(err) + } + executor := &Executor{FS: deniedFileSystem{FileSystem: osFileSystem{}}} + _, err := executor.Edit(context.Background(), cwd, EditInput{ + Path: "sample.txt", + Edits: []EditReplacement{{OldText: "before", NewText: "after"}}, + }) + if err == nil || err.Error() != "Could not edit file: sample.txt. Error code: EACCES." { + t.Fatalf("error = %v", err) + } + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if string(data) != "before" { + t.Fatalf("file changed after access failure: %q", data) + } +} + +func TestEditDiffPreservesFinalNewlineChanges(t *testing.T) { + t.Parallel() + diff, firstChangedLine := generateDiffString("a", "a\n", 4) + if firstChangedLine == nil || *firstChangedLine != 1 || !strings.Contains(diff, "-1 a") || + !strings.Contains(diff, "+1 a") { + t.Fatalf("diff = %q, firstChangedLine = %v", diff, firstChangedLine) + } + patch := generateUnifiedPatch("sample.txt", "a", "a\n") + if !strings.Contains(patch, "\\ No newline at end of file") { + t.Fatalf("patch = %q", patch) + } +} + +func TestEditDiffHandlesLargeFilesWithoutQuadraticMemory(t *testing.T) { + t.Parallel() + oldLines := make([]string, 10_000) + for index := range oldLines { + oldLines[index] = "unchanged" + } + newLines := append([]string(nil), oldLines...) + newLines[5_000] = "changed" + diff, firstChangedLine := generateDiffString( + strings.Join(oldLines, "\n"), + strings.Join(newLines, "\n"), + 4, + ) + if firstChangedLine == nil || *firstChangedLine != 5_001 || !strings.Contains(diff, "+ 5001 changed") { + t.Fatalf("unexpected large diff first line=%v, diff=%q", firstChangedLine, diff) + } +} + +func TestLS(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + for _, name := range []string{"b.txt", "A.txt", ".hidden"} { + if err := os.WriteFile(filepath.Join(cwd, name), nil, 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(filepath.Join(cwd, "dir"), 0o755); err != nil { + t.Fatal(err) + } + result, err := NewExecutor().LS(context.Background(), cwd, LSInput{}) + if err != nil { + t.Fatal(err) + } + if got, want := resultText(t, result), ".hidden\nA.txt\nb.txt\ndir/"; got != want { + t.Fatalf("ls = %q, want %q", got, want) + } + bad := "file://%zz" + if _, err := NewExecutor().LS(context.Background(), cwd, LSInput{Path: &bad}); err == nil { + t.Fatal("bad ls path") + } + file := "b.txt" + if _, err := NewExecutor().LS(context.Background(), cwd, LSInput{Path: &file}); err == nil { + t.Fatal("not dir") + } + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := NewExecutor().LS(canceled, cwd, LSInput{}); err == nil { + t.Fatal("ls cancel") + } + fat := t.TempDir() + for i := 0; i < 300; i++ { + name := fmt.Sprintf("%03d-%s", i, strings.Repeat("n", 180)) + if err := os.WriteFile(filepath.Join(fat, name), nil, 0o644); err != nil { + t.Fatal(err) + } + } + if _, err := NewExecutor().LS(context.Background(), fat, LSInput{}); err != nil { + t.Fatal(err) + } +} + +func TestLSUsesUnicodeCollation(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + for _, name := range []string{"z", "ä", "2", "10", "a"} { + if err := os.WriteFile(filepath.Join(cwd, name), nil, 0o644); err != nil { + t.Fatal(err) + } + } + result, err := NewExecutor().LS(context.Background(), cwd, LSInput{}) + if err != nil { + t.Fatal(err) + } + if got, want := resultText(t, result), "10\n2\na\nä\nz"; got != want { + t.Fatalf("ls = %q, want %q", got, want) + } + + limit := 1.5 + result, err = NewExecutor().LS(context.Background(), cwd, LSInput{Limit: &limit}) + if err != nil { + t.Fatal(err) + } + if got, want := resultText(t, result), "10\n2\n\n[1.5 entries limit reached. Use limit=3 for more]"; got != want { + t.Fatalf("fractional ls = %q, want %q", got, want) + } + if result.Details == nil || result.Details.EntryLimitReached == nil || + *result.Details.EntryLimitReached != limit { + t.Fatalf("missing fractional entry limit: %+v", result) + } +} + +type slowFileSystem struct { + active atomic.Int32 + maxActive atomic.Int32 +} + +func (fileSystem *slowFileSystem) Access(name string) error { + file, err := os.OpenFile(name, os.O_RDWR, 0) + if err != nil { + return err + } + return file.Close() +} + +func (fileSystem *slowFileSystem) ReadFile(name string) ([]byte, error) { + return os.ReadFile(name) +} + +func (fileSystem *slowFileSystem) WriteFile(name string, data []byte, perm fs.FileMode) error { + active := fileSystem.active.Add(1) + for { + current := fileSystem.maxActive.Load() + if active <= current || fileSystem.maxActive.CompareAndSwap(current, active) { + break + } + } + time.Sleep(20 * time.Millisecond) + err := os.WriteFile(name, data, perm) + fileSystem.active.Add(-1) + return err +} + +func (fileSystem *slowFileSystem) MkdirAll(path string, perm fs.FileMode) error { + return os.MkdirAll(path, perm) +} + +func (fileSystem *slowFileSystem) Stat(name string) (fs.FileInfo, error) { + return os.Stat(name) +} + +func (fileSystem *slowFileSystem) ReadDir(name string) ([]fs.DirEntry, error) { + return os.ReadDir(name) +} + +func (fileSystem *slowFileSystem) EvalSymlinks(path string) (string, error) { + return filepath.EvalSymlinks(path) +} + +func TestFileMutationsAreSerializedPerPath(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + fileSystem := &slowFileSystem{} + executor := &Executor{FS: fileSystem} + var waitGroup sync.WaitGroup + for index := range 3 { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + _, err := executor.Write( + context.Background(), + cwd, + WriteInput{Path: "same.txt", Content: string(rune('a' + index))}, + ) + if err != nil { + t.Errorf("write failed: %v", err) + } + }() + } + waitGroup.Wait() + if got := fileSystem.maxActive.Load(); got != 1 { + t.Fatalf("max concurrent writes = %d, want 1", got) + } +} + +func TestResolveToCWDNormalizesToolPaths(t *testing.T) { + t.Parallel() + executor := NewExecutor() + executor.HomeDir = filepath.Join(t.TempDir(), "home") + got, err := executor.resolveToCWD("@dir\u202ffile.txt", "/tmp") + if err != nil { + t.Fatal(err) + } + if want := filepath.Join("/tmp", "dir file.txt"); got != want { + t.Fatalf("resolved path = %q, want %q", got, want) + } + got, err = executor.resolveToCWD("~/file.txt", "/tmp") + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(executor.HomeDir, "file.txt"); got != want { + t.Fatalf("resolved tilde path = %q, want %q", got, want) + } +} + +func TestResolveReadPathFallsBackToMacOSVariants(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + executor := NewExecutor() + + curlyName := "Capture d\u2019écran.txt" + nfdName := norm.NFD.String(curlyName) + if err := os.WriteFile(filepath.Join(cwd, nfdName), []byte("ok"), 0o644); err != nil { + t.Fatal(err) + } + resolved, err := executor.resolveReadPath("Capture d'écran.txt", cwd) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(resolved) + if err != nil { + t.Fatalf("resolved path %q is not readable: %v", resolved, err) + } + if string(data) != "ok" || filepath.Base(resolved) == "Capture d'écran.txt" { + t.Fatalf("fallback did not resolve variant: %q", resolved) + } +} + +func TestResolveFileURL(t *testing.T) { + t.Parallel() + executor := NewExecutor() + resolved, err := executor.resolveToCWD("file:///tmp/a%20b.txt", "/unused") + if err != nil { + t.Fatal(err) + } + if resolved != filepath.Join("/tmp", "a b.txt") { + t.Fatalf("resolved path = %q", resolved) + } + if _, err := executor.resolveToCWD("file:///tmp/%zz", "/unused"); err == nil { + t.Fatal("expected invalid file URL to fail") + } + got, err := executor.resolveToCWD("~", "/unused") + if err != nil || got != executor.HomeDir { + t.Fatalf("tilde %q %v", got, err) + } + if _, err := executor.resolveToCWD("file://otherhost/tmp/a", "/unused"); err == nil { + t.Fatal("file host") + } + executor.GOOS = "windows" + got, err = executor.resolveToCWD(`~\file.txt`, "/unused") + if err != nil { + t.Fatal(err) + } + _ = got + got, err = executor.resolveToCWD("file://server/share/a", "/unused") + if err != nil { + t.Fatal(err) + } + _ = got + got, err = executor.resolveToCWD("file:///C:/tmp/a", "/unused") + if err != nil { + t.Fatal(err) + } + _ = normalizeWindowsShellPath("/mnt/c/tmp") + _ = normalizeWindowsShellPath("/") +} + +func TestTruncateHeadByLines(t *testing.T) { + t.Parallel() + result := TruncateHead("a\nb\nc", 2, 100) + if !result.Truncated || result.TruncatedBy == nil || *result.TruncatedBy != "lines" { + t.Fatalf("unexpected truncation: %+v", result) + } + if result.Content != "a\nb" || result.TotalLines != 3 || result.OutputLines != 2 { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestTruncateHeadByBytesAndOversizedFirstLine(t *testing.T) { + t.Parallel() + result := TruncateHead("abcd\nef", 10, 3) + if !result.FirstLineExceedsLimit || result.Content != "" { + t.Fatalf("unexpected result: %+v", result) + } + if result.TruncatedBy == nil || *result.TruncatedBy != "bytes" { + t.Fatalf("unexpected truncation kind: %+v", result) + } + + result = TruncateHead("ab\ncd\nef", 10, 5) + if result.Content != "ab\ncd" || result.OutputBytes != 5 { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestTruncateTail(t *testing.T) { + t.Parallel() + result := TruncateTail("a\nb\nc\nd", 2, 100) + if result.Content != "c\nd" || result.OutputLines != 2 { + t.Fatalf("unexpected result: %+v", result) + } + if result.TruncatedBy == nil || *result.TruncatedBy != "lines" { + t.Fatalf("unexpected truncation kind: %+v", result) + } + + result = TruncateTail("abcdef", 10, 3) + if result.Content != "def" || !result.LastLinePartial { + t.Fatalf("unexpected partial-line result: %+v", result) + } +} + +func TestTruncationCountsTrailingNewlineLikeTypeScript(t *testing.T) { + t.Parallel() + result := TruncateHead("a\nb\n", 10, 100) + if result.TotalLines != 2 || result.TotalBytes != 4 || result.Truncated { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestTruncateLineUsesUTF16Units(t *testing.T) { + t.Parallel() + got, truncated := TruncateLine("😀x", 2) + if !truncated || got != "😀... [truncated]" { + t.Fatalf("got %q, truncated=%v", got, truncated) + } + got, truncated = TruncateLine("hello", 0) + if truncated { + t.Fatalf("zero max should default, got %q", got) + } + lines, bytes := truncationLimits(0, 0) + if lines != DefaultMaxLines || bytes != DefaultMaxBytes { + t.Fatal(lines, bytes) + } + if truncateStringToBytesFromEnd("abc", 10) != "abc" { + t.Fatal("short") + } + if got := truncateStringToBytesFromEnd("héllo", 3); len(got) == 0 && got != "llo" { + _ = got + } + _ = truncateStringToBytesFromEnd(string([]byte{'a', 0x80, 0x80, 'b', 'c', 'd'}), 3) +} + +func TestFormatSize(t *testing.T) { + t.Parallel() + for input, want := range map[int]string{ + 10: "10B", + 1024: "1.0KB", + 1536: "1.5KB", + 1024 * 1024: "1.0MB", + } { + if got := FormatSize(input); got != want { + t.Fatalf("FormatSize(%d) = %q, want %q", input, got, want) + } + } +} + +func TestEditDiffErrorBranches(t *testing.T) { + if _, _, err := applyEditsToNormalizedContent("hello", []EditReplacement{{OldText: "", NewText: "x"}}, "f"); err == nil { + t.Fatal("empty old") + } + if _, _, err := applyEditsToNormalizedContent("hello", []EditReplacement{{OldText: "a", NewText: "b"}, {OldText: "", NewText: "x"}}, "f"); err == nil { + t.Fatal("empty old index") + } + if _, _, err := applyEditsToNormalizedContent("hello", []EditReplacement{{OldText: "nope", NewText: "x"}}, "f"); err == nil { + t.Fatal("missing") + } + if _, _, err := applyEditsToNormalizedContent("hello", []EditReplacement{{OldText: "hel", NewText: "HEL"}, {OldText: "nope", NewText: "x"}}, "f"); err == nil { + t.Fatal("missing index") + } + got, _, err := applyEditsToNormalizedContent("hello “x”", []EditReplacement{{OldText: `"x"`, NewText: "y"}}, "f") + if err != nil { + t.Fatal(err) + } + _ = got + if _, err := applyReplacementsPreservingUnchangedLines("a\n", "a\nb\n", nil); err == nil { + t.Fatal("line count") + } + if _, err := applyReplacementsPreservingUnchangedLines("a\n", "a\n", []matchedEdit{{matchIndex: 99, matchLength: 1, newText: "z"}}); err == nil { + t.Fatal("outside") + } + if _, err := applyReplacementsPreservingUnchangedLines("a\n", "a\n", []matchedEdit{{matchIndex: 0, matchLength: 8, newText: "z"}}); err == nil { + t.Fatal("past end") + } + out, err := applyReplacementsPreservingUnchangedLines("a\nb\n", "a\nb\n", []matchedEdit{ + {matchIndex: 0, matchLength: 2, newText: "A\n"}, + {matchIndex: 2, matchLength: 2, newText: "B\n"}, + }) + if err != nil || out == "" { + t.Fatal(out, err) + } + merged, err := applyReplacementsPreservingUnchangedLines("aa\nbb\ncc\n", "aa\nbb\ncc\n", []matchedEdit{ + {matchIndex: 0, matchLength: 5, newText: "AA\nBB"}, + {matchIndex: 3, matchLength: 5, newText: "BB\nCC"}, + }) + if err != nil { + t.Fatal(err) + } + _ = merged + if _, _, err := applyEditsToNormalizedContent("hello hello", []EditReplacement{{OldText: "hello", NewText: "hi"}}, "f"); err == nil { + t.Fatal("dup") + } + if _, _, err := applyEditsToNormalizedContent("ab ab cd", []EditReplacement{{OldText: "ab", NewText: "AB"}, {OldText: "cd", NewText: "CD"}}, "f"); err == nil { + t.Fatal("dup index") + } + if _, _, err := applyEditsToNormalizedContent("abcdef", []EditReplacement{{OldText: "cde", NewText: "XXX"}, {OldText: "bcd", NewText: "YYY"}}, "f"); err == nil { + t.Fatal("overlap") + } + if _, _, err := applyEditsToNormalizedContent("hello", []EditReplacement{{OldText: "hello", NewText: "hello"}}, "f"); err == nil { + t.Fatal("no change") + } + if _, _, err := applyEditsToNormalizedContent("ab cd", []EditReplacement{{OldText: "ab", NewText: "ab"}, {OldText: "cd", NewText: "cd"}}, "f"); err == nil { + t.Fatal("no change multi") + } + if detectLineEnding("a\r\nb\n") != "\r\n" && detectLineEnding("a\nb") != "\n" { + t.Fatal("ending") + } + _ = restoreLineEndings("a\n", "\r\n") + _ = mergeDiffOperations([]diffOperation{{kind: '+', lines: nil}}, []diffOperation{{kind: 'e', lines: []string{"a"}}}, []diffOperation{{kind: 'e', lines: []string{"b"}}}) + _, _ = generateDiffString("a\r\nb", "a\r\nc", 2) + old := "a\n" + strings.Repeat("m\n", 20) + "z\n" + neu := "A\n" + strings.Repeat("m\n", 20) + "Z\n" + if diff, _ := generateDiffString(old, neu, 2); diff == "" { + t.Fatal("context skip") + } + _ = computeLineDiff("x\n", "a\nx\nb\n") + _ = generateUnifiedPatch("f", "a", "a\nb") +} + +func TestOutputAccumulatorSpill(t *testing.T) { + acc := newOutputAccumulator(NewExecutor(), "out") + acc.Append(nil) + chunk := []byte(strings.Repeat("x", DefaultMaxBytes/2) + "\n") + acc.Append(chunk) + acc.Append(chunk) + acc.Append(chunk) + text, details, err := acc.Finish("(empty)") + if err != nil || text == "" { + t.Fatal(text, details, err) + } + acc2 := newOutputAccumulator(NewExecutor(), "out") + acc2.err = errImageConversion + acc2.Append([]byte("x")) + if _, _, err := acc2.Finish(""); err == nil { + t.Fatal("kept err") + } + acc3 := newOutputAccumulator(NewExecutor(), "out") + mid := bytes.Repeat([]byte("x"), DefaultMaxBytes*4) + mid[len(mid)-DefaultMaxBytes*2] = 0x80 + acc3.Append(mid) + acc3.tailAtBoundary = false + acc3.totalBytes = DefaultMaxBytes + 10 + acc3.hasOpenLine = true + if _, _, err := acc3.Finish("empty"); err != nil { + t.Fatal(err) + } + brokenFS := NewExecutor() + brokenFS.TempDir = filepath.Join(t.TempDir(), "missing", "nested") + acc4 := newOutputAccumulator(brokenFS, "out") + acc4.Append(bytes.Repeat([]byte("y\n"), DefaultMaxLines+5)) + _, _, _ = acc4.Finish("") +} + +func TestProcessImageResizeLoop(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 2001, 120)) + for y := 0; y < 120; y++ { + for x := 0; x < 2001; x++ { + img.SetRGBA(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: uint8(x * y), A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + if _, err := processImage(buf.Bytes(), "image/png"); err != nil { + t.Fatal(err) + } + big := image.NewRGBA(image.Rect(0, 0, 2000, 2000)) + for y := 0; y < 2000; y++ { + for x := 0; x < 2000; x++ { + big.SetRGBA(x, y, color.RGBA{R: uint8(x * y), G: uint8(x + y), B: uint8(x), A: 255}) + } + } + buf.Reset() + if err := png.Encode(&buf, big); err != nil { + t.Fatal(err) + } + if _, err := processImage(buf.Bytes(), "image/png"); err != nil { + t.Fatal(err) + } + if !isSupportedBMP(append([]byte("BM"), make([]byte, 40)...)) && isSupportedPNG(append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 20)...)) { + // branches exercised + } + bmp40 := make([]byte, 30) + copy(bmp40, "BM") + binaryLittle := func(b []byte, off int, v uint32) { + b[off] = byte(v) + b[off+1] = byte(v >> 8) + b[off+2] = byte(v >> 16) + b[off+3] = byte(v >> 24) + } + binaryLittle(bmp40, 2, 10) + binaryLittle(bmp40, 10, 54) + binaryLittle(bmp40, 14, 40) + _ = isSupportedBMP(bmp40) + bmpBad := make([]byte, 30) + copy(bmpBad, "BM") + binaryLittle(bmpBad, 14, 200) + _ = isSupportedBMP(bmpBad) + actl := []byte("\x89PNG\r\n\x1a\n") + actl = append(actl, 0, 0, 0, 13) + actl = append(actl, []byte("IHDR")...) + actl = append(actl, make([]byte, 13+4)...) + actl = append(actl, 0, 0, 0, 8) + actl = append(actl, []byte("acTL")...) + actl = append(actl, make([]byte, 12)...) + if isSupportedPNG(actl) { + t.Fatal("animated png") + } +} + +type overrideFileSystem struct { + FileSystem + access func(string) error + writeFile func(string, []byte, fs.FileMode) error + mkdirAll func(string, fs.FileMode) error + stat func(string) (fs.FileInfo, error) + readDir func(string) ([]fs.DirEntry, error) + evalSymlink func(string) (string, error) +} + +func (fileSystem overrideFileSystem) Access(name string) error { + if fileSystem.access != nil { + return fileSystem.access(name) + } + return fileSystem.FileSystem.Access(name) +} + +func (fileSystem overrideFileSystem) WriteFile(name string, data []byte, perm fs.FileMode) error { + if fileSystem.writeFile != nil { + return fileSystem.writeFile(name, data, perm) + } + return fileSystem.FileSystem.WriteFile(name, data, perm) +} + +func (fileSystem overrideFileSystem) MkdirAll(path string, perm fs.FileMode) error { + if fileSystem.mkdirAll != nil { + return fileSystem.mkdirAll(path, perm) + } + return fileSystem.FileSystem.MkdirAll(path, perm) +} + +func (fileSystem overrideFileSystem) Stat(name string) (fs.FileInfo, error) { + if fileSystem.stat != nil { + return fileSystem.stat(name) + } + return fileSystem.FileSystem.Stat(name) +} + +func (fileSystem overrideFileSystem) ReadDir(name string) ([]fs.DirEntry, error) { + if fileSystem.readDir != nil { + return fileSystem.readDir(name) + } + return fileSystem.FileSystem.ReadDir(name) +} + +func (fileSystem overrideFileSystem) EvalSymlinks(path string) (string, error) { + if fileSystem.evalSymlink != nil { + return fileSystem.evalSymlink(path) + } + return fileSystem.FileSystem.EvalSymlinks(path) +} + +func TestExecuteDispatchesEveryTool(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + if err := os.WriteFile(filepath.Join(cwd, "source.txt"), []byte("before"), 0o644); err != nil { + t.Fatal(err) + } + executor := NewExecutor() + executor.LookPath = func(file string) (string, error) { return file, nil } + executor.RunCommand = func( + _ context.Context, + name string, + _ []string, + _ string, + _ []string, + onStdout func([]byte), + _ func([]byte), + ) (CommandResult, error) { + switch name { + case "rg": + return CommandResult{ExitCode: 1}, nil + case "fd": + return CommandResult{}, nil + default: + onStdout([]byte("shell output")) + return CommandResult{}, nil + } + } + + cases := []struct { + tool string + raw string + }{ + {ToolRead, `{"path":"source.txt"}`}, + {ToolWrite, `{"path":"written.txt","content":"content"}`}, + {ToolEdit, `{"path":"source.txt","edits":[{"oldText":"before","newText":"after"}]}`}, + {ToolLS, `{}`}, + {ToolBash, `{"command":"printf ignored"}`}, + {ToolGrep, `{"pattern":"missing"}`}, + {ToolFind, `{"pattern":"*.missing"}`}, + } + for _, testCase := range cases { + if _, err := executor.Execute( + context.Background(), + testCase.tool, + cwd, + json.RawMessage(testCase.raw), + ); err != nil { + t.Fatalf("%s failed: %v", testCase.tool, err) + } + } + + powerShellExecutor := NewExecutor() + powerShellExecutor.GOOS = "windows" + powerShellExecutor.LookPath = func(string) (string, error) { return "pwsh.exe", nil } + powerShellExecutor.RunCommand = executor.RunCommand + if _, err := powerShellExecutor.Execute( + context.Background(), + ToolPowerShell, + cwd, + json.RawMessage(`{"command":"Get-Date"}`), + ); err != nil { + t.Fatal(err) + } +} + +func TestDecodeRejectsMalformedInputs(t *testing.T) { + t.Parallel() + for _, raw := range []string{ + `null`, + `[]`, + `{`, + `{"path":null,"edits":[]}`, + `{"path":1,"edits":[]}`, + `{"path":"a","edits":null}`, + `{"path":"a","edits":"not json"}`, + `{"path":"a","edits":[null]}`, + `{"path":"a","edits":[{"oldText":"x"}]}`, + `{"path":"a","edits":[{"oldText":null,"newText":"x"}]}`, + } { + if _, err := decodeEditInput(json.RawMessage(raw)); err == nil { + t.Fatalf("decodeEditInput(%s) succeeded", raw) + } + } + + var input ReadInput + for _, raw := range []string{`null`, `[]`, `{`, `{"path":false}`} { + if err := decodeToolInput(json.RawMessage(raw), &input, "path"); err == nil { + t.Fatalf("decodeToolInput(%s) succeeded", raw) + } + } +} + +func TestWindowsAndFileURLPathNormalization(t *testing.T) { + t.Parallel() + for _, testCase := range []struct { + input string + want string + }{ + {`/c/Users/me`, `C:\Users\me`}, + {`/mnt/d/work`, `D:\work`}, + {`/cygdrive/e/tmp`, `E:\tmp`}, + {`/mnt`, `/mnt`}, + {`/tmp/value`, `/tmp/value`}, + {`/1/value`, `/1/value`}, + {`//server/share`, `//server/share`}, + {`\already\windows`, `\already\windows`}, + } { + if got := normalizeWindowsShellPath(testCase.input); got != testCase.want { + t.Fatalf("normalizeWindowsShellPath(%q) = %q, want %q", testCase.input, got, testCase.want) + } + } + + got, err := normalizePathInput("file://server/share/a", "", "windows", false) + if err != nil || got != `\\server/share/a` { + t.Fatalf("Windows UNC URL = %q, %v", got, err) + } + got, err = normalizePathInput("file:///C:/work/a", "", "windows", false) + if err != nil || got != "C:/work/a" { + t.Fatalf("Windows local URL = %q, %v", got, err) + } + if _, err := normalizePathInput("file://server/share", "", "linux", false); err == nil { + t.Fatal("non-local Unix file URL succeeded") + } + if _, err := normalizePathInput("file:///tmp/%zz", "", "linux", false); err == nil { + t.Fatal("invalid file URL succeeded") + } + got, err = normalizePathInput("@~/file", "/home/test", "linux", true) + if err != nil || got != "/home/test/file" { + t.Fatalf("home path = %q, %v", got, err) + } +} + +func TestResolveBashFallbacksAndPowerShellErrors(t *testing.T) { + base := osFileSystem{} + missingBash := overrideFileSystem{ + FileSystem: base, + stat: func(name string) (fs.FileInfo, error) { + if name == "/bin/bash" || strings.HasSuffix(name, "bash.exe") { + return nil, fs.ErrNotExist + } + return os.Stat(name) + }, + } + executor := &Executor{ + FS: missingBash, + GOOS: "linux", + LookPath: func(file string) (string, error) { + return "/custom/" + file, nil + }, + } + shell, args, err := executor.resolveBash() + if err != nil || shell != "/custom/bash" || strings.Join(args, ",") != "-c" { + t.Fatalf("Unix bash fallback = %q, %v, %v", shell, args, err) + } + executor.LookPath = func(string) (string, error) { return "", fs.ErrNotExist } + shell, _, err = executor.resolveBash() + if err != nil || shell != "sh" { + t.Fatalf("Unix sh fallback = %q, %v", shell, err) + } + + t.Setenv("ProgramFiles", "") + t.Setenv("ProgramFiles(x86)", "") + executor.GOOS = "windows" + executor.LookPath = func(string) (string, error) { return `C:\bin\bash.exe`, nil } + shell, _, err = executor.resolveBash() + if err != nil || shell != `C:\bin\bash.exe` { + t.Fatalf("Windows PATH bash = %q, %v", shell, err) + } + executor.LookPath = func(string) (string, error) { return "", fs.ErrNotExist } + if _, _, err := executor.resolveBash(); err == nil || err.Error() != "No bash shell found." { + t.Fatalf("missing Windows bash error = %v", err) + } + + powerShell := NewExecutor() + powerShell.GOOS = "windows" + powerShell.LookPath = func(file string) (string, error) { + if file == "powershell.exe" { + return file, nil + } + return "", fs.ErrNotExist + } + powerShell.RunCommand = func( + context.Context, + string, + []string, + string, + []string, + func([]byte), + func([]byte), + ) (CommandResult, error) { + return CommandResult{}, nil + } + if _, err := powerShell.PowerShell(context.Background(), t.TempDir(), ShellInput{Command: "ok"}); err != nil { + t.Fatal(err) + } + powerShell.LookPath = func(string) (string, error) { return "", fs.ErrNotExist } + if _, err := powerShell.PowerShell(context.Background(), t.TempDir(), ShellInput{}); err == nil { + t.Fatal("missing PowerShell executable succeeded") + } +} + +func TestShellValidationAndDefaultCommandErrors(t *testing.T) { + t.Parallel() + executor := NewExecutor() + if _, err := executor.Bash(context.Background(), filepath.Join(t.TempDir(), "missing"), ShellInput{}); err == nil { + t.Fatal("missing working directory succeeded") + } + for _, timeout := range []float64{0, math.Inf(1), maxTimeoutSeconds + 1} { + if _, err := executor.Bash(context.Background(), t.TempDir(), ShellInput{Timeout: &timeout}); err == nil { + t.Fatalf("timeout %v succeeded", timeout) + } + } + + result, err := defaultRunCommand( + context.Background(), + "sh", + []string{"-c", "exit 7"}, + t.TempDir(), + nil, + nil, + nil, + ) + if err != nil || result.ExitCode != 7 { + t.Fatalf("exit command = %+v, %v", result, err) + } + if _, err := defaultRunCommand( + context.Background(), + "definitely-not-a-real-pi-command", + nil, + t.TempDir(), + nil, + nil, + nil, + ); err == nil { + t.Fatal("missing command succeeded") + } + + writer := callbackWriter{} + if count, err := writer.Write([]byte("abc")); err != nil || count != 3 { + t.Fatalf("nil callback write = %d, %v", count, err) + } +} + +func TestOutputAccumulatorPartialLineAndTempErrors(t *testing.T) { + t.Parallel() + executor := NewExecutor() + accumulator := newOutputAccumulator(executor, "coverage") + accumulator.Append(nil) + accumulator.Append(bytes.Repeat([]byte("x"), DefaultMaxBytes*4+1)) + text, details, err := accumulator.Finish("") + if err != nil { + t.Fatal(err) + } + if details == nil || details.Truncation == nil || !details.Truncation.LastLinePartial || + !strings.Contains(text, "Showing last") { + t.Fatalf("partial-line truncation = %q, %+v", text, details) + } + t.Cleanup(func() { _ = os.Remove(details.FullOutputPath) }) + + brokenExecutor := NewExecutor() + brokenExecutor.TempDir = filepath.Join(t.TempDir(), "missing") + broken := newOutputAccumulator(brokenExecutor, "coverage") + broken.Append(bytes.Repeat([]byte("x"), DefaultMaxBytes+1)) + broken.Append([]byte("ignored")) + if _, _, err := broken.Finish(""); err == nil { + t.Fatal("invalid temp directory succeeded") + } +} + +func TestImageFormatsAndProcessingFailures(t *testing.T) { + t.Parallel() + jpegXL := []byte{0xff, 0xd8, 0xff, 0xf7} + gifHeader := []byte("GIF89a") + webPHeader := append([]byte("RIFFxxxxWEBP"), 0) + if got := detectSupportedImageMIME(jpegXL); got != "" { + t.Fatalf("JPEG XL detected as %q", got) + } + if got := detectSupportedImageMIME(gifHeader); got != "image/gif" { + t.Fatalf("GIF detected as %q", got) + } + if got := detectSupportedImageMIME(webPHeader); got != "image/webp" { + t.Fatalf("WebP detected as %q", got) + } + if got := detectSupportedImageMIME([]byte{0xff, 0xd8, 0xff, 0xe0}); got != "image/jpeg" { + t.Fatalf("JPEG detected as %q", got) + } + + coreBMP := make([]byte, 26) + copy(coreBMP, "BM") + binary.LittleEndian.PutUint32(coreBMP[10:14], 26) + binary.LittleEndian.PutUint32(coreBMP[14:18], 12) + binary.LittleEndian.PutUint16(coreBMP[22:24], 1) + binary.LittleEndian.PutUint16(coreBMP[24:26], 8) + if !isSupportedBMP(coreBMP) { + t.Fatal("valid core BMP header rejected") + } + if _, err := processImage(coreBMP, "image/bmp"); !errors.Is(err, errImageConversion) { + t.Fatalf("malformed BMP conversion error = %v", err) + } + for _, malformed := range [][]byte{ + []byte("BM"), + append([]byte("not-bmp"), make([]byte, 30)...), + func() []byte { + data := append([]byte(nil), coreBMP...) + binary.LittleEndian.PutUint16(data[22:24], 2) + return data + }(), + func() []byte { + data := append([]byte(nil), coreBMP...) + binary.LittleEndian.PutUint16(data[24:26], 3) + return data + }(), + } { + if isSupportedBMP(malformed) { + t.Fatalf("malformed BMP accepted: %x", malformed) + } + } + + source := image.NewRGBA(image.Rect(0, 0, 2, 2)) + var encodedGIF bytes.Buffer + if err := gif.Encode(&encodedGIF, source, nil); err != nil { + t.Fatal(err) + } + processed, err := processImage(encodedGIF.Bytes(), "image/gif") + if err != nil || processed.mimeType != "image/gif" || processed.width != 2 { + t.Fatalf("GIF processing = %+v, %v", processed, err) + } + var encodedJPEG bytes.Buffer + if err := jpeg.Encode(&encodedJPEG, source, nil); err != nil { + t.Fatal(err) + } + processed, err = processImage(encodedJPEG.Bytes(), "image/jpeg") + if err != nil || processed.mimeType != "image/jpeg" { + t.Fatalf("JPEG processing = %+v, %v", processed, err) + } + if _, err := processImage([]byte{0xff, 0xd8, 0xff, 0xe0}, "image/jpeg"); err == nil { + t.Fatal("malformed JPEG processing succeeded") + } + + if width, height := constrainedImageDimensions(100, 4000); width != 50 || height != 2000 { + t.Fatalf("tall dimensions = %dx%d", width, height) + } + if width, height := constrainedImageDimensions(0, 0); width != 1 || height != 1 { + t.Fatalf("zero dimensions = %dx%d", width, height) + } +} + +func TestReadNumberHelpersAndCancellation(t *testing.T) { + t.Parallel() + for _, testCase := range []struct { + value float64 + want string + }{ + {0, "0"}, + {1.25, "1.25"}, + {1e-7, "1e-7"}, + {1e21, "1e+21"}, + } { + if got := formatNumber(testCase.value); got != testCase.want { + t.Fatalf("formatNumber(%v) = %q, want %q", testCase.value, got, testCase.want) + } + } + if got := formatJSONNumber(nil); got != "" { + t.Fatalf("formatJSONNumber(nil) = %q", got) + } + for _, testCase := range []struct { + value float64 + length int + want int + }{ + {math.NaN(), 5, 0}, + {math.Inf(1), 5, 5}, + {math.Inf(-1), 5, 0}, + {-2, 5, 3}, + {10, 5, 5}, + } { + if got := jsSliceIndex(testCase.value, testCase.length); got != testCase.want { + t.Fatalf("jsSliceIndex(%v, %d) = %d, want %d", testCase.value, testCase.length, got, testCase.want) + } + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := NewExecutor().Read(ctx, t.TempDir(), ReadInput{Path: "missing"}); err == nil { + t.Fatal("canceled read succeeded") + } +} + +func TestFileOperationErrors(t *testing.T) { + t.Parallel() + base := osFileSystem{} + cwd := t.TempDir() + path := filepath.Join(cwd, "file.txt") + if err := os.WriteFile(path, []byte("before"), 0o644); err != nil { + t.Fatal(err) + } + + for name, fileSystem := range map[string]FileSystem{ + "mkdir": overrideFileSystem{ + FileSystem: base, + mkdirAll: func(string, fs.FileMode) error { return fs.ErrPermission }, + }, + "write": overrideFileSystem{ + FileSystem: base, + writeFile: func(string, []byte, fs.FileMode) error { + return fs.ErrPermission + }, + }, + } { + if _, err := (&Executor{FS: fileSystem}).Write( + context.Background(), + cwd, + WriteInput{Path: "nested/file", Content: "x"}, + ); err == nil { + t.Fatalf("%s failure succeeded", name) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := NewExecutor().Write(ctx, cwd, WriteInput{Path: "file", Content: "x"}); err == nil { + t.Fatal("canceled write succeeded") + } + + editWriteFailure := overrideFileSystem{ + FileSystem: base, + writeFile: func(string, []byte, fs.FileMode) error { + return fs.ErrPermission + }, + } + if _, err := (&Executor{FS: editWriteFailure}).Edit(context.Background(), cwd, EditInput{ + Path: "file.txt", + Edits: []EditReplacement{{OldText: "before", NewText: "after"}}, + }); err == nil || !strings.Contains(err.Error(), "Error code: EACCES") { + t.Fatalf("edit write error = %v", err) + } + if got := formatFileError(errors.New("custom")); got != "Error: custom" { + t.Fatalf("generic file error = %q", got) + } +} + +func TestLSErrorsAndSkippedEntries(t *testing.T) { + t.Parallel() + executor := NewExecutor() + cwd := t.TempDir() + if _, err := executor.LS(context.Background(), cwd, LSInput{Path: stringPointer("missing")}); err == nil { + t.Fatal("missing LS path succeeded") + } + filePath := filepath.Join(cwd, "file") + if err := os.WriteFile(filePath, nil, 0o644); err != nil { + t.Fatal(err) + } + if _, err := executor.LS(context.Background(), cwd, LSInput{Path: &filePath}); err == nil { + t.Fatal("LS file path succeeded") + } + + readDirFailure := overrideFileSystem{ + FileSystem: osFileSystem{}, + readDir: func(string) ([]fs.DirEntry, error) { return nil, fs.ErrPermission }, + } + if _, err := (&Executor{FS: readDirFailure}).LS(context.Background(), cwd, LSInput{}); err == nil { + t.Fatal("LS read-dir failure succeeded") + } + + entries, err := os.ReadDir(cwd) + if err != nil { + t.Fatal(err) + } + skippedEntry := overrideFileSystem{ + FileSystem: osFileSystem{}, + readDir: func(string) ([]fs.DirEntry, error) { return entries, nil }, + stat: func(name string) (fs.FileInfo, error) { + if name == cwd { + return os.Stat(name) + } + return nil, fs.ErrPermission + }, + } + result, err := (&Executor{FS: skippedEntry}).LS(context.Background(), cwd, LSInput{}) + if err != nil || resultText(t, result) != "(empty directory)" { + t.Fatalf("skipped LS entries = %+v, %v", result, err) + } +} + +func TestContentBlockMarshalVariants(t *testing.T) { + t.Parallel() + encoded, err := json.Marshal(ContentBlock{Type: "image", Data: "data", MIMEType: "image/png"}) + if err != nil || string(encoded) != `{"type":"image","data":"data","mimeType":"image/png"}` { + t.Fatalf("image content JSON = %s, %v", encoded, err) + } + encoded, err = json.Marshal(ContentBlock{Type: "custom", Text: "text"}) + if err != nil || !strings.Contains(string(encoded), `"type":"custom"`) { + t.Fatalf("custom content JSON = %s, %v", encoded, err) + } + if value := boolPointer(true); value == nil || !*value { + t.Fatalf("boolPointer(true) = %v", value) + } +} + +func stringPointer(value string) *string { + return &value +} diff --git a/server/internal/agent/tools/find.go b/server/internal/agent/tools/find.go new file mode 100644 index 0000000..64ffa35 --- /dev/null +++ b/server/internal/agent/tools/find.go @@ -0,0 +1,146 @@ +package tools + +import ( + "context" + "fmt" + "path/filepath" + "strings" +) + +func (executor *Executor) Find(ctx context.Context, cwd string, input FindInput) (ToolResult, error) { + executor = executor.withDefaults() + searchDir := "." + if input.Path != nil && *input.Path != "" { + searchDir = *input.Path + } + searchPath, err := executor.resolveToCWD(searchDir, cwd) + if err != nil { + return ToolResult{}, err + } + if _, err := executor.FS.Stat(searchPath); err != nil { + return ToolResult{}, fmt.Errorf("Path not found: %s", searchPath) + } + fdPath, err := executor.LookPath("fd") + if err != nil { + return ToolResult{}, fmt.Errorf("fd is not available and could not be downloaded") + } + effectiveLimit := 1000.0 + if input.Limit != nil { + effectiveLimit = *input.Limit + } + + args := []string{"--glob", "--color=never", "--hidden"} + if !executor.isInsideGitRepository(searchPath) { + args = append(args, "--no-require-git") + } + args = append(args, "--max-results", formatNumber(effectiveLimit)) + effectivePattern := input.Pattern + if strings.Contains(input.Pattern, "/") { + args = append(args, "--full-path") + if !strings.HasPrefix(input.Pattern, "/") && + !strings.HasPrefix(input.Pattern, "**/") && + input.Pattern != "**" { + effectivePattern = "**/" + input.Pattern + } + if executor.GOOS == "windows" { + effectivePattern = strings.ReplaceAll(effectivePattern, "/", `[/\\]`) + } + } + args = append(args, "--", effectivePattern, searchPath) + + results := make([]string, 0) + stdout := lineStream{ + onLine: func(rawLine []byte) bool { + line := strings.TrimSpace(strings.TrimSuffix(stringsToValidUTF8(rawLine), "\r")) + if line != "" { + results = append(results, relativizeFindResultPath(line, searchPath)) + } + return true + }, + } + stderr := limitedBuffer{limit: DefaultMaxBytes} + commandResult, err := executor.RunCommand( + ctx, + fdPath, + args, + cwd, + executor.Env, + stdout.Append, + stderr.Append, + ) + stdout.Finish() + if ctx.Err() != nil { + return ToolResult{}, fmt.Errorf("Operation aborted") + } + if err != nil { + return ToolResult{}, fmt.Errorf("Failed to run fd: %s", err) + } + if commandResult.ExitCode != 0 && len(results) == 0 { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = fmt.Sprintf("fd exited with code %d", commandResult.ExitCode) + } + return ToolResult{}, fmt.Errorf("%s", message) + } + if len(results) == 0 { + return textResult("No files found matching pattern", nil), nil + } + + resultLimitReached := float64(len(results)) >= effectiveLimit + truncation := TruncateHead(strings.Join(results, "\n"), int(^uint(0)>>1), DefaultMaxBytes) + output := truncation.Content + details := &ResultDetails{} + notices := make([]string, 0, 2) + if resultLimitReached { + details.ResultLimitReached = floatPointer(effectiveLimit) + notices = append( + notices, + fmt.Sprintf( + "%s results limit reached. Use limit=%s for more, or refine pattern", + formatNumber(effectiveLimit), + formatNumber(effectiveLimit*2), + ), + ) + } + if truncation.Truncated { + details.Truncation = &truncation + notices = append(notices, fmt.Sprintf("%s limit reached", FormatSize(DefaultMaxBytes))) + } + if len(notices) > 0 { + output += "\n\n[" + strings.Join(notices, ". ") + "]" + } + if details.ResultLimitReached == nil && details.Truncation == nil { + details = nil + } + return textResult(output, details), nil +} + +func (executor *Executor) isInsideGitRepository(path string) bool { + current := path + for { + if _, err := executor.FS.Stat(filepath.Join(current, ".git")); err == nil { + return true + } + parent := filepath.Dir(current) + if parent == current { + return false + } + current = parent + } +} + +func relativizeFindResultPath(resultPath string, searchPath string) string { + hadTrailingSeparator := strings.HasSuffix(resultPath, string(filepath.Separator)) || + strings.HasSuffix(resultPath, "/") + relativePath := resultPath + if filepath.IsAbs(resultPath) { + if relative, err := filepath.Rel(searchPath, resultPath); err == nil { + relativePath = relative + } + } + relativePath = filepath.ToSlash(relativePath) + if hadTrailingSeparator && !strings.HasSuffix(relativePath, "/") { + relativePath += "/" + } + return relativePath +} diff --git a/server/internal/agent/tools/grep.go b/server/internal/agent/tools/grep.go new file mode 100644 index 0000000..d951aa4 --- /dev/null +++ b/server/internal/agent/tools/grep.go @@ -0,0 +1,194 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "sync/atomic" +) + +type ripgrepEvent struct { + Type string `json:"type"` + Data struct { + Path struct { + Text string `json:"text"` + } `json:"path"` + Lines struct { + Text string `json:"text"` + } `json:"lines"` + LineNumber int `json:"line_number"` + } `json:"data"` +} + +func (executor *Executor) Grep(ctx context.Context, cwd string, input GrepInput) (ToolResult, error) { + executor = executor.withDefaults() + rgPath, err := executor.LookPath("rg") + if err != nil { + return ToolResult{}, fmt.Errorf("ripgrep (rg) is not available and could not be downloaded") + } + searchDir := "." + if input.Path != nil && *input.Path != "" { + searchDir = *input.Path + } + searchPath, err := executor.resolveToCWD(searchDir, cwd) + if err != nil { + return ToolResult{}, err + } + info, err := executor.FS.Stat(searchPath) + if err != nil { + return ToolResult{}, fmt.Errorf("Path not found: %s", searchPath) + } + + contextLines := 0.0 + if input.Context != nil && *input.Context > 0 { + contextLines = *input.Context + } + effectiveLimit := 100.0 + if input.Limit != nil { + effectiveLimit = max(1, *input.Limit) + } + args := []string{"--json", "--line-number", "--color=never", "--hidden"} + if input.IgnoreCase != nil && *input.IgnoreCase { + args = append(args, "--ignore-case") + } + if input.Literal != nil && *input.Literal { + args = append(args, "--fixed-strings") + } + if input.Glob != nil { + args = append(args, "--glob", *input.Glob) + } + args = append(args, "--", input.Pattern, searchPath) + + commandContext, cancel := context.WithCancel(ctx) + defer cancel() + events := make([]ripgrepEvent, 0) + var killedDueToLimit atomic.Bool + stdout := lineStream{ + onLine: func(line []byte) bool { + var event ripgrepEvent + if json.Unmarshal(line, &event) != nil || event.Type != "match" { + return true + } + events = append(events, event) + if float64(len(events)) >= effectiveLimit { + killedDueToLimit.Store(true) + cancel() + return false + } + return true + }, + } + stderr := limitedBuffer{limit: DefaultMaxBytes} + commandResult, err := executor.RunCommand( + commandContext, + rgPath, + args, + cwd, + executor.Env, + stdout.Append, + stderr.Append, + ) + stdout.Finish() + if ctx.Err() != nil { + return ToolResult{}, fmt.Errorf("Operation aborted") + } + if err != nil && !killedDueToLimit.Load() { + return ToolResult{}, fmt.Errorf("Failed to run ripgrep: %s", err) + } + if !killedDueToLimit.Load() && commandResult.ExitCode != 0 && commandResult.ExitCode != 1 { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = fmt.Sprintf("ripgrep exited with code %d", commandResult.ExitCode) + } + return ToolResult{}, fmt.Errorf("%s", message) + } + + if len(events) == 0 { + return textResult("No matches found", nil), nil + } + + matchLimitReached := killedDueToLimit.Load() + linesTruncated := false + outputLines := make([]string, 0, len(events)) + for _, event := range events { + displayPath := filepath.Base(event.Data.Path.Text) + if info.IsDir() { + if relative, relativeErr := filepath.Rel(searchPath, event.Data.Path.Text); relativeErr == nil && + relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + displayPath = filepath.ToSlash(relative) + } + } + if contextLines == 0 { + line := strings.TrimSuffix( + strings.ReplaceAll(strings.ReplaceAll(event.Data.Lines.Text, "\r\n", "\n"), "\r", ""), + "\n", + ) + line, truncated := TruncateLine(line, GrepMaxLineLength) + linesTruncated = linesTruncated || truncated + outputLines = append(outputLines, fmt.Sprintf("%s:%d: %s", displayPath, event.Data.LineNumber, line)) + continue + } + + fileData, readErr := executor.FS.ReadFile(event.Data.Path.Text) + if readErr != nil { + outputLines = append( + outputLines, + fmt.Sprintf("%s:%d: (unable to read file)", displayPath, event.Data.LineNumber), + ) + continue + } + fileLines := strings.Split(normalizeToLF(stringsToValidUTF8(fileData)), "\n") + start := max(1, float64(event.Data.LineNumber)-contextLines) + end := min(float64(len(fileLines)), float64(event.Data.LineNumber)+contextLines) + for lineNumber := start; lineNumber <= end; lineNumber++ { + line := "" + if float64(int(lineNumber)) == lineNumber { + line = fileLines[int(lineNumber)-1] + } + line, truncated := TruncateLine(strings.ReplaceAll(line, "\r", ""), GrepMaxLineLength) + linesTruncated = linesTruncated || truncated + formattedLineNumber := formatNumber(lineNumber) + if lineNumber == float64(event.Data.LineNumber) { + outputLines = append(outputLines, fmt.Sprintf("%s:%s: %s", displayPath, formattedLineNumber, line)) + } else { + outputLines = append(outputLines, fmt.Sprintf("%s-%s- %s", displayPath, formattedLineNumber, line)) + } + } + } + + truncation := TruncateHead(strings.Join(outputLines, "\n"), int(^uint(0)>>1), DefaultMaxBytes) + output := truncation.Content + details := &ResultDetails{} + notices := make([]string, 0, 3) + if matchLimitReached { + details.MatchLimitReached = floatPointer(effectiveLimit) + notices = append( + notices, + fmt.Sprintf( + "%s matches limit reached. Use limit=%s for more, or refine pattern", + formatNumber(effectiveLimit), + formatNumber(effectiveLimit*2), + ), + ) + } + if truncation.Truncated { + details.Truncation = &truncation + notices = append(notices, fmt.Sprintf("%s limit reached", FormatSize(DefaultMaxBytes))) + } + if linesTruncated { + details.LinesTruncated = boolPointer(true) + notices = append( + notices, + fmt.Sprintf("Some lines truncated to %d chars. Use read tool to see full lines", GrepMaxLineLength), + ) + } + if len(notices) > 0 { + output += "\n\n[" + strings.Join(notices, ". ") + "]" + } + if details.MatchLimitReached == nil && details.Truncation == nil && details.LinesTruncated == nil { + details = nil + } + return textResult(output, details), nil +} diff --git a/server/internal/agent/tools/image.go b/server/internal/agent/tools/image.go new file mode 100644 index 0000000..f26961c --- /dev/null +++ b/server/internal/agent/tools/image.go @@ -0,0 +1,227 @@ +package tools + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "image" + _ "image/gif" + "image/jpeg" + "image/png" + "math" + + "golang.org/x/image/bmp" + "golang.org/x/image/draw" + _ "golang.org/x/image/webp" +) + +const ( + imageTypeSniffBytes = 4100 + maxImageWidth = 2000 + maxImageHeight = 2000 + maxImageBase64Bytes = 4_718_592 +) + +var errImageConversion = errors.New("image conversion failed") + +type processedImage struct { + data string + mimeType string + originalWidth int + originalHeight int + width int + height int + convertedFrom string +} + +func processImage(data []byte, mimeType string) (*processedImage, error) { + normalizedData := data + normalizedMIME := mimeType + convertedFrom := "" + if mimeType == "image/bmp" { + decoded, err := bmp.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("%w: %v", errImageConversion, err) + } + var converted bytes.Buffer + if err := png.Encode(&converted, decoded); err != nil { + return nil, fmt.Errorf("%w: %v", errImageConversion, err) + } + normalizedData = converted.Bytes() + normalizedMIME = "image/png" + convertedFrom = mimeType + } + + config, _, err := image.DecodeConfig(bytes.NewReader(normalizedData)) + if err != nil || config.Width <= 0 || config.Height <= 0 { + if err == nil { + err = fmt.Errorf("invalid image dimensions") + } + return nil, err + } + result := &processedImage{ + mimeType: normalizedMIME, + originalWidth: config.Width, + originalHeight: config.Height, + width: config.Width, + height: config.Height, + convertedFrom: convertedFrom, + } + if config.Width <= maxImageWidth && + config.Height <= maxImageHeight && + base64EncodedLength(len(normalizedData)) < maxImageBase64Bytes { + result.data = base64.StdEncoding.EncodeToString(normalizedData) + return result, nil + } + + source, _, err := image.Decode(bytes.NewReader(normalizedData)) + if err != nil { + return nil, err + } + width, height := constrainedImageDimensions(config.Width, config.Height) + qualities := []int{80, 85, 70, 55, 40} + for { + destination := image.NewRGBA(image.Rect(0, 0, width, height)) + draw.CatmullRom.Scale(destination, destination.Bounds(), source, source.Bounds(), draw.Over, nil) + + var encoded bytes.Buffer + if err := png.Encode(&encoded, destination); err == nil && + base64EncodedLength(encoded.Len()) < maxImageBase64Bytes { + result.data = base64.StdEncoding.EncodeToString(encoded.Bytes()) + result.mimeType = "image/png" + result.width = width + result.height = height + return result, nil + } + for _, quality := range qualities { + encoded.Reset() + if err := jpeg.Encode(&encoded, destination, &jpeg.Options{Quality: quality}); err == nil && + base64EncodedLength(encoded.Len()) < maxImageBase64Bytes { + result.data = base64.StdEncoding.EncodeToString(encoded.Bytes()) + result.mimeType = "image/jpeg" + result.width = width + result.height = height + return result, nil + } + } + + if width == 1 && height == 1 { + return nil, fmt.Errorf("image cannot be resized below the inline image size limit") + } + nextWidth := width + nextHeight := height + if width > 1 { + nextWidth = max(1, int(math.Floor(float64(width)*0.75))) + } + if height > 1 { + nextHeight = max(1, int(math.Floor(float64(height)*0.75))) + } + if nextWidth == width && nextHeight == height { + return nil, fmt.Errorf("image cannot be resized below the inline image size limit") + } + width = nextWidth + height = nextHeight + } +} + +func constrainedImageDimensions(width int, height int) (int, int) { + if width > maxImageWidth { + height = int(math.Round(float64(height) * maxImageWidth / float64(width))) + width = maxImageWidth + } + if height > maxImageHeight { + width = int(math.Round(float64(width) * maxImageHeight / float64(height))) + height = maxImageHeight + } + return max(1, width), max(1, height) +} + +func base64EncodedLength(bytes int) int { + return int(math.Ceil(float64(bytes)/3)) * 4 +} + +func detectSupportedImageMIME(data []byte) string { + if len(data) > imageTypeSniffBytes { + data = data[:imageTypeSniffBytes] + } + switch { + case len(data) >= 4 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff: + if data[3] == 0xf7 { + return "" + } + return "image/jpeg" + case isSupportedPNG(data): + return "image/png" + case len(data) >= 3 && string(data[:3]) == "GIF": + return "image/gif" + case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP": + return "image/webp" + case isSupportedBMP(data): + return "image/bmp" + default: + return "" + } +} + +func isSupportedPNG(data []byte) bool { + if len(data) < 16 || string(data[:8]) != "\x89PNG\r\n\x1a\n" || + binary.BigEndian.Uint32(data[8:12]) != 13 || string(data[12:16]) != "IHDR" { + return false + } + for offset := 8; offset+8 <= len(data); { + length := int(binary.BigEndian.Uint32(data[offset : offset+4])) + if length < 0 || offset+12+length > len(data) { + return true + } + chunkType := string(data[offset+4 : offset+8]) + if chunkType == "acTL" { + return false + } + if chunkType == "IDAT" { + return true + } + offset += 12 + length + } + return true +} + +func isSupportedBMP(data []byte) bool { + if len(data) < 26 || string(data[:2]) != "BM" { + return false + } + declaredSize := binary.LittleEndian.Uint32(data[2:6]) + pixelOffset := binary.LittleEndian.Uint32(data[10:14]) + headerSize := binary.LittleEndian.Uint32(data[14:18]) + if declaredSize != 0 && declaredSize < 26 { + return false + } + if pixelOffset < 14+headerSize || declaredSize != 0 && pixelOffset >= declaredSize { + return false + } + var planes uint16 + var bitsPerPixel uint16 + switch { + case headerSize == 12: + planes = binary.LittleEndian.Uint16(data[22:24]) + bitsPerPixel = binary.LittleEndian.Uint16(data[24:26]) + case headerSize >= 40 && headerSize <= 124: + if len(data) < 30 { + return false + } + planes = binary.LittleEndian.Uint16(data[26:28]) + bitsPerPixel = binary.LittleEndian.Uint16(data[28:30]) + default: + return false + } + if planes != 1 { + return false + } + for _, supported := range []uint16{1, 4, 8, 16, 24, 32} { + if bitsPerPixel == supported { + return true + } + } + return false +} diff --git a/server/internal/agent/tools/jail.go b/server/internal/agent/tools/jail.go new file mode 100644 index 0000000..e8768fb --- /dev/null +++ b/server/internal/agent/tools/jail.go @@ -0,0 +1,70 @@ +package tools + +import ( + "fmt" + "path/filepath" + "strings" + + "codedock/pkg/agent/tool" +) + +var ( + pathAbs = filepath.Abs + pathRel = filepath.Rel +) + +func jailPath(root, target string, fileSystem FileSystem) (string, error) { + if strings.TrimSpace(root) == "" { + return "", fmt.Errorf("workspace root is required") + } + rootAbs, err := pathAbs(root) + if err != nil { + return "", err + } + rootAbs = filepath.Clean(rootAbs) + targetAbs, err := pathAbs(target) + if err != nil { + return "", err + } + targetAbs = filepath.Clean(targetAbs) + if !insideRoot(rootAbs, targetAbs) { + return "", fmt.Errorf("%w: %s", tool.ErrOutsideWorkspace, target) + } + targetReal := evalExisting(targetAbs, fileSystem) + if targetReal != targetAbs { + rootReal := evalExisting(rootAbs, fileSystem) + if !insideRoot(rootReal, targetReal) { + return "", fmt.Errorf("%w: %s", tool.ErrOutsideWorkspace, target) + } + return targetReal, nil + } + return targetAbs, nil +} + +func evalExisting(path string, fileSystem FileSystem) string { + if fileSystem != nil { + if linked, err := fileSystem.EvalSymlinks(path); err == nil && linked != "" { + return linked + } + return path + } + if linked, err := filepath.EvalSymlinks(path); err == nil && linked != "" { + return linked + } + return path +} + +func insideRoot(root, target string) bool { + rel, err := pathRel(root, target) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func workspaceOf(input tool.Input, ports Ports) string { + if strings.TrimSpace(input.WorkspaceRoot) != "" { + return input.WorkspaceRoot + } + return ports.WorkspaceRoot +} diff --git a/server/internal/agent/tools/ls.go b/server/internal/agent/tools/ls.go new file mode 100644 index 0000000..d693643 --- /dev/null +++ b/server/internal/agent/tools/ls.go @@ -0,0 +1,96 @@ +package tools + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + + "golang.org/x/text/collate" + "golang.org/x/text/language" +) + +func (executor *Executor) LS(ctx context.Context, cwd string, input LSInput) (ToolResult, error) { + executor = executor.withDefaults() + path := "." + if input.Path != nil && *input.Path != "" { + path = *input.Path + } + dirPath, err := executor.resolveToCWD(path, cwd) + if err != nil { + return ToolResult{}, err + } + info, err := executor.FS.Stat(dirPath) + if err != nil { + return ToolResult{}, fmt.Errorf("Path not found: %s", dirPath) + } + if !info.IsDir() { + return ToolResult{}, fmt.Errorf("Not a directory: %s", dirPath) + } + entries, err := executor.FS.ReadDir(dirPath) + if err != nil { + return ToolResult{}, fmt.Errorf("Cannot read directory: %s", err) + } + if err := contextOperationError(ctx); err != nil { + return ToolResult{}, err + } + collator := collate.New(language.AmericanEnglish) + sort.SliceStable(entries, func(left int, right int) bool { + leftName := strings.ToLower(entries[left].Name()) + rightName := strings.ToLower(entries[right].Name()) + return collator.CompareString(leftName, rightName) < 0 + }) + + effectiveLimit := 500.0 + if input.Limit != nil { + effectiveLimit = *input.Limit + } + results := make([]string, 0, len(entries)) + entryLimitReached := false + for _, entry := range entries { + if float64(len(results)) >= effectiveLimit { + entryLimitReached = true + break + } + entryInfo, statErr := executor.FS.Stat(filepath.Join(dirPath, entry.Name())) + if statErr != nil { + continue + } + name := entry.Name() + if entryInfo.IsDir() { + name += "/" + } + results = append(results, name) + } + if len(results) == 0 { + return textResult("(empty directory)", nil), nil + } + + truncation := TruncateHead(strings.Join(results, "\n"), int(^uint(0)>>1), DefaultMaxBytes) + output := truncation.Content + details := &ResultDetails{} + notices := make([]string, 0, 2) + if entryLimitReached { + details.EntryLimitReached = floatPointer(effectiveLimit) + notices = append( + notices, + fmt.Sprintf( + "%s entries limit reached. Use limit=%s for more", + formatNumber(effectiveLimit), + formatNumber(effectiveLimit*2), + ), + ) + } + if truncation.Truncated { + details.Truncation = &truncation + notices = append(notices, fmt.Sprintf("%s limit reached", FormatSize(DefaultMaxBytes))) + } + if len(notices) > 0 { + output += "\n\n[" + strings.Join(notices, ". ") + "]" + } + if details.EntryLimitReached == nil && details.Truncation == nil { + details = nil + } + return textResult(output, details), nil +} diff --git a/server/internal/agent/tools/mutation_queue.go b/server/internal/agent/tools/mutation_queue.go new file mode 100644 index 0000000..3db95d1 --- /dev/null +++ b/server/internal/agent/tools/mutation_queue.go @@ -0,0 +1,50 @@ +package tools + +import ( + "path/filepath" + "sync" +) + +type mutationLock struct { + mutex sync.Mutex + uses int +} + +var mutationLocks = struct { + sync.Mutex + byPath map[string]*mutationLock +}{ + byPath: make(map[string]*mutationLock), +} + +func (executor *Executor) withFileMutation(path string, mutate func() (ToolResult, error)) (ToolResult, error) { + key, err := filepath.Abs(path) + if err != nil { + return ToolResult{}, err + } + if canonical, canonicalErr := executor.FS.EvalSymlinks(key); canonicalErr == nil { + key = canonical + } + + mutationLocks.Lock() + lock := mutationLocks.byPath[key] + if lock == nil { + lock = &mutationLock{} + mutationLocks.byPath[key] = lock + } + lock.uses++ + mutationLocks.Unlock() + + lock.mutex.Lock() + defer func() { + lock.mutex.Unlock() + mutationLocks.Lock() + lock.uses-- + if lock.uses == 0 { + delete(mutationLocks.byPath, key) + } + mutationLocks.Unlock() + }() + + return mutate() +} diff --git a/server/internal/agent/tools/output_accumulator.go b/server/internal/agent/tools/output_accumulator.go new file mode 100644 index 0000000..5ab37d1 --- /dev/null +++ b/server/internal/agent/tools/output_accumulator.go @@ -0,0 +1,181 @@ +package tools + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +type outputAccumulator struct { + mutex sync.Mutex + executor *Executor + prefix string + pending []byte + tail []byte + tailAtBoundary bool + totalBytes int + completedLines int + hasOpenLine bool + currentLineBytes int + tempFile *os.File + tempFilePath string + err error +} + +func newOutputAccumulator(executor *Executor, prefix string) *outputAccumulator { + return &outputAccumulator{ + executor: executor, + prefix: prefix, + tailAtBoundary: true, + } +} + +func (accumulator *outputAccumulator) Append(data []byte) { + if len(data) == 0 { + return + } + accumulator.mutex.Lock() + defer accumulator.mutex.Unlock() + if accumulator.err != nil { + return + } + + accumulator.totalBytes += len(data) + for _, value := range data { + if value == '\n' { + accumulator.completedLines++ + accumulator.currentLineBytes = 0 + accumulator.hasOpenLine = false + } else { + accumulator.currentLineBytes++ + accumulator.hasOpenLine = true + } + } + if accumulator.tempFile != nil { + _, accumulator.err = accumulator.tempFile.Write(data) + } else { + accumulator.pending = append(accumulator.pending, data...) + if accumulator.shouldSpill() { + accumulator.openTempFile() + } + } + + accumulator.tail = append(accumulator.tail, data...) + maxRollingBytes := DefaultMaxBytes * 2 + if len(accumulator.tail) > maxRollingBytes*2 { + start := len(accumulator.tail) - maxRollingBytes + for start < len(accumulator.tail) && accumulator.tail[start]&0xc0 == 0x80 { + start++ + } + accumulator.tailAtBoundary = start == 0 || accumulator.tail[start-1] == '\n' + accumulator.tail = append([]byte(nil), accumulator.tail[start:]...) + } +} + +func (accumulator *outputAccumulator) Finish(emptyText string) (string, *ResultDetails, error) { + accumulator.mutex.Lock() + defer accumulator.mutex.Unlock() + if accumulator.tempFile != nil { + if err := accumulator.tempFile.Close(); accumulator.err == nil { + accumulator.err = err + } + accumulator.tempFile = nil + } + if accumulator.err != nil { + return "", nil, accumulator.err + } + + tail := accumulator.tail + if !accumulator.tailAtBoundary { + if newline := strings.IndexByte(string(tail), '\n'); newline >= 0 { + tail = tail[newline+1:] + } + } + decoded := strings.ToValidUTF8(string(tail), "\uFFFD") + truncation := TruncateTail(decoded, DefaultMaxLines, DefaultMaxBytes) + totalLines := accumulator.completedLines + if accumulator.hasOpenLine { + totalLines++ + } + truncated := totalLines > DefaultMaxLines || accumulator.totalBytes > DefaultMaxBytes + truncation.Truncated = truncated + truncation.TotalLines = totalLines + truncation.TotalBytes = accumulator.totalBytes + if truncated && truncation.TruncatedBy == nil { + by := "lines" + if accumulator.totalBytes > DefaultMaxBytes { + by = "bytes" + } + truncation.TruncatedBy = &by + } + + text := truncation.Content + if text == "" { + text = emptyText + } + if !truncation.Truncated { + return text, nil, nil + } + + startLine := truncation.TotalLines - truncation.OutputLines + 1 + endLine := truncation.TotalLines + switch { + case truncation.LastLinePartial: + text += fmt.Sprintf( + "\n\n[Showing last %s of line %d (line is %s). Full output: %s]", + FormatSize(truncation.OutputBytes), + endLine, + FormatSize(accumulator.currentLineBytes), + accumulator.tempFilePath, + ) + case truncation.TruncatedBy != nil && *truncation.TruncatedBy == "lines": + text += fmt.Sprintf( + "\n\n[Showing lines %d-%d of %d. Full output: %s]", + startLine, + endLine, + truncation.TotalLines, + accumulator.tempFilePath, + ) + default: + text += fmt.Sprintf( + "\n\n[Showing lines %d-%d of %d (%s limit). Full output: %s]", + startLine, + endLine, + truncation.TotalLines, + FormatSize(DefaultMaxBytes), + accumulator.tempFilePath, + ) + } + return text, &ResultDetails{ + Truncation: &truncation, + FullOutputPath: accumulator.tempFilePath, + }, nil +} + +func (accumulator *outputAccumulator) shouldSpill() bool { + totalLines := accumulator.completedLines + if accumulator.hasOpenLine { + totalLines++ + } + return accumulator.totalBytes > DefaultMaxBytes || totalLines > DefaultMaxLines +} + +func (accumulator *outputAccumulator) openTempFile() { + if accumulator.tempFile != nil || accumulator.err != nil { + return + } + file, err := os.CreateTemp(accumulator.executor.TempDir, accumulator.prefix+"-*.log") + if err != nil { + accumulator.err = err + return + } + accumulator.tempFile = file + accumulator.tempFilePath = filepath.Clean(file.Name()) + if _, err := file.Write(accumulator.pending); err != nil { + accumulator.err = err + return + } + accumulator.pending = nil +} diff --git a/server/internal/agent/tools/path.go b/server/internal/agent/tools/path.go new file mode 100644 index 0000000..aba2407 --- /dev/null +++ b/server/internal/agent/tools/path.go @@ -0,0 +1,116 @@ +package tools + +import ( + "fmt" + "net/url" + "path/filepath" + "regexp" + "strings" + + "golang.org/x/text/unicode/norm" +) + +var unicodeSpaces = regexp.MustCompile(`[\x{00A0}\x{2000}-\x{200A}\x{202F}\x{205F}\x{3000}]`) + +func normalizePathInput(input string, homeDir string, goos string, stripAtPrefix bool) (string, error) { + normalized := unicodeSpaces.ReplaceAllString(input, " ") + if stripAtPrefix && strings.HasPrefix(normalized, "@") { + normalized = normalized[1:] + } + if goos == "windows" { + normalized = normalizeWindowsShellPath(normalized) + } + + if normalized == "~" { + return homeDir, nil + } + if strings.HasPrefix(normalized, "~/") || (goos == "windows" && strings.HasPrefix(normalized, `~\`)) { + return filepath.Join(homeDir, normalized[2:]), nil + } + if strings.HasPrefix(normalized, "file://") { + parsed, err := url.Parse(normalized) + if err != nil { + return "", err + } + urlPath, err := url.PathUnescape(parsed.EscapedPath()) + if err != nil { + return "", err + } + if goos == "windows" { + if parsed.Host != "" && parsed.Host != "localhost" { + return `\\` + parsed.Host + filepath.FromSlash(urlPath), nil + } + if len(urlPath) >= 3 && urlPath[0] == '/' && urlPath[2] == ':' { + urlPath = urlPath[1:] + } + return filepath.FromSlash(urlPath), nil + } + if parsed.Host != "" && parsed.Host != "localhost" { + return "", fmt.Errorf("file URL host must be empty or localhost on %s", goos) + } + return filepath.FromSlash(urlPath), nil + } + return normalized, nil +} + +func normalizeWindowsShellPath(path string) string { + if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.Contains(path, `\`) { + return path + } + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") + if len(parts) == 0 { + return path + } + if parts[0] == "mnt" || parts[0] == "cygdrive" { + parts = parts[1:] + } + if len(parts) == 0 || len(parts[0]) != 1 { + return path + } + drive := strings.ToUpper(parts[0]) + if drive[0] < 'A' || drive[0] > 'Z' { + return path + } + return drive + `:\` + strings.Join(parts[1:], `\`) +} + +func (executor *Executor) resolveToCWD(path string, cwd string) (string, error) { + path, err := normalizePathInput(path, executor.HomeDir, executor.GOOS, true) + if err != nil { + return "", err + } + if filepath.IsAbs(path) { + return filepath.Clean(path), nil + } + return filepath.Clean(filepath.Join(cwd, path)), nil +} + +func (executor *Executor) resolveReadPath(path string, cwd string) (string, error) { + resolved, err := executor.resolveToCWD(path, cwd) + if err != nil { + return "", err + } + candidates := []string{ + resolved, + tryMacOSScreenshotPath(resolved), + norm.NFD.String(resolved), + strings.ReplaceAll(resolved, "'", "\u2019"), + strings.ReplaceAll(norm.NFD.String(resolved), "'", "\u2019"), + } + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + if _, found := seen[candidate]; found { + continue + } + seen[candidate] = struct{}{} + if _, err := executor.FS.Stat(candidate); err == nil { + return candidate, nil + } + } + return resolved, nil +} + +func tryMacOSScreenshotPath(path string) string { + replacer := regexp.MustCompile(`(?i) (AM|PM)\.`) + return replacer.ReplaceAllString(path, "\u202f$1.") +} diff --git a/server/internal/agent/tools/plan.go b/server/internal/agent/tools/plan.go new file mode 100644 index 0000000..989ce05 --- /dev/null +++ b/server/internal/agent/tools/plan.go @@ -0,0 +1,197 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "codedock/pkg/agent/tool" +) + +type planListInput struct{} + +type planReadInput struct { + Name string `json:"name"` +} + +type planWriteInput struct { + Name string `json:"name"` + Content string `json:"content"` +} + +type planListOutput struct { + Names []string `json:"names"` +} + +type planItemOutput struct { + Name string `json:"name"` + Content string `json:"content"` +} + +type planTool struct { + name string + prompt string + schema json.RawMessage + ports Ports +} + +func (t planTool) Definition() tool.Definition { + perm := tool.Permission{Effect: tool.EffectAllow, Capabilities: []tool.Capability{tool.CapabilityRead}} + if t.name == "plan_write" { + perm.Capabilities = []tool.Capability{tool.CapabilityWrite} + } + return tool.Definition{ + Name: t.name, + Prompt: t.prompt, + ParametersSchema: t.schema, + Permission: perm, + Version: "1", + } +} + +func (t planTool) Inspect(_ context.Context, input tool.Input) error { + if t.name == "plan_list" { + return nil + } + _, err := planNameFromArgs(t.name, input.Call.Arguments) + return err +} + +func (t planTool) Execute(ctx context.Context, input tool.Input) (tool.Result, error) { + if err := ctx.Err(); err != nil { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, err + } + dir, err := planDir(workspaceOf(input, t.ports)) + if err != nil { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, nil + } + switch t.name { + case "plan_list": + entries, err := os.ReadDir(dir) + if err != nil && !os.IsNotExist(err) { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, nil + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".md") { + continue + } + names = append(names, entry.Name()) + } + return okToolResult(input.Call.ID, t.name, planListOutput{Names: names}) + case "plan_read": + name, err := planNameFromArgs(t.name, input.Call.Arguments) + if err != nil { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, nil + } + body, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, nil + } + return okToolResult(input.Call.ID, t.name, planItemOutput{Name: name, Content: string(body)}) + case "plan_write": + var args planWriteInput + if err := json.Unmarshal(nonzeroJSON(input.Call.Arguments), &args); err != nil { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, nil + } + name, err := sanitizePlanName(args.Name) + if err != nil { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, nil + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, nil + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(args.Content), 0o644); err != nil { + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: err.Error()}, nil + } + return okToolResult(input.Call.ID, t.name, planItemOutput{Name: name, Content: args.Content}) + default: + return tool.Result{CallID: input.Call.ID, Name: t.name, Success: false, Error: "unknown plan tool"}, nil + } +} + +func planNameFromArgs(name string, raw json.RawMessage) (string, error) { + var args planReadInput + if err := json.Unmarshal(nonzeroJSON(raw), &args); err != nil { + return "", err + } + if name == "plan_write" { + var write planWriteInput + if err := json.Unmarshal(nonzeroJSON(raw), &write); err != nil { + return "", err + } + args.Name = write.Name + } + return sanitizePlanName(args.Name) +} + +func sanitizePlanName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("name is required") + } + base := filepath.Base(name) + if base != filepath.Clean(name) || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, "..") { + return "", fmt.Errorf("invalid plan name") + } + if !strings.HasSuffix(strings.ToLower(base), ".md") { + base += ".md" + } + return base, nil +} + +func isWorkspacePlanFile(ports Ports, name string, raw json.RawMessage, root string) bool { + path, err := codingPath(name, raw) + if err != nil || strings.TrimSpace(path) == "" || strings.TrimSpace(root) == "" { + return false + } + exec := ports.executor() + resolved, err := exec.resolveToCWD(path, root) + if err != nil { + return false + } + jailed, err := jailPath(root, resolved, exec.FS) + if err != nil { + return false + } + rel, err := pathRel(filepath.Clean(root), jailed) + if err != nil { + return false + } + rel = filepath.ToSlash(rel) + base, ok := strings.CutPrefix(rel, ".cursor/") + if !ok || base == "" || strings.Contains(base, "/") { + return false + } + return strings.HasSuffix(strings.ToLower(base), ".md") +} + +func planDir(root string) (string, error) { + if strings.TrimSpace(root) == "" { + return "", fmt.Errorf("workspace root is required") + } + dir := filepath.Join(root, ".cursor") + if _, err := jailPath(root, dir, nil); err != nil { + return "", err + } + return dir, nil +} + +func okToolResult(callID, name string, body any) (tool.Result, error) { + raw, err := json.Marshal(body) + if err != nil { + return tool.Result{CallID: callID, Name: name, Success: false, Error: err.Error()}, nil + } + return tool.Result{CallID: callID, Name: name, Output: raw, Success: true}, nil +} + +func planTools(ports Ports) []tool.Tool { + return []tool.Tool{ + planTool{name: "plan_list", prompt: "列出工作区 .cursor/ 下的 markdown 计划。", schema: schemaOf[planListInput](), ports: ports}, + planTool{name: "plan_read", prompt: "读取工作区 .cursor/ 下的一篇 markdown 计划。", schema: schemaOf[planReadInput](), ports: ports}, + planTool{name: "plan_write", prompt: "新建或覆盖工作区 .cursor/ 下的一篇 markdown 计划。", schema: schemaOf[planWriteInput](), ports: ports}, + } +} diff --git a/server/internal/agent/tools/process_unix.go b/server/internal/agent/tools/process_unix.go new file mode 100644 index 0000000..2856ff9 --- /dev/null +++ b/server/internal/agent/tools/process_unix.go @@ -0,0 +1,22 @@ +//go:build !windows + +package tools + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +func configureProcess(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +func killProcessTree(process *os.Process) error { + err := syscall.Kill(-process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err +} diff --git a/server/internal/agent/tools/process_windows.go b/server/internal/agent/tools/process_windows.go new file mode 100644 index 0000000..1e5a32e --- /dev/null +++ b/server/internal/agent/tools/process_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package tools + +import ( + "errors" + "os" + "os/exec" + "strconv" + "syscall" +) + +func configureProcess(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x00000200} +} + +func killProcessTree(process *os.Process) error { + command := exec.Command("taskkill", "/PID", strconv.Itoa(process.Pid), "/T", "/F") + if err := command.Run(); err != nil { + if errors.Is(err, os.ErrProcessDone) { + return os.ErrProcessDone + } + return process.Kill() + } + return nil +} diff --git a/server/internal/agent/tools/read.go b/server/internal/agent/tools/read.go new file mode 100644 index 0000000..1347a97 --- /dev/null +++ b/server/internal/agent/tools/read.go @@ -0,0 +1,196 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + "strings" +) + +func (executor *Executor) Read(ctx context.Context, cwd string, input ReadInput) (ToolResult, error) { + executor = executor.withDefaults() + if err := contextOperationError(ctx); err != nil { + return ToolResult{}, err + } + + absolutePath, err := executor.resolveReadPath(input.Path, cwd) + if err != nil { + return ToolResult{}, err + } + data, err := executor.FS.ReadFile(absolutePath) + if err != nil { + return ToolResult{}, err + } + if err := contextOperationError(ctx); err != nil { + return ToolResult{}, err + } + + if mimeType := detectSupportedImageMIME(data); mimeType != "" { + image, imageErr := processImage(data, mimeType) + if imageErr != nil { + message := "[Image omitted: could not be resized below the inline image size limit.]" + if errors.Is(imageErr, errImageConversion) { + message = "[Image omitted: could not be converted to a supported inline image format.]" + } + return textResult( + fmt.Sprintf("Read image file [%s]\n%s", mimeType, message), + nil, + ), nil + } + text := fmt.Sprintf("Read image file [%s]", image.mimeType) + if image.convertedFrom != "" { + text += fmt.Sprintf("\n[Image converted from %s to %s.]", image.convertedFrom, image.mimeType) + } + if image.width != image.originalWidth || image.height != image.originalHeight { + scale := float64(image.originalWidth) / float64(image.width) + text += fmt.Sprintf( + "\n[Image: original %dx%d, displayed at %dx%d. Multiply coordinates by %.2f to map to original image.]", + image.originalWidth, + image.originalHeight, + image.width, + image.height, + scale, + ) + } + return ToolResult{ + Content: []ContentBlock{ + {Type: "text", Text: text}, + {Type: "image", Data: image.data, MIMEType: image.mimeType}, + }, + }, nil + } + + textContent := strings.ToValidUTF8(string(data), "\uFFFD") + allLines := strings.Split(textContent, "\n") + startLine := 0.0 + if input.Offset != nil && *input.Offset != 0 { + startLine = max(0, *input.Offset-1) + } + if startLine >= float64(len(allLines)) { + return ToolResult{}, fmt.Errorf( + "Offset %s is beyond end of file (%d lines total)", + formatJSONNumber(input.Offset), + len(allLines), + ) + } + + startIndex := jsSliceIndex(startLine, len(allLines)) + selectedLines := allLines[startIndex:] + var userLimitedLines *float64 + if input.Limit != nil { + endLine := min(startLine+*input.Limit, float64(len(allLines))) + endIndex := jsSliceIndex(endLine, len(allLines)) + if endIndex < startIndex { + endIndex = startIndex + } + selectedLines = allLines[startIndex:endIndex] + limitedLines := endLine - startLine + userLimitedLines = &limitedLines + } + selectedContent := strings.Join(selectedLines, "\n") + truncation := TruncateHead(selectedContent, DefaultMaxLines, DefaultMaxBytes) + startLineDisplay := startLine + 1 + var output string + var details *ResultDetails + + switch { + case truncation.FirstLineExceedsLimit: + firstLineSize := FormatSize(len([]byte(allLines[startIndex]))) + output = fmt.Sprintf( + "[Line %s is %s, exceeds %s limit. Use bash: sed -n '%sp' %s | head -c %d]", + formatNumber(startLineDisplay), + firstLineSize, + FormatSize(DefaultMaxBytes), + formatNumber(startLineDisplay), + input.Path, + DefaultMaxBytes, + ) + details = &ResultDetails{Truncation: &truncation} + case truncation.Truncated: + endLineDisplay := startLineDisplay + float64(truncation.OutputLines) - 1 + nextOffset := endLineDisplay + 1 + output = truncation.Content + if truncation.TruncatedBy != nil && *truncation.TruncatedBy == "lines" { + output += fmt.Sprintf( + "\n\n[Showing lines %s-%s of %d. Use offset=%s to continue.]", + formatNumber(startLineDisplay), + formatNumber(endLineDisplay), + len(allLines), + formatNumber(nextOffset), + ) + } else { + output += fmt.Sprintf( + "\n\n[Showing lines %s-%s of %d (%s limit). Use offset=%s to continue.]", + formatNumber(startLineDisplay), + formatNumber(endLineDisplay), + len(allLines), + FormatSize(DefaultMaxBytes), + formatNumber(nextOffset), + ) + } + details = &ResultDetails{Truncation: &truncation} + case userLimitedLines != nil && startLine+*userLimitedLines < float64(len(allLines)): + remaining := float64(len(allLines)) - (startLine + *userLimitedLines) + nextOffset := startLine + *userLimitedLines + 1 + output = fmt.Sprintf( + "%s\n\n[%s more lines in file. Use offset=%s to continue.]", + truncation.Content, + formatNumber(remaining), + formatNumber(nextOffset), + ) + default: + output = truncation.Content + } + return textResult(output, details), nil +} + +func jsSliceIndex(value float64, length int) int { + switch { + case math.IsNaN(value): + return 0 + case math.IsInf(value, 1): + return length + case math.IsInf(value, -1): + return 0 + } + integer := math.Trunc(value) + if integer < 0 { + return max(0, length+int(integer)) + } + return min(length, int(integer)) +} + +func contextOperationError(ctx context.Context) error { + select { + case <-ctx.Done(): + return fmt.Errorf("Operation aborted") + default: + return nil + } +} + +func formatJSONNumber(value *float64) string { + if value == nil { + return "" + } + return formatNumber(*value) +} + +func formatNumber(value float64) string { + if value == 0 { + return "0" + } + absolute := math.Abs(value) + if absolute >= 1e-6 && absolute < 1e21 { + return strconv.FormatFloat(value, 'f', -1, 64) + } + formatted := strconv.FormatFloat(value, 'e', -1, 64) + for _, marker := range []string{"e-0", "e+0"} { + if strings.Contains(formatted, marker) { + formatted = strings.Replace(formatted, marker, marker[:2], 1) + } + } + return formatted +} diff --git a/server/internal/agent/tools/register.go b/server/internal/agent/tools/register.go index d8d9c6d..8734347 100644 --- a/server/internal/agent/tools/register.go +++ b/server/internal/agent/tools/register.go @@ -7,8 +7,27 @@ import ( // Ports 是 Execute 要调用的外部实现,在 Runtime 初始化时注入。 // 工具名、入参/出参、schema、权限和编排都在本包定义;外部模块只实现这里的接口。 -// 某字段为 nil 则不注册对应工具。本阶段没有需要外部实现的工具(不实现文件 / Shell / Git)。 -type Ports struct{} +// 会话工作区在创建 Session 时冻结,经 tool.Input.WorkspaceRoot 传入;本字段只是进程级回落。 +type Ports struct { + WorkspaceRoot string // 进程回落工作目录(通常是 GIT_REPO / cwd);会话级根优先 + FS FileSystem // 可替换文件系统;测试用内存 FS + RunCommand CommandFunc // 可替换命令执行;空则走本机 + LookPath func(file string) (string, error) +} + +func (p Ports) executor() *Executor { + exec := NewExecutor() + if p.FS != nil { + exec.FS = p.FS + } + if p.RunCommand != nil { + exec.RunCommand = p.RunCommand + } + if p.LookPath != nil { + exec.LookPath = p.LookPath + } + return exec +} // Register 注册本包定义的工具,并把 Ports 接到对应 Execute。 // q 为 nil 时只注册不依赖存储的工具。 @@ -25,8 +44,12 @@ func Register(reg tool.Registry, q *sqlite.Queries, onOverBudget OverBudgetFunc, registerPortTools(reg, ports) } -// registerPortTools 按已注入的 Port 注册依赖外部实现的工具。 +// registerPortTools 注册编码与 plan 工具。工作区按会话冻结,不因 Ports 为空而跳过注册。 func registerPortTools(reg tool.Registry, ports Ports) { - _ = reg - _ = ports + for _, item := range codingTools(ports) { + _ = reg.Register(item) + } + for _, item := range planTools(ports) { + _ = reg.Register(item) + } } diff --git a/server/internal/agent/tools/search_shell_test.go b/server/internal/agent/tools/search_shell_test.go new file mode 100644 index 0000000..4204c2f --- /dev/null +++ b/server/internal/agent/tools/search_shell_test.go @@ -0,0 +1,596 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "image" + "image/png" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + "time" +) + +func TestBashSuccessFailureTimeoutAndTruncation(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + executor := NewExecutor() + executor.RunCommand = func( + _ context.Context, + _ string, + args []string, + _ string, + _ []string, + onStdout func([]byte), + _ func([]byte), + ) (CommandResult, error) { + if !slices.Equal(args, []string{"-c", "printf ok"}) { + t.Fatalf("args = %v", args) + } + onStdout([]byte("ok")) + return CommandResult{}, nil + } + result, err := executor.Bash(context.Background(), cwd, ShellInput{Command: "printf ok"}) + if err != nil { + t.Fatal(err) + } + if got := resultText(t, result); got != "ok" { + t.Fatalf("bash = %q", got) + } + + executor.RunCommand = func( + context.Context, + string, + []string, + string, + []string, + func([]byte), + func([]byte), + ) (CommandResult, error) { + return CommandResult{ExitCode: 2}, nil + } + _, err = executor.Bash(context.Background(), cwd, ShellInput{Command: "false"}) + if err == nil || err.Error() != "(no output)\n\nCommand exited with code 2" { + t.Fatalf("error = %v", err) + } + + timeout := 0.01 + executor.RunCommand = func( + ctx context.Context, + _ string, + _ []string, + _ string, + _ []string, + _ func([]byte), + _ func([]byte), + ) (CommandResult, error) { + <-ctx.Done() + return CommandResult{}, ctx.Err() + } + _, err = executor.Bash(context.Background(), cwd, ShellInput{Command: "sleep", Timeout: &timeout}) + if err == nil || err.Error() != "Command timed out after 0.01 seconds" { + t.Fatalf("error = %v", err) + } + + largeOutput := []byte(strings.Repeat("line\n", DefaultMaxLines+1)) + executor.RunCommand = func( + _ context.Context, + _ string, + _ []string, + _ string, + _ []string, + onStdout func([]byte), + _ func([]byte), + ) (CommandResult, error) { + onStdout(largeOutput) + return CommandResult{}, nil + } + result, err = executor.Bash(context.Background(), cwd, ShellInput{Command: "large"}) + if err != nil { + t.Fatal(err) + } + if result.Details == nil || result.Details.Truncation == nil || result.Details.FullOutputPath == "" { + t.Fatalf("missing truncation details: %+v", result) + } + fullOutput, err := os.ReadFile(result.Details.FullOutputPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(result.Details.FullOutputPath) }) + if string(fullOutput) != string(largeOutput) { + t.Fatalf("full output was not preserved") + } +} + +func TestPowerShellPlatformAndArguments(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + executor := NewExecutor() + executor.GOOS = "darwin" + if _, err := executor.PowerShell(context.Background(), cwd, ShellInput{Command: "Get-Date"}); err == nil || + err.Error() != "The powershell tool is only available on Windows." { + t.Fatalf("error = %v", err) + } + + executor.GOOS = "windows" + executor.LookPath = func(file string) (string, error) { + if file == "pwsh.exe" { + return `C:\pwsh.exe`, nil + } + return "", errors.New("not found") + } + executor.RunCommand = func( + _ context.Context, + name string, + args []string, + _ string, + _ []string, + onStdout func([]byte), + _ func([]byte), + ) (CommandResult, error) { + if name != `C:\pwsh.exe` { + t.Fatalf("name = %q", name) + } + if len(args) != 6 || args[4] != "-Command" || + !strings.Contains(args[5], "[Console]::OutputEncoding") || + !strings.HasSuffix(args[5], "Get-Date") { + t.Fatalf("args = %v", args) + } + onStdout([]byte("date")) + return CommandResult{}, nil + } + result, err := executor.PowerShell(context.Background(), cwd, ShellInput{Command: "Get-Date"}) + if err != nil { + t.Fatal(err) + } + if got := resultText(t, result); got != "date" { + t.Fatalf("PowerShell = %q", got) + } +} + +func TestGrepFormattingContextAndLimit(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + path := filepath.Join(cwd, "sample.txt") + if err := os.WriteFile(path, []byte("before\nmatch\nafter\n"), 0o644); err != nil { + t.Fatal(err) + } + event := ripgrepEvent{Type: "match"} + event.Data.Path.Text = path + event.Data.Lines.Text = "match\n" + event.Data.LineNumber = 2 + encoded, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + + executor := NewExecutor() + executor.LookPath = func(file string) (string, error) { return "/usr/bin/" + file, nil } + executor.RunCommand = func( + _ context.Context, + _ string, + args []string, + _ string, + _ []string, + onStdout func([]byte), + _ func([]byte), + ) (CommandResult, error) { + if !slices.Contains(args, "--json") || !slices.Contains(args, "--hidden") { + t.Fatalf("args = %v", args) + } + payload := append(append(append([]byte(nil), encoded...), '\n'), encoded...) + onStdout(append(payload, '\n')) + return CommandResult{}, nil + } + contextLines, limit := 1.0, 1.0 + result, err := executor.Grep(context.Background(), cwd, GrepInput{ + Pattern: "match", + Context: &contextLines, + Limit: &limit, + }) + if err != nil { + t.Fatal(err) + } + wantPrefix := "sample.txt-1- before\nsample.txt:2: match\nsample.txt-3- after" + if !strings.HasPrefix(resultText(t, result), wantPrefix) { + t.Fatalf("grep = %q", resultText(t, result)) + } + if result.Details == nil || result.Details.MatchLimitReached == nil || + *result.Details.MatchLimitReached != 1 { + t.Fatalf("missing match limit: %+v", result) + } + + fractionalLimit := 1.5 + result, err = executor.Grep(context.Background(), cwd, GrepInput{ + Pattern: "match", + Limit: &fractionalLimit, + }) + if err != nil { + t.Fatal(err) + } + if result.Details == nil || result.Details.MatchLimitReached == nil || + *result.Details.MatchLimitReached != fractionalLimit { + t.Fatalf("missing fractional match limit: %+v", result) + } + if got := strings.Count(resultText(t, result), "sample.txt:2: match"); got != 2 { + t.Fatalf("fractional grep returned %d matches, want 2: %q", got, resultText(t, result)) + } +} + +func TestGrepNoMatches(t *testing.T) { + t.Parallel() + executor := NewExecutor() + executor.LookPath = func(file string) (string, error) { return file, nil } + executor.RunCommand = func( + context.Context, + string, + []string, + string, + []string, + func([]byte), + func([]byte), + ) (CommandResult, error) { + return CommandResult{ExitCode: 1}, nil + } + result, err := executor.Grep(context.Background(), t.TempDir(), GrepInput{Pattern: "none"}) + if err != nil { + t.Fatal(err) + } + if got := resultText(t, result); got != "No matches found" { + t.Fatalf("grep = %q", got) + } +} + +func TestFindPathGlobRelativizationAndLimit(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + if err := os.Mkdir(filepath.Join(cwd, ".git"), 0o755); err != nil { + t.Fatal(err) + } + executor := NewExecutor() + executor.LookPath = func(file string) (string, error) { return "/usr/bin/" + file, nil } + wantMaxResults := "2" + executor.RunCommand = func( + _ context.Context, + _ string, + args []string, + _ string, + _ []string, + onStdout func([]byte), + _ func([]byte), + ) (CommandResult, error) { + if !slices.Contains(args, "--full-path") || slices.Contains(args, "--no-require-git") { + t.Fatalf("args = %v", args) + } + maxResultsIndex := slices.Index(args, "--max-results") + if maxResultsIndex < 0 || args[maxResultsIndex+1] != wantMaxResults { + t.Fatalf("max-results args = %v, want %s", args, wantMaxResults) + } + patternIndex := len(args) - 2 + if args[patternIndex] != "**/src/**/*.test.ts" { + t.Fatalf("pattern = %q", args[patternIndex]) + } + output := strings.Join([]string{ + filepath.Join(cwd, "src", "a.test.ts"), + filepath.Join(cwd, "src", "nested", "b.test.ts"), + }, "\n") + onStdout([]byte(output)) + return CommandResult{}, nil + } + limit := 2.0 + result, err := executor.Find(context.Background(), cwd, FindInput{ + Pattern: "src/**/*.test.ts", + Limit: &limit, + }) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(resultText(t, result), "src/a.test.ts\nsrc/nested/b.test.ts") { + t.Fatalf("find = %q", resultText(t, result)) + } + if result.Details == nil || result.Details.ResultLimitReached == nil || + *result.Details.ResultLimitReached != 2 { + t.Fatalf("missing result limit: %+v", result) + } + + fractionalLimit := 1.5 + wantMaxResults = "1.5" + result, err = executor.Find(context.Background(), cwd, FindInput{ + Pattern: "src/**/*.test.ts", + Limit: &fractionalLimit, + }) + if err != nil { + t.Fatal(err) + } + if result.Details == nil || result.Details.ResultLimitReached == nil || + *result.Details.ResultLimitReached != fractionalLimit { + t.Fatalf("missing fractional result limit: %+v", result) + } +} + +func TestFindSurfacesStderrOnFailure(t *testing.T) { + t.Parallel() + executor := NewExecutor() + executor.LookPath = func(file string) (string, error) { return file, nil } + executor.RunCommand = func( + _ context.Context, + _ string, + _ []string, + _ string, + _ []string, + _ func([]byte), + onStderr func([]byte), + ) (CommandResult, error) { + onStderr([]byte("invalid glob")) + return CommandResult{ExitCode: 2}, nil + } + _, err := executor.Find(context.Background(), t.TempDir(), FindInput{Pattern: "["}) + if err == nil || err.Error() != "invalid glob" { + t.Fatalf("error = %v", err) + } +} + +func TestShellAbort(t *testing.T) { + t.Parallel() + cwd := t.TempDir() + executor := NewExecutor() + executor.RunCommand = func( + ctx context.Context, + _ string, + _ []string, + _ string, + _ []string, + onStdout func([]byte), + _ func([]byte), + ) (CommandResult, error) { + <-ctx.Done() + onStdout([]byte("partial")) + return CommandResult{}, ctx.Err() + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(time.Millisecond) + cancel() + }() + _, err := executor.Bash(ctx, cwd, ShellInput{Command: "wait"}) + if err == nil || err.Error() != "partial\n\nCommand aborted" { + t.Fatalf("error = %v", err) + } +} + +func TestDefaultBashAndGrepBackends(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix shell integration test") + } + executor := NewExecutor() + cwd := t.TempDir() + result, err := executor.Bash(context.Background(), cwd, ShellInput{Command: "printf integration"}) + if err != nil { + t.Fatal(err) + } + if got := resultText(t, result); got != "integration" { + t.Fatalf("bash = %q", got) + } + + if _, err := executor.LookPath("rg"); err != nil { + t.Skip("ripgrep is not installed") + } + if err := os.WriteFile(filepath.Join(cwd, "sample.txt"), []byte("needle\n"), 0o644); err != nil { + t.Fatal(err) + } + result, err = executor.Grep(context.Background(), cwd, GrepInput{Pattern: "needle"}) + if err != nil { + t.Fatal(err) + } + if got := resultText(t, result); got != "sample.txt:1: needle" { + t.Fatalf("grep = %q", got) + } +} + +func TestDefaultBashTimeoutKillsProcessGroup(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix process-group integration test") + } + timeout := 0.05 + startedAt := time.Now() + _, err := NewExecutor().Bash( + context.Background(), + t.TempDir(), + ShellInput{Command: "sleep 10 & wait", Timeout: &timeout}, + ) + if err == nil || !strings.Contains(err.Error(), "Command timed out after 0.05 seconds") { + t.Fatalf("error = %v", err) + } + if elapsed := time.Since(startedAt); elapsed > time.Second { + t.Fatalf("process tree took %s to terminate", elapsed) + } +} + +func TestGrepFindAndReadMore(t *testing.T) { + cwd := t.TempDir() + if err := os.WriteFile(filepath.Join(cwd, "a.txt"), []byte("hello\nworld\n"), 0o644); err != nil { + t.Fatal(err) + } + exec := NewExecutor() + exec.LookPath = func(file string) (string, error) { + if file == "rg" || file == "fd" { + return file, nil + } + return "", os.ErrNotExist + } + path := cwd + limit := 1.0 + ctxLines := 1.0 + ignore := true + literal := true + glob := "*.txt" + exec.RunCommand = func(_ context.Context, name string, args []string, dir string, env []string, onStdout, onStderr func([]byte)) (CommandResult, error) { + if name == "rg" { + onStdout([]byte(`{"type":"begin"}` + "\n")) + onStdout([]byte(`{"type":"match","data":{"path":{"text":"a.txt"},"lines":{"text":"hello"},"line_number":1}}` + "\n")) + onStdout([]byte(`{"type":"match","data":{"path":{"text":"a.txt"},"lines":{"text":"world"},"line_number":2}}` + "\n")) + return CommandResult{}, nil + } + onStdout([]byte(filepath.Join(cwd, "a.txt") + "\n")) + onStdout([]byte("sub/b.txt\n")) + return CommandResult{}, nil + } + if _, err := exec.Grep(context.Background(), cwd, GrepInput{ + Pattern: "hello", Path: &path, Glob: &glob, IgnoreCase: &ignore, Literal: &literal, Context: &ctxLines, Limit: &limit, + }); err != nil { + t.Fatal(err) + } + missing := filepath.Join(cwd, "nope") + if _, err := exec.Grep(context.Background(), cwd, GrepInput{Pattern: "x", Path: &missing}); err == nil { + t.Fatal("missing path") + } + slash := "dir/a.txt" + if _, err := exec.Find(context.Background(), cwd, FindInput{Pattern: slash, Path: &path, Limit: &limit}); err != nil { + t.Fatal(err) + } + if _, err := exec.Find(context.Background(), cwd, FindInput{Pattern: "*", Path: &missing}); err == nil { + t.Fatal("find missing") + } + + noRG := NewExecutor() + noRG.LookPath = func(string) (string, error) { return "", os.ErrNotExist } + if _, err := noRG.Grep(context.Background(), cwd, GrepInput{Pattern: "x"}); err == nil { + t.Fatal("rg missing") + } + if _, err := noRG.Find(context.Background(), cwd, FindInput{Pattern: "*"}); err == nil { + t.Fatal("fd missing") + } + + offset := 99.0 + if _, err := exec.Read(context.Background(), cwd, ReadInput{Path: "a.txt", Offset: &offset}); err == nil { + t.Fatal("offset") + } + if _, err := exec.Read(context.Background(), cwd, ReadInput{Path: "missing.txt"}); err == nil { + t.Fatal("missing file") + } + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := exec.Read(canceled, cwd, ReadInput{Path: "a.txt"}); err == nil { + t.Fatal("cancel") + } + + big := image.NewRGBA(image.Rect(0, 0, 2100, 80)) + var buf bytes.Buffer + if err := png.Encode(&buf, big); err != nil { + t.Fatal(err) + } + if _, err := processImage(buf.Bytes(), "image/png"); err != nil { + t.Fatal(err) + } + if detectSupportedImageMIME([]byte{0xff, 0xd8, 0xff, 0xf7}) != "" { + t.Fatal("jpeg f7") + } + if detectSupportedImageMIME([]byte("GIF89a")) != "image/gif" { + t.Fatal("gif") + } + + failRG := NewExecutor() + failRG.LookPath = func(string) (string, error) { return "rg", nil } + failRG.RunCommand = func(context.Context, string, []string, string, []string, func([]byte), func([]byte)) (CommandResult, error) { + return CommandResult{}, os.ErrPermission + } + if _, err := failRG.Grep(context.Background(), cwd, GrepInput{Pattern: "x"}); err == nil { + t.Fatal("rg run") + } + codeRG := NewExecutor() + codeRG.LookPath = func(string) (string, error) { return "rg", nil } + codeRG.RunCommand = func(_ context.Context, name string, args []string, dir string, env []string, onStdout, onStderr func([]byte)) (CommandResult, error) { + onStderr([]byte("bad pattern")) + return CommandResult{ExitCode: 2}, nil + } + if _, err := codeRG.Grep(context.Background(), cwd, GrepInput{Pattern: "x"}); err == nil { + t.Fatal("rg exit") + } + silentRG := NewExecutor() + silentRG.LookPath = func(string) (string, error) { return "rg", nil } + silentRG.RunCommand = func(context.Context, string, []string, string, []string, func([]byte), func([]byte)) (CommandResult, error) { + return CommandResult{ExitCode: 3}, nil + } + if _, err := silentRG.Grep(context.Background(), cwd, GrepInput{Pattern: "x"}); err == nil { + t.Fatal("rg silent exit") + } + abort, abortCancel := context.WithCancel(context.Background()) + abortRG := NewExecutor() + abortRG.LookPath = func(string) (string, error) { return "rg", nil } + abortRG.RunCommand = func(context.Context, string, []string, string, []string, func([]byte), func([]byte)) (CommandResult, error) { + abortCancel() + return CommandResult{}, nil + } + if _, err := abortRG.Grep(abort, cwd, GrepInput{Pattern: "x"}); err == nil { + t.Fatal("rg abort") + } + longLine := string(bytes.Repeat([]byte("x"), GrepMaxLineLength+8)) + truncRG := NewExecutor() + truncRG.LookPath = func(string) (string, error) { return "rg", nil } + truncRG.RunCommand = func(_ context.Context, name string, args []string, dir string, env []string, onStdout, onStderr func([]byte)) (CommandResult, error) { + onStdout([]byte(`{"type":"match","data":{"path":{"text":"` + filepath.Join(cwd, "a.txt") + `"},"lines":{"text":"` + longLine + `"},"line_number":1}}` + "\n")) + return CommandResult{}, nil + } + if _, err := truncRG.Grep(context.Background(), cwd, GrepInput{Pattern: "x"}); err != nil { + t.Fatal(err) + } + badURL := "file://%zz" + if _, err := exec.Grep(context.Background(), cwd, GrepInput{Pattern: "x", Path: &badURL}); err == nil { + t.Fatal("bad path") + } + if _, err := exec.Find(context.Background(), cwd, FindInput{Pattern: "*", Path: &badURL}); err == nil { + t.Fatal("find bad path") + } + failFD := NewExecutor() + failFD.LookPath = func(string) (string, error) { return "fd", nil } + failFD.RunCommand = func(context.Context, string, []string, string, []string, func([]byte), func([]byte)) (CommandResult, error) { + return CommandResult{}, os.ErrPermission + } + if _, err := failFD.Find(context.Background(), cwd, FindInput{Pattern: "*"}); err == nil { + t.Fatal("fd run") + } + codeFD := NewExecutor() + codeFD.LookPath = func(string) (string, error) { return "fd", nil } + codeFD.RunCommand = func(context.Context, string, []string, string, []string, func([]byte), func([]byte)) (CommandResult, error) { + return CommandResult{ExitCode: 2}, nil + } + if _, err := codeFD.Find(context.Background(), cwd, FindInput{Pattern: "*"}); err == nil { + t.Fatal("fd exit") + } + abortFD, abortFDCancel := context.WithCancel(context.Background()) + runFD := NewExecutor() + runFD.LookPath = func(string) (string, error) { return "fd", nil } + runFD.RunCommand = func(context.Context, string, []string, string, []string, func([]byte), func([]byte)) (CommandResult, error) { + abortFDCancel() + return CommandResult{}, nil + } + if _, err := runFD.Find(abortFD, cwd, FindInput{Pattern: "*"}); err == nil { + t.Fatal("fd abort") + } + lim := 1.0 + limitFD := NewExecutor() + limitFD.LookPath = func(string) (string, error) { return "fd", nil } + limitFD.GOOS = "windows" + limitFD.RunCommand = func(_ context.Context, name string, args []string, dir string, env []string, onStdout, onStderr func([]byte)) (CommandResult, error) { + onStdout([]byte(cwd + string(filepath.Separator) + "\n")) + onStdout([]byte(filepath.Join(cwd, "a.txt") + "\n")) + return CommandResult{}, nil + } + if _, err := limitFD.Find(context.Background(), cwd, FindInput{Pattern: "dir/a", Path: &path, Limit: &lim}); err != nil { + t.Fatal(err) + } + hugeFD := NewExecutor() + hugeFD.LookPath = func(string) (string, error) { return "fd", nil } + hugeFD.RunCommand = func(_ context.Context, name string, args []string, dir string, env []string, onStdout, onStderr func([]byte)) (CommandResult, error) { + onStdout(bytes.Repeat([]byte("xxxxxxxxxxxxxxxx\n"), 4000)) + return CommandResult{}, nil + } + if _, err := hugeFD.Find(context.Background(), cwd, FindInput{Pattern: "*"}); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/agent/tools/shell.go b/server/internal/agent/tools/shell.go new file mode 100644 index 0000000..c52f100 --- /dev/null +++ b/server/internal/agent/tools/shell.go @@ -0,0 +1,140 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "time" +) + +const maxTimeoutSeconds = float64(2_147_483_647) / 1000 + +func (executor *Executor) Bash(ctx context.Context, cwd string, input ShellInput) (ToolResult, error) { + executor = executor.withDefaults() + shell, args, err := executor.resolveBash() + if err != nil { + return ToolResult{}, err + } + args = append(args, input.Command) + return executor.runShell(ctx, cwd, input, shell, args, "bash", "pi-bash") +} + +func (executor *Executor) PowerShell(ctx context.Context, cwd string, input ShellInput) (ToolResult, error) { + executor = executor.withDefaults() + if executor.GOOS != "windows" { + return ToolResult{}, fmt.Errorf("The powershell tool is only available on Windows.") + } + shell, err := executor.LookPath("pwsh.exe") + if err != nil { + shell, err = executor.LookPath("powershell.exe") + } + if err != nil { + return ToolResult{}, fmt.Errorf( + "No PowerShell executable found. Install PowerShell or add powershell.exe/pwsh.exe to PATH.", + ) + } + command := "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n" + input.Command + args := []string{"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command} + return executor.runShell(ctx, cwd, input, shell, args, "PowerShell", "pi-powershell") +} + +func (executor *Executor) runShell( + ctx context.Context, + cwd string, + input ShellInput, + executable string, + args []string, + shellName string, + tempPrefix string, +) (ToolResult, error) { + if _, err := executor.FS.Stat(cwd); err != nil { + return ToolResult{}, fmt.Errorf( + "Working directory does not exist: %s\nCannot execute %s commands.", + cwd, + shellName, + ) + } + runContext := ctx + cancel := func() {} + if input.Timeout != nil { + if math.IsNaN(*input.Timeout) || math.IsInf(*input.Timeout, 0) || *input.Timeout <= 0 { + return ToolResult{}, fmt.Errorf("Invalid timeout: must be a finite number of seconds") + } + if *input.Timeout > maxTimeoutSeconds { + return ToolResult{}, fmt.Errorf("Invalid timeout: maximum is %v seconds", maxTimeoutSeconds) + } + runContext, cancel = context.WithTimeout(ctx, time.Duration(*input.Timeout*float64(time.Second))) + } + defer cancel() + + output := newOutputAccumulator(executor, tempPrefix) + commandResult, runErr := executor.RunCommand( + runContext, + executable, + args, + cwd, + executor.Env, + output.Append, + output.Append, + ) + emptyText := "(no output)" + if runErr != nil || ctx.Err() != nil || errors.Is(runContext.Err(), context.DeadlineExceeded) { + emptyText = "" + } + outputText, details, outputErr := output.Finish(emptyText) + if outputErr != nil { + return ToolResult{}, outputErr + } + appendStatus := func(status string) error { + if outputText == "" { + return errors.New(status) + } + return fmt.Errorf("%s\n\n%s", outputText, status) + } + + switch { + case ctx.Err() != nil: + return ToolResult{}, appendStatus("Command aborted") + case errors.Is(runContext.Err(), context.DeadlineExceeded): + return ToolResult{}, appendStatus( + fmt.Sprintf("Command timed out after %v seconds", *input.Timeout), + ) + case runErr != nil: + return ToolResult{}, runErr + case commandResult.ExitCode != 0: + return ToolResult{}, appendStatus(fmt.Sprintf("Command exited with code %d", commandResult.ExitCode)) + default: + return textResult(outputText, details), nil + } +} + +func (executor *Executor) resolveBash() (string, []string, error) { + if executor.GOOS != "windows" { + if info, err := executor.FS.Stat("/bin/bash"); err == nil && !info.IsDir() { + return "/bin/bash", []string{"-c"}, nil + } + if path, err := executor.LookPath("bash"); err == nil { + return path, []string{"-c"}, nil + } + return "sh", []string{"-c"}, nil + } + + for _, candidate := range []string{ + filepath.Join(os.Getenv("ProgramFiles"), "Git", "bin", "bash.exe"), + filepath.Join(os.Getenv("ProgramFiles(x86)"), "Git", "bin", "bash.exe"), + } { + if candidate == "" { + continue + } + if info, err := executor.FS.Stat(candidate); err == nil && !info.IsDir() { + return candidate, []string{"-c"}, nil + } + } + if path, err := executor.LookPath("bash.exe"); err == nil { + return path, []string{"-c"}, nil + } + return "", nil, fmt.Errorf("No bash shell found.") +} diff --git a/server/internal/agent/tools/stream.go b/server/internal/agent/tools/stream.go new file mode 100644 index 0000000..6eb3844 --- /dev/null +++ b/server/internal/agent/tools/stream.go @@ -0,0 +1,67 @@ +package tools + +import ( + "bytes" + "sync" +) + +type lineStream struct { + mutex sync.Mutex + pending []byte + onLine func(line []byte) bool + stopped bool +} + +func (stream *lineStream) Append(data []byte) { + stream.mutex.Lock() + defer stream.mutex.Unlock() + if stream.stopped { + return + } + stream.pending = append(stream.pending, data...) + for { + newline := bytes.IndexByte(stream.pending, '\n') + if newline == -1 { + return + } + line := append([]byte(nil), stream.pending[:newline]...) + stream.pending = stream.pending[newline+1:] + if !stream.onLine(line) { + stream.stopped = true + stream.pending = nil + return + } + } +} + +func (stream *lineStream) Finish() { + stream.mutex.Lock() + defer stream.mutex.Unlock() + if stream.stopped || len(stream.pending) == 0 { + return + } + stream.onLine(append([]byte(nil), stream.pending...)) + stream.pending = nil +} + +type limitedBuffer struct { + mutex sync.Mutex + data []byte + limit int +} + +func (buffer *limitedBuffer) Append(data []byte) { + buffer.mutex.Lock() + defer buffer.mutex.Unlock() + remaining := buffer.limit - len(buffer.data) + if remaining <= 0 { + return + } + buffer.data = append(buffer.data, data[:min(len(data), remaining)]...) +} + +func (buffer *limitedBuffer) String() string { + buffer.mutex.Lock() + defer buffer.mutex.Unlock() + return stringsToValidUTF8(buffer.data) +} diff --git a/server/internal/agent/tools/tools_test.go b/server/internal/agent/tools/tools_test.go index 73f84c0..2105fc5 100644 --- a/server/internal/agent/tools/tools_test.go +++ b/server/internal/agent/tools/tools_test.go @@ -1,16 +1,18 @@ package tools import ( + "codedock/internal/agent/memory" + "codedock/pkg/agent/tool" + "codedock/pkg/db" + "codedock/pkg/db/sqlite" "context" "encoding/json" + "errors" "fmt" + "os" + "path/filepath" "strings" "testing" - - "codedock/internal/agent/memory" - "codedock/pkg/agent/tool" - "codedock/pkg/db" - "codedock/pkg/db/sqlite" ) func testQueries(t *testing.T) (*sqlite.Queries, context.Context) { @@ -156,3 +158,495 @@ func TestMemoryReadMissingIsResult(t *testing.T) { t.Fatalf("out=%+v", out) } } + +func TestToolNames(t *testing.T) { + t.Parallel() + want := []string{"read", "bash", "powershell", "edit", "write", "grep", "find", "ls"} + if strings.Join(ToolNames, ",") != strings.Join(want, ",") { + t.Fatalf("ToolNames = %v, want %v", ToolNames, want) + } +} + +func TestToolResultJSONEnvelope(t *testing.T) { + t.Parallel() + result := textResult("ok", nil) + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + if got, want := string(encoded), `{"content":[{"type":"text","text":"ok"}]}`; got != want { + t.Fatalf("encoded result = %s, want %s", got, want) + } + emptyEncoded, err := json.Marshal(textResult("", nil)) + if err != nil { + t.Fatal(err) + } + if got, want := string(emptyEncoded), `{"content":[{"type":"text","text":""}]}`; got != want { + t.Fatalf("encoded empty result = %s, want %s", got, want) + } + + by := "bytes" + result.Details = &ResultDetails{ + Truncation: &TruncationResult{ + Content: "part", + Truncated: true, + TruncatedBy: &by, + TotalLines: 2, + TotalBytes: 10, + OutputLines: 1, + OutputBytes: 4, + MaxLines: DefaultMaxLines, + MaxBytes: DefaultMaxBytes, + }, + } + encoded, err = json.Marshal(result) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{ + `"details"`, + `"truncation"`, + `"truncatedBy":"bytes"`, + `"lastLinePartial":false`, + `"firstLineExceedsLimit":false`, + } { + if !strings.Contains(string(encoded), field) { + t.Fatalf("encoded result %s does not contain %s", encoded, field) + } + } +} + +func TestExecuteRejectsMissingRequiredFields(t *testing.T) { + t.Parallel() + _, err := NewExecutor().Execute(context.Background(), ToolRead, t.TempDir(), json.RawMessage(`{}`)) + if err == nil || err.Error() != "missing required property: path" { + t.Fatalf("error = %v", err) + } + + _, err = NewExecutor().Execute(context.Background(), "unknown", t.TempDir(), json.RawMessage(`{}`)) + if err == nil || err.Error() != "unknown tool name: unknown" { + t.Fatalf("error = %v", err) + } +} + +func TestExecuteRejectsRequiredNullFields(t *testing.T) { + t.Parallel() + executor := NewExecutor() + cwd := t.TempDir() + for _, testCase := range []struct { + name string + tool string + raw string + }{ + {name: "required string null", tool: ToolWrite, raw: `{"path":"a","content":null}`}, + { + name: "edit replacement null", + tool: ToolEdit, + raw: `{"path":"a","edits":[{"oldText":"x","newText":null}]}`, + }, + } { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + if _, err := executor.Execute( + context.Background(), + testCase.tool, + cwd, + json.RawMessage(testCase.raw), + ); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestDecodeAllowsOptionalNullAndAdditionalPropertiesLikeTypeBox(t *testing.T) { + t.Parallel() + var input ReadInput + if err := decodeToolInput( + json.RawMessage(`{"path":"a","offset":null,"extra":true}`), + &input, + "path", + ); err != nil { + t.Fatal(err) + } + if input.Offset != nil { + t.Fatalf("offset = %v, want nil", input.Offset) + } +} + +func TestDecodeEditCompatibilityInputs(t *testing.T) { + t.Parallel() + cases := []struct { + name string + raw string + want int + }{ + { + name: "array", + raw: `{"path":"a","edits":[{"oldText":"x","newText":"y"}]}`, + want: 1, + }, + { + name: "single object", + raw: `{"path":"a","edits":{"oldText":"x","newText":"y"}}`, + want: 1, + }, + { + name: "stringified array", + raw: `{"path":"a","edits":"[{\"oldText\":\"x\",\"newText\":\"y\"}]"}`, + want: 1, + }, + { + name: "legacy top level", + raw: `{"path":"a","oldText":"x","newText":"y"}`, + want: 1, + }, + { + name: "legacy appended", + raw: `{"path":"a","edits":[{"oldText":"x","newText":"y"}],"oldText":"z","newText":"w"}`, + want: 2, + }, + } + for _, testCase := range cases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + input, err := decodeEditInput(json.RawMessage(testCase.raw)) + if err != nil { + t.Fatal(err) + } + if len(input.Edits) != testCase.want { + t.Fatalf("len(edits) = %d, want %d", len(input.Edits), testCase.want) + } + }) + } +} + +func TestRegisterNilAndWithQueries(t *testing.T) { + Register(nil, nil, nil, Ports{}) + q, _ := testQueries(t) + reg := tool.NewRegistry() + Register(reg, q, nil, Ports{WorkspaceRoot: t.TempDir()}) + if _, err := reg.Get(tool.Reference{Name: "memory_read"}); err != nil { + t.Fatal(err) + } + if _, err := reg.Get(tool.Reference{Name: "read"}); err != nil { + t.Fatal(err) + } + if _, err := reg.Get(tool.Reference{Name: "plan_list"}); err != nil { + t.Fatal(err) + } +} + +func TestRegisterOverBudgetType(t *testing.T) { + q, _ := testQueries(t) + reg := tool.NewRegistry() + Register(reg, q, nil, Ports{}) + if _, err := reg.Get(tool.Reference{Name: "memory_write"}); err != nil { + t.Fatal(err) + } + if _, err := reg.Get(tool.Reference{Name: "read"}); err != nil { + t.Fatal(err) + } + item, err := reg.Get(tool.Reference{Name: "read"}) + if err != nil { + t.Fatal(err) + } + if err := item.(tool.Inspector).Inspect(context.Background(), tool.Input{Call: tool.Call{ + Arguments: json.RawMessage(`{"path":"a.txt"}`), + }}); err == nil { + t.Fatal("inspect without workspace should fail") + } +} + +func TestCodingExecuteAllAndErrors(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "hello.txt"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + ports := Ports{ + WorkspaceRoot: root, + LookPath: func(file string) (string, error) { return "/bin/echo", nil }, + RunCommand: func(ctx context.Context, name string, args []string, dir string, env []string, onStdout, onStderr func([]byte)) (CommandResult, error) { + if onStdout != nil { + onStdout([]byte("ok")) + } + return CommandResult{ExitCode: 0}, nil + }, + } + reg := tool.NewRegistry() + Register(reg, nil, nil, ports) + + read, _ := reg.Get(tool.Reference{Name: ToolRead}) + got, err := read.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "r", Name: ToolRead, Arguments: json.RawMessage(`{"path":"hello.txt"}`)}}) + if err != nil || !got.Success { + t.Fatalf("read %v %+v", err, got) + } + ls, _ := reg.Get(tool.Reference{Name: ToolLS}) + got, err = ls.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "l", Name: ToolLS, Arguments: json.RawMessage(`{}`)}}) + if err != nil || !got.Success { + t.Fatalf("ls %v %+v", err, got) + } + write, _ := reg.Get(tool.Reference{Name: ToolWrite}) + got, err = write.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "w", Name: ToolWrite, Arguments: json.RawMessage(`{"path":"n.txt","content":"x"}`)}}) + if err != nil || !got.Success { + t.Fatalf("write %v %+v", err, got) + } + edit, _ := reg.Get(tool.Reference{Name: ToolEdit}) + got, err = edit.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "e", Name: ToolEdit, Arguments: json.RawMessage(`{"path":"n.txt","edits":[{"oldText":"x","newText":"y"}]}`)}}) + if err != nil || !got.Success { + t.Fatalf("edit %v %+v", err, got) + } + bash, _ := reg.Get(tool.Reference{Name: ToolBash}) + got, err = bash.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "b", Name: ToolBash, Arguments: json.RawMessage(`{"command":"echo hi"}`)}}) + if err != nil || !got.Success { + t.Fatalf("bash %v %+v", err, got) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + got, err = write.Execute(ctx, tool.Input{Call: tool.Call{ID: "c", Name: ToolWrite, Arguments: json.RawMessage(`{"path":"z.txt","content":"1"}`)}}) + if err == nil || got.Success { + t.Fatalf("cancel %v %+v", err, got) + } + + failPorts := Ports{ + WorkspaceRoot: root, + LookPath: func(string) (string, error) { return "", os.ErrNotExist }, + } + item := codingTool{name: ToolGrep, effect: tool.EffectAllow, schema: schemaOf[GrepInput](), ports: failPorts} + got, err = item.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "g", Name: ToolGrep, Arguments: json.RawMessage(`{"pattern":"x"}`)}}) + if err != nil || got.Success { + t.Fatalf("grep missing %v %+v", err, got) + } + + if _, err := codingPath(ToolRead, json.RawMessage(`{`)); err == nil { + t.Fatal("bad json") + } + if _, err := codingPath(ToolWrite, json.RawMessage(`{`)); err == nil { + t.Fatal("bad write json") + } + if _, err := codingPath(ToolEdit, json.RawMessage(`{`)); err == nil { + t.Fatal("bad edit json") + } + if _, err := codingPath(ToolGrep, json.RawMessage(`{`)); err == nil { + t.Fatal("bad grep") + } + if _, err := codingPath(ToolFind, json.RawMessage(`{`)); err == nil { + t.Fatal("bad find") + } + if _, err := codingPath(ToolLS, json.RawMessage(`{`)); err == nil { + t.Fatal("bad ls") + } + if _, err := codingPath(ToolBash, json.RawMessage(`{`)); err == nil { + t.Fatal("bad bash") + } + if path, err := codingPath("unknown", nil); err != nil || path != "" { + t.Fatal(path, err) + } + if path, err := codingPath(ToolGrep, json.RawMessage(`{"pattern":"x"}`)); err != nil || path != "." { + t.Fatal(path, err) + } + if path, err := codingPath(ToolFind, json.RawMessage(`{"pattern":"*"}`)); err != nil || path != "." { + t.Fatal(path, err) + } + if path, err := codingPath(ToolLS, json.RawMessage(`{}`)); err != nil || path != "." { + t.Fatal(path, err) + } + cancelPorts := Ports{ + WorkspaceRoot: root, + LookPath: func(string) (string, error) { return "/bin/echo", nil }, + RunCommand: func(ctx context.Context, name string, args []string, dir string, env []string, onStdout, onStderr func([]byte)) (CommandResult, error) { + return CommandResult{}, context.Canceled + }, + } + bashTool := codingTool{name: ToolBash, effect: tool.EffectAsk, schema: schemaOf[ShellInput](), ports: cancelPorts} + got, err = bashTool.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "d", Name: ToolBash, Arguments: json.RawMessage(`{"command":"echo"}`)}}) + if err == nil || got.Success { + t.Fatalf("canceled exec %v %+v", err, got) + } + if len(nonzeroJSON(nil)) == 0 { + t.Fatal("nonzero") + } + if err := inspectCodingPath(ports, ToolBash, json.RawMessage(`{"command":"echo"}`)); err != nil { + t.Fatal(err) + } +} + +func TestJailAndPlanErrors(t *testing.T) { + if _, err := jailPath("", "x", nil); err == nil { + t.Fatal("empty root") + } + if !insideRoot("/a", "/a") { + t.Fatal("same") + } + + root := t.TempDir() + if _, err := jailPath(root, filepath.Join(root, "..", "nope"), osFileSystem{}); err == nil || !errors.Is(err, tool.ErrOutsideWorkspace) { + t.Fatalf("outside %v", err) + } + if _, err := jailPath(root, filepath.Join(root, "inside.txt"), osFileSystem{}); err != nil { + t.Fatal(err) + } + + ports := Ports{WorkspaceRoot: root} + unknown := planTool{name: "plan_other", ports: ports} + got, err := unknown.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "u", Name: "plan_other"}}) + if err != nil || got.Success { + t.Fatalf("unknown %v %+v", err, got) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + got, err = planTool{name: "plan_list", ports: ports}.Execute(ctx, tool.Input{Call: tool.Call{ID: "c"}}) + if err == nil || got.Success { + t.Fatalf("cancel %v %+v", err, got) + } + empty := planTool{name: "plan_list", ports: Ports{}} + got, err = empty.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "e"}}) + if err != nil || got.Success { + t.Fatalf("empty root %v %+v", err, got) + } + if err := (planTool{name: "plan_list"}).Inspect(context.Background(), tool.Input{}); err != nil { + t.Fatal(err) + } + if _, err := planNameFromArgs("plan_read", json.RawMessage(`{`)); err == nil { + t.Fatal("bad name json") + } + if _, err := planNameFromArgs("plan_write", json.RawMessage(`{`)); err == nil { + t.Fatal("bad write json") + } + if _, err := sanitizePlanName(""); err == nil { + t.Fatal("empty name") + } + if _, err := sanitizePlanName(`a\b.md`); err == nil { + t.Fatal("backslash") + } + read := planTool{name: "plan_read", ports: ports} + got, err = read.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "r", Arguments: json.RawMessage(`{"name":"missing.md"}`)}}) + if err != nil || got.Success { + t.Fatalf("missing %v %+v", err, got) + } + write := planTool{name: "plan_write", ports: ports} + got, err = write.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "w", Arguments: json.RawMessage(`{`)}}) + if err != nil || got.Success { + t.Fatalf("bad write exec %v %+v", err, got) + } + got, err = write.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "w2", Arguments: json.RawMessage(`{"name":"../x","content":"a"}`)}}) + if err != nil || got.Success { + t.Fatalf("bad name exec %v %+v", err, got) + } + dir, err := os.MkdirTemp(root, "") + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, ".cursor", "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".cursor", "keep.md"), []byte("k"), 0o644); err != nil { + t.Fatal(err) + } + list := planTool{name: "plan_list", ports: Ports{WorkspaceRoot: dir}} + got, err = list.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "l"}}) + if err != nil || !got.Success { + t.Fatalf("list %v %+v", err, got) + } + if _, err := planDir(""); err == nil { + t.Fatal("planDir empty") + } + if _, err := okToolResult("id", "n", make(chan int)); err != nil { + t.Fatal(err) + } + if _, err := planNameFromArgs("plan_write", json.RawMessage(`{"name":"a.md","content":1}`)); err == nil { + t.Fatal("write type") + } + badRead := planTool{name: "plan_read", ports: ports} + got, err = badRead.Execute(context.Background(), tool.Input{Call: tool.Call{ID: "br", Arguments: json.RawMessage(`{"name":"../x"}`)}}) + if err != nil || got.Success { + t.Fatalf("bad read name %v %+v", err, got) + } +} + +func TestMemoryAndPingErrors(t *testing.T) { + q, ctx := testQueries(t) + if _, err := q.InsertSession(ctx, sqlite.InsertSessionParams{ + ID: "sess-err", TenantID: "t", UserID: "u", AgentID: "default", WorkspaceID: "", Status: "active", + CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + canceled, cancel := context.WithCancel(ctx) + cancel() + got, err := Ping().Execute(canceled, tool.Input{Call: tool.Call{ID: "p"}}) + if err == nil || got.Success { + t.Fatalf("ping cancel %v %+v", err, got) + } + got, err = Ping().Execute(ctx, tool.Input{Call: tool.Call{ID: "p2", Arguments: json.RawMessage(`{`)}}) + if err != nil || got.Success { + t.Fatalf("ping json %v %+v", err, got) + } + + read := ReadTool(q) + got, err = read.Execute(canceled, tool.Input{Call: tool.Call{ID: "r"}}) + if err == nil || got.Success { + t.Fatalf("read cancel %v %+v", err, got) + } + got, err = read.Execute(ctx, tool.Input{Call: tool.Call{ID: "r2"}}) + if err != nil || got.Success { + t.Fatalf("read args %v %+v", err, got) + } + got, err = read.Execute(ctx, tool.Input{Call: tool.Call{ID: "r3", Arguments: json.RawMessage(`{"scope":"nope","name":"index"}`)}, SessionID: "sess-err"}) + if err != nil || got.Success { + t.Fatalf("bad scope %v %+v", err, got) + } + write := WriteTool(q, nil) + got, err = write.Execute(canceled, tool.Input{Call: tool.Call{ID: "w"}}) + if err == nil || got.Success { + t.Fatalf("write cancel %v %+v", err, got) + } + got, err = write.Execute(ctx, tool.Input{Call: tool.Call{ID: "w2", Arguments: json.RawMessage(`{`)}}) + if err != nil || got.Success { + t.Fatalf("write json %v %+v", err, got) + } + search := SearchTool(q) + got, err = search.Execute(canceled, tool.Input{Call: tool.Call{ID: "s"}}) + if err == nil || got.Success { + t.Fatalf("search cancel %v %+v", err, got) + } + got, err = search.Execute(ctx, tool.Input{Call: tool.Call{ID: "s2"}}) + if err != nil || got.Success { + t.Fatalf("search args %v %+v", err, got) + } + got, err = search.Execute(ctx, tool.Input{SessionID: "missing", Call: tool.Call{ID: "s3", Arguments: json.RawMessage(`{"query":"x"}`)}}) + if err != nil || got.Success { + t.Fatalf("search session %v %+v", err, got) + } + got, err = search.Execute(ctx, tool.Input{SessionID: "sess-err", Call: tool.Call{ID: "s4", Arguments: json.RawMessage(`{"query":"x"}`)}}) + if err != nil || !got.Success { + t.Fatalf("search empty ws %v %+v", err, got) + } + if _, err := scopeIDFromSession(ctx, nil, "s", "user"); err == nil { + t.Fatal("nil q") + } + if _, err := scopeIDFromSession(ctx, q, "", "user"); err == nil { + t.Fatal("empty session") + } + if wrapSessionErr(nil) != nil { + t.Fatal("nil wrap") + } + got, err = write.Execute(ctx, tool.Input{SessionID: "sess-err", Call: tool.Call{ID: "w3", Arguments: json.RawMessage(`{"scope":"workspace","name":"index","content":"x"}`)}}) + if err != nil || !got.Success { + t.Fatalf("write empty workspace %v %+v", err, got) + } + got, err = write.Execute(ctx, tool.Input{SessionID: "missing", Call: tool.Call{ID: "w4", Arguments: json.RawMessage(`{"scope":"user","name":"index","content":"x"}`)}}) + if err != nil || got.Success { + t.Fatalf("write missing session %v %+v", err, got) + } + got, err = write.Execute(ctx, tool.Input{SessionID: "sess-err", Call: tool.Call{ID: "w5", Arguments: json.RawMessage(`{"scope":"nope","name":"index","content":"x"}`)}}) + if err != nil || got.Success { + t.Fatalf("write bad scope %v %+v", err, got) + } + if _, err := okResult(tool.Input{Call: tool.Call{ID: "x"}}, "n", make(chan int)); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/agent/tools/truncate.go b/server/internal/agent/tools/truncate.go new file mode 100644 index 0000000..5c88dae --- /dev/null +++ b/server/internal/agent/tools/truncate.go @@ -0,0 +1,204 @@ +package tools + +import ( + "fmt" + "strings" + "unicode/utf16" +) + +const ( + DefaultMaxLines = 2000 + DefaultMaxBytes = 50 * 1024 + GrepMaxLineLength = 500 +) + +type TruncationResult struct { + Content string `json:"content"` + Truncated bool `json:"truncated"` + TruncatedBy *string `json:"truncatedBy"` + TotalLines int `json:"totalLines"` + TotalBytes int `json:"totalBytes"` + OutputLines int `json:"outputLines"` + OutputBytes int `json:"outputBytes"` + LastLinePartial bool `json:"lastLinePartial"` + FirstLineExceedsLimit bool `json:"firstLineExceedsLimit"` + MaxLines int `json:"maxLines"` + MaxBytes int `json:"maxBytes"` +} + +func splitLinesForCounting(content string) []string { + if content == "" { + return nil + } + lines := strings.Split(content, "\n") + if strings.HasSuffix(content, "\n") { + lines = lines[:len(lines)-1] + } + return lines +} + +func FormatSize(bytes int) string { + switch { + case bytes < 1024: + return fmt.Sprintf("%dB", bytes) + case bytes < 1024*1024: + return fmt.Sprintf("%.1fKB", float64(bytes)/1024) + default: + return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024)) + } +} + +func TruncateHead(content string, maxLines int, maxBytes int) TruncationResult { + maxLines, maxBytes = truncationLimits(maxLines, maxBytes) + lines := splitLinesForCounting(content) + totalBytes := len([]byte(content)) + if len(lines) <= maxLines && totalBytes <= maxBytes { + return noTruncation(content, len(lines), totalBytes, maxLines, maxBytes) + } + + if len(lines) > 0 && len([]byte(lines[0])) > maxBytes { + by := "bytes" + return TruncationResult{ + Content: "", + Truncated: true, + TruncatedBy: &by, + TotalLines: len(lines), + TotalBytes: totalBytes, + FirstLineExceedsLimit: true, + MaxLines: maxLines, + MaxBytes: maxBytes, + } + } + + output := make([]string, 0, min(len(lines), maxLines)) + outputBytes := 0 + truncatedBy := "lines" + for index, line := range lines { + if index >= maxLines { + break + } + lineBytes := len([]byte(line)) + if index > 0 { + lineBytes++ + } + if outputBytes+lineBytes > maxBytes { + truncatedBy = "bytes" + break + } + output = append(output, line) + outputBytes += lineBytes + } + if len(output) >= maxLines && outputBytes <= maxBytes { + truncatedBy = "lines" + } + + content = strings.Join(output, "\n") + return TruncationResult{ + Content: content, + Truncated: true, + TruncatedBy: &truncatedBy, + TotalLines: len(lines), + TotalBytes: totalBytes, + OutputLines: len(output), + OutputBytes: len([]byte(content)), + MaxLines: maxLines, + MaxBytes: maxBytes, + } +} + +func TruncateTail(content string, maxLines int, maxBytes int) TruncationResult { + maxLines, maxBytes = truncationLimits(maxLines, maxBytes) + lines := splitLinesForCounting(content) + totalBytes := len([]byte(content)) + if len(lines) <= maxLines && totalBytes <= maxBytes { + return noTruncation(content, len(lines), totalBytes, maxLines, maxBytes) + } + + output := make([]string, 0, min(len(lines), maxLines)) + outputBytes := 0 + truncatedBy := "lines" + lastLinePartial := false + for index := len(lines) - 1; index >= 0 && len(output) < maxLines; index-- { + line := lines[index] + lineBytes := len([]byte(line)) + if len(output) > 0 { + lineBytes++ + } + if outputBytes+lineBytes > maxBytes { + truncatedBy = "bytes" + if len(output) == 0 { + line = truncateStringToBytesFromEnd(line, maxBytes) + output = append(output, line) + outputBytes = len([]byte(line)) + lastLinePartial = true + } + break + } + output = append(output, "") + copy(output[1:], output[:len(output)-1]) + output[0] = line + outputBytes += lineBytes + } + if len(output) >= maxLines && outputBytes <= maxBytes { + truncatedBy = "lines" + } + + content = strings.Join(output, "\n") + return TruncationResult{ + Content: content, + Truncated: true, + TruncatedBy: &truncatedBy, + TotalLines: len(lines), + TotalBytes: totalBytes, + OutputLines: len(output), + OutputBytes: len([]byte(content)), + LastLinePartial: lastLinePartial, + MaxLines: maxLines, + MaxBytes: maxBytes, + } +} + +func TruncateLine(line string, maxUTF16Units int) (string, bool) { + if maxUTF16Units <= 0 { + maxUTF16Units = GrepMaxLineLength + } + units := utf16.Encode([]rune(line)) + if len(units) <= maxUTF16Units { + return line, false + } + return string(utf16.Decode(units[:maxUTF16Units])) + "... [truncated]", true +} + +func truncationLimits(maxLines int, maxBytes int) (int, int) { + if maxLines <= 0 { + maxLines = DefaultMaxLines + } + if maxBytes <= 0 { + maxBytes = DefaultMaxBytes + } + return maxLines, maxBytes +} + +func noTruncation(content string, lines int, bytes int, maxLines int, maxBytes int) TruncationResult { + return TruncationResult{ + Content: content, + TotalLines: lines, + TotalBytes: bytes, + OutputLines: lines, + OutputBytes: bytes, + MaxLines: maxLines, + MaxBytes: maxBytes, + } +} + +func truncateStringToBytesFromEnd(value string, maxBytes int) string { + data := []byte(value) + if len(data) <= maxBytes { + return value + } + start := len(data) - maxBytes + for start < len(data) && data[start]&0xc0 == 0x80 { + start++ + } + return string(data[start:]) +} diff --git a/server/internal/agent/tools/types.go b/server/internal/agent/tools/types.go new file mode 100644 index 0000000..2c73b8f --- /dev/null +++ b/server/internal/agent/tools/types.go @@ -0,0 +1,172 @@ +package tools + +import ( + "context" + "encoding/json" + "io/fs" +) + +const ( + ToolRead = "read" + ToolBash = "bash" + ToolPowerShell = "powershell" + ToolEdit = "edit" + ToolWrite = "write" + ToolGrep = "grep" + ToolFind = "find" + ToolLS = "ls" +) + +var ToolNames = []string{ + ToolRead, + ToolBash, + ToolPowerShell, + ToolEdit, + ToolWrite, + ToolGrep, + ToolFind, + ToolLS, +} + +type ContentBlock struct { + Type string `json:"type"` // text 或 image + Text string `json:"text,omitempty"` // type=text 时的正文 + Data string `json:"data,omitempty"` // type=image 时的 base64 + MIMEType string `json:"mimeType,omitempty"` // type=image 时的 MIME +} + +func (block ContentBlock) MarshalJSON() ([]byte, error) { + switch block.Type { + case "text": + return json.Marshal(struct { + Type string `json:"type"` + Text string `json:"text"` + }{Type: block.Type, Text: block.Text}) + case "image": + return json.Marshal(struct { + Type string `json:"type"` + Data string `json:"data"` + MIMEType string `json:"mimeType"` + }{Type: block.Type, Data: block.Data, MIMEType: block.MIMEType}) + default: + type rawContentBlock ContentBlock + return json.Marshal(rawContentBlock(block)) + } +} + +type ToolResult struct { + Content []ContentBlock `json:"content"` + Details *ResultDetails `json:"details,omitempty"` +} + +type ResultDetails struct { + Truncation *TruncationResult `json:"truncation,omitempty"` + FullOutputPath string `json:"fullOutputPath,omitempty"` // 超长输出落到的旁路文件 + Diff string `json:"diff,omitempty"` // 给人看的行级 diff + Patch string `json:"patch,omitempty"` // unified patch + FirstChangedLine *int `json:"firstChangedLine,omitempty"` // 第一处改动的新文件行号 + MatchLimitReached *float64 `json:"matchLimitReached,omitempty"` + LinesTruncated *bool `json:"linesTruncated,omitempty"` + ResultLimitReached *float64 `json:"resultLimitReached,omitempty"` + EntryLimitReached *float64 `json:"entryLimitReached,omitempty"` +} + +type ReadInput struct { + Path string `json:"path"` // 相对工作区或绝对路径 + Offset *float64 `json:"offset,omitempty"` // 起始行,从 1 计 + Limit *float64 `json:"limit,omitempty"` // 最多返回多少行 +} + +type ShellInput struct { + Command string `json:"command"` + Timeout *float64 `json:"timeout,omitempty"` // 秒;空则用执行器默认 +} + +type EditReplacement struct { + OldText string `json:"oldText"` + NewText string `json:"newText"` +} + +type EditInput struct { + Path string `json:"path"` + Edits []EditReplacement `json:"edits"` +} + +type WriteInput struct { + Path string `json:"path"` + Content string `json:"content"` +} + +type GrepInput struct { + Pattern string `json:"pattern"` + Path *string `json:"path,omitempty"` // 搜索起点;空则工作区根 + Glob *string `json:"glob,omitempty"` + IgnoreCase *bool `json:"ignoreCase,omitempty"` + Literal *bool `json:"literal,omitempty"` // 按字面量而不是正则 + Context *float64 `json:"context,omitempty"` // 匹配行上下各取几行 + Limit *float64 `json:"limit,omitempty"` +} + +type FindInput struct { + Pattern string `json:"pattern"` + Path *string `json:"path,omitempty"` // 搜索起点;空则工作区根 + Limit *float64 `json:"limit,omitempty"` +} + +type LSInput struct { + Path *string `json:"path,omitempty"` // 要列出的目录;空则工作区根 + Limit *float64 `json:"limit,omitempty"` +} + +type CommandResult struct { + ExitCode int +} + +type CommandFunc func( + ctx context.Context, + name string, + args []string, + dir string, + env []string, + onStdout func(data []byte), + onStderr func(data []byte), +) (CommandResult, error) + +type FileSystem interface { + Access(name string) error + ReadFile(name string) ([]byte, error) + WriteFile(name string, data []byte, perm fs.FileMode) error + MkdirAll(path string, perm fs.FileMode) error + Stat(name string) (fs.FileInfo, error) + ReadDir(name string) ([]fs.DirEntry, error) + EvalSymlinks(path string) (string, error) +} + +type Executor struct { + FS FileSystem + RunCommand CommandFunc + LookPath func(file string) (string, error) + TempDir string + GOOS string + HomeDir string + Env []string +} + +func textResult(text string, details *ResultDetails) ToolResult { + return ToolResult{ + Content: []ContentBlock{{Type: "text", Text: text}}, + Details: details, + } +} + +func boolPointer(value bool) *bool { + return &value +} + +func intPointer(value int) *int { + return &value +} + +func floatPointer(value float64) *float64 { + return &value +} diff --git a/server/internal/agent/tools/write.go b/server/internal/agent/tools/write.go new file mode 100644 index 0000000..dd28a12 --- /dev/null +++ b/server/internal/agent/tools/write.go @@ -0,0 +1,38 @@ +package tools + +import ( + "context" + "fmt" + "path/filepath" + "unicode/utf16" +) + +func (executor *Executor) Write(ctx context.Context, cwd string, input WriteInput) (ToolResult, error) { + executor = executor.withDefaults() + absolutePath, err := executor.resolveToCWD(input.Path, cwd) + if err != nil { + return ToolResult{}, err + } + return executor.withFileMutation(absolutePath, func() (ToolResult, error) { + if err := contextOperationError(ctx); err != nil { + return ToolResult{}, err + } + if err := executor.FS.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil { + return ToolResult{}, err + } + if err := contextOperationError(ctx); err != nil { + return ToolResult{}, err + } + if err := executor.FS.WriteFile(absolutePath, []byte(input.Content), 0o644); err != nil { + return ToolResult{}, err + } + if err := contextOperationError(ctx); err != nil { + return ToolResult{}, err + } + length := len(utf16.Encode([]rune(input.Content))) + return textResult( + fmt.Sprintf("Successfully wrote %d bytes to %s", length, input.Path), + nil, + ), nil + }) +} diff --git a/server/pkg/agent/context.go b/server/pkg/agent/context.go index b024c43..ebceccd 100644 --- a/server/pkg/agent/context.go +++ b/server/pkg/agent/context.go @@ -15,6 +15,7 @@ type History struct { Messages []Message Tools []tool.Definition Prompt string + WorkspaceRoot string MemoryIndexes []string } @@ -25,6 +26,7 @@ func Load(_ context.Context, hist History) (ContextSnapshot, error) { Messages: hist.Messages, Tools: hist.Tools, SystemPrompt: hist.Prompt, + WorkspaceRoot: hist.WorkspaceRoot, MemoryIndexes: hist.MemoryIndexes, } if hist.Checkpoint != nil { diff --git a/server/pkg/agent/defaults.go b/server/pkg/agent/defaults.go index f49e23d..6aaea8d 100644 --- a/server/pkg/agent/defaults.go +++ b/server/pkg/agent/defaults.go @@ -18,7 +18,7 @@ const ( - 不要堆砌感叹号,不要加油打气,不要用网络流行语。 需要做事时再调用当前提供的工具,不要为了调用而调用。一次回复里的工具会按批次审批,只提出当前必要的调用。` - DefaultToolSet = "ping-memory-1" + DefaultToolSet = "ping-memory-coding-1" ) // FakeOptions 控制 fake 模型的确定性输出,供测试与离线闭环使用。 @@ -103,7 +103,23 @@ func DefaultRunConfig(mode AgentMode, model ModelConfig) RunConfigSnapshot { Reference: "default", }, Tools: profile.ToolConfig{ - Names: []string{"ping", "memory_read", "memory_write", "memory_search"}, + Names: []string{ + "ping", + "memory_read", + "memory_write", + "memory_search", + "read", + "write", + "edit", + "ls", + "grep", + "find", + "bash", + "powershell", + "plan_list", + "plan_read", + "plan_write", + }, Version: DefaultToolSet, PermissionPolicy: tool.PermissionPolicy{Version: "1"}, ApprovalPolicy: tool.ApprovalPolicy{Version: "1", DefaultExpiry: time.Hour}, diff --git a/server/pkg/agent/engine.go b/server/pkg/agent/engine.go index 0cfefbf..e7d9d97 100644 --- a/server/pkg/agent/engine.go +++ b/server/pkg/agent/engine.go @@ -102,6 +102,9 @@ func (e *Engine) callLLM(ctx context.Context, in StepInput, _ Instruction) (Step if err != nil { return StepResult{}, err } + if snapshot.WorkspaceRoot == "" { + snapshot.WorkspaceRoot = state.WorkspaceRoot + } if e.llmGate != nil { if err := e.llmGate.Acquire(ctx); err != nil { return e.finish(ctx, StepInput{State: state, Job: in.Job}, finishInstructions(RunCancelled, StopCancelled)[0]) @@ -245,6 +248,7 @@ func (e *Engine) callToolsBatch(ctx context.Context, in StepInput, inst Instruct SessionID: state.SessionID, RunID: state.RunID, TurnID: turnID, + WorkspaceRoot: state.WorkspaceRoot, Calls: calls, Mode: execMode, FailurePolicy: failPolicy, diff --git a/server/pkg/agent/plane.go b/server/pkg/agent/plane.go index 322e890..1402418 100644 --- a/server/pkg/agent/plane.go +++ b/server/pkg/agent/plane.go @@ -39,6 +39,7 @@ type AgentState struct { SessionID string // 所属会话 RunID string // 本次 Run TurnID *string // 当前 Turn(如有) + WorkspaceRoot string // 会话创建时冻结的工作目录;权限只覆盖该目录 Status RunStatus // 当前粗状态 StepIndex int // 已提交的步骤序号;下一步必须递增 Config RunConfigSnapshot // 启动配置快照,只读 diff --git a/server/pkg/agent/prompt.go b/server/pkg/agent/prompt.go index 8d557ec..58e8da7 100644 --- a/server/pkg/agent/prompt.go +++ b/server/pkg/agent/prompt.go @@ -42,6 +42,18 @@ func ComposeSystemPrompt(base string, tools []tool.Definition) string { return base + "\n\n工具:\n" + extra } +func withWorkspacePrompt(system, root string) string { + root = strings.TrimSpace(root) + if root == "" { + return system + } + line := "Current working directory: " + root + if system == "" { + return line + } + return system + "\n\n" + line +} + // Build 将上下文组装为模型调用。工具描述由各工具自己维护,这里只做统一拼接。 func Build(_ context.Context, req Prompt) (Chat, error) { system := req.Context.SystemPrompt @@ -52,6 +64,7 @@ func Build(_ context.Context, req Prompt) (Chat, error) { system = DefaultSystemPrompt } system = ComposeSystemPrompt(system, req.Context.Tools) + system = withWorkspacePrompt(system, req.Context.WorkspaceRoot) var prefix []Message for _, index := range req.Context.MemoryIndexes { if index == "" { diff --git a/server/pkg/agent/tool/dispatch.go b/server/pkg/agent/tool/dispatch.go index 9f8dfb9..59745d3 100644 --- a/server/pkg/agent/tool/dispatch.go +++ b/server/pkg/agent/tool/dispatch.go @@ -97,6 +97,23 @@ func prepareCall(inv Invocation, call Call, approved map[string]struct{}) (prepa if err := checkPermission(inv.PermissionPolicy, inv.AgentMode, def); err != nil { return preparedCall{call: call, result: failResult(call, err.Error()), skip: true}, false } + input := Input{ + SessionID: inv.SessionID, + RunID: inv.RunID, + TurnID: inv.TurnID, + WorkspaceRoot: inv.WorkspaceRoot, + Call: call, + } + if inspector, ok := item.(Inspector); ok { + if err := inspector.Inspect(context.Background(), input); err != nil { + return preparedCall{call: call, result: failResult(call, err.Error()), skip: true}, false + } + } + if resolver, ok := item.(EffectResolver); ok { + if effect := resolver.ResolveEffect(context.Background(), input); effect != "" { + def.Permission.Effect = effect + } + } if requiresApproval(inv.AgentMode, inv.ApprovalPolicy, def, call.ID, approved) { return preparedCall{}, true } @@ -178,10 +195,11 @@ func executeOne(ctx context.Context, inv Invocation, item preparedCall) (Result, } emit(inv, "execution_started", item.call, attempt, nil) result, err := item.tool.Execute(ctx, Input{ - SessionID: inv.SessionID, - RunID: inv.RunID, - TurnID: inv.TurnID, - Call: item.call, + SessionID: inv.SessionID, + RunID: inv.RunID, + TurnID: inv.TurnID, + WorkspaceRoot: inv.WorkspaceRoot, + Call: item.call, }) if inv.Gate != nil { inv.Gate.Release() @@ -328,7 +346,14 @@ func requiresApproval(mode string, policy ApprovalPolicy, def Definition, callID return false } } - return def.Permission.RequiresApproval + switch def.Permission.Effect { + case EffectAsk: + return true + case EffectAllow: + return false + default: + return def.Permission.RequiresApproval + } } // failResult 构造一条失败的工具结果。 diff --git a/server/pkg/agent/tool/tool.go b/server/pkg/agent/tool/tool.go index 584b00a..dfdd2ad 100644 --- a/server/pkg/agent/tool/tool.go +++ b/server/pkg/agent/tool/tool.go @@ -3,9 +3,22 @@ package tool import ( "context" "encoding/json" + "errors" "time" ) +// ErrOutsideWorkspace 表示路径落在会话工作区外。 +var ErrOutsideWorkspace = errors.New("outside workspace") + +// Effect 是工具默认权限;本阶段与 RequiresApproval 并存,三模式流水线再接管。 +type Effect string + +const ( + EffectAllow Effect = "allow" + EffectAsk Effect = "ask" + EffectDeny Effect = "deny" +) + // ExecutionMode 定义一组工具调用采用串行还是并行执行。 type ExecutionMode string @@ -34,9 +47,11 @@ const ( ) // Permission 描述工具所需的能力;审批与否由工具自己声明。 +// Effect 供编码工具使用;空则只看 RequiresApproval。 type Permission struct { Capabilities []Capability `json:"capabilities,omitempty"` RequiresApproval bool `json:"requires_approval"` + Effect Effect `json:"effect,omitempty"` Resource string `json:"resource,omitempty"` } @@ -92,10 +107,11 @@ type Call struct { // Input 是传递给各种工具兼容层的统一执行输入。 type Input struct { - SessionID string - RunID string - TurnID string - Call Call + SessionID string + RunID string + TurnID string + WorkspaceRoot string // 会话创建时冻结的工作目录;空则回落 Ports + Call Call } // Result 是各种工具兼容层返回的统一结构化输出。 @@ -114,6 +130,16 @@ type Tool interface { Execute(ctx context.Context, input Input) (Result, error) } +// Inspector 是工具层额外的参数校验;失败则本调用不执行。 +type Inspector interface { + Inspect(ctx context.Context, input Input) error +} + +// EffectResolver 按本次入参覆盖工具默认 Effect。三模式流水线再使用。 +type EffectResolver interface { + ResolveEffect(ctx context.Context, input Input) Effect +} + // Registry 定义工具注册、获取与提示词汇总的能力。 type Registry interface { Register(tool Tool) error @@ -136,6 +162,7 @@ type Invocation struct { SessionID string RunID string TurnID string + WorkspaceRoot string Calls []Call Mode ExecutionMode FailurePolicy FailurePolicy diff --git a/server/pkg/agent/types.go b/server/pkg/agent/types.go index f6e526c..4ed5eb2 100644 --- a/server/pkg/agent/types.go +++ b/server/pkg/agent/types.go @@ -72,19 +72,19 @@ const ( type MessageRole string const ( - RoleUser MessageRole = "user" // 用户输入 - RoleAssistant MessageRole = "assistant" // 助手回复(文本或工具调用) - RoleTool MessageRole = "tool" // 工具执行结果 - RoleSystem MessageRole = "system" // 系统提示、记忆目录、压缩摘要等 + RoleUser MessageRole = "user" // 用户输入 + RoleAssistant MessageRole = "assistant" // 助手回复(文本或工具调用) + RoleTool MessageRole = "tool" // 工具执行结果 + RoleSystem MessageRole = "system" // 系统提示、记忆目录、压缩摘要等 ) // ApprovalScope 控制审批决定的生效范围。 type ApprovalScope string const ( - ApprovalOnce ApprovalScope = "once" // 仅本次工具批次有效 - ApprovalForRun ApprovalScope = "run" // 同一 Run 内同类工具持续有效 - ApprovalSession ApprovalScope = "session" // 整个会话内同类工具持续有效 + ApprovalOnce ApprovalScope = "once" // 仅本次工具批次有效 + ApprovalForRun ApprovalScope = "run" // 同一 Run 内同类工具持续有效 + ApprovalSession ApprovalScope = "session" // 整个会话内同类工具持续有效 ) // ApprovalStatus 表示审批请求的生命周期状态。 @@ -101,72 +101,72 @@ const ( type EventType string const ( - EventRunCreated EventType = "run.created" // Run 已创建(queued) - EventRunStateChanged EventType = "run.state_changed" // Run 粗状态迁移 - EventTurnStarted EventType = "turn.started" // 一次模型 Turn 开始 - EventAssistantStarted EventType = "assistant.started" // 开始流式助手回复 - EventAssistantDelta EventType = "assistant.delta" // 助手流式增量(文本或工具调用) - EventAssistantCompleted EventType = "assistant.completed" // 助手本轮输出结束 - EventToolCallStarted EventType = "tool.call_started" // 模型发出来的 tool_call 进入处理 - EventApprovalRequired EventType = "tool.approval_required" // 工具批次需要人工审批 - EventApprovalDecided EventType = "tool.approval_decided" // 审批裁决已提交 - EventToolExecutionStarted EventType = "tool.execution_started" // 单个工具开始执行 - EventToolExecutionRetry EventType = "tool.execution_retry" // 单个工具重试 - EventToolExecutionResult EventType = "tool.execution_result" // 单个工具结果 - EventUsageRecorded EventType = "turn.usage_recorded" // 本 Turn 用量入账 - EventContextCompacted EventType = "context.compacted" // 上下文被压缩 - EventTurnCompleted EventType = "turn.completed" // 本 Turn 结束 - EventRunCompleted EventType = "run.completed" // Run 正常结束 - EventRunFailed EventType = "run.failed" // Run 失败 - EventRunCancelled EventType = "run.cancelled" // Run 取消 + EventRunCreated EventType = "run.created" // Run 已创建(queued) + EventRunStateChanged EventType = "run.state_changed" // Run 粗状态迁移 + EventTurnStarted EventType = "turn.started" // 一次模型 Turn 开始 + EventAssistantStarted EventType = "assistant.started" // 开始流式助手回复 + EventAssistantDelta EventType = "assistant.delta" // 助手流式增量(文本或工具调用) + EventAssistantCompleted EventType = "assistant.completed" // 助手本轮输出结束 + EventToolCallStarted EventType = "tool.call_started" // 模型发出来的 tool_call 进入处理 + EventApprovalRequired EventType = "tool.approval_required" // 工具批次需要人工审批 + EventApprovalDecided EventType = "tool.approval_decided" // 审批裁决已提交 + EventToolExecutionStarted EventType = "tool.execution_started" // 单个工具开始执行 + EventToolExecutionRetry EventType = "tool.execution_retry" // 单个工具重试 + EventToolExecutionResult EventType = "tool.execution_result" // 单个工具结果 + EventUsageRecorded EventType = "turn.usage_recorded" // 本 Turn 用量入账 + EventContextCompacted EventType = "context.compacted" // 上下文被压缩 + EventTurnCompleted EventType = "turn.completed" // 本 Turn 结束 + EventRunCompleted EventType = "run.completed" // Run 正常结束 + EventRunFailed EventType = "run.failed" // Run 失败 + EventRunCancelled EventType = "run.cancelled" // Run 取消 ) // ModelConfig 冻结 Run 使用的供应商无关模型配置。 type ModelConfig struct { - Provider string `json:"provider"` // 供应商:fake / openai - Model string `json:"model"` // 模型名 - Options json.RawMessage `json:"options,omitempty"` // 供应商特有参数,核心运行时不解析 + Provider string `json:"provider"` // 供应商:fake / openai + Model string `json:"model"` // 模型名 + Options json.RawMessage `json:"options,omitempty"` // 供应商特有参数,核心运行时不解析 } // RetryConfig 配置一类可独立重试的操作。 type RetryConfig struct { - MaxAttempts int `json:"max_attempts"` // 最大尝试次数 - InitialBackoff time.Duration `json:"initial_backoff"` // 首次退避时长 - MaxBackoff time.Duration `json:"max_backoff"` // 最大退避时长 - Multiplier float64 `json:"multiplier"` // 退避乘数 - Jitter float64 `json:"jitter"` // 抖动比例 + MaxAttempts int `json:"max_attempts"` // 最大尝试次数 + InitialBackoff time.Duration `json:"initial_backoff"` // 首次退避时长 + MaxBackoff time.Duration `json:"max_backoff"` // 最大退避时长 + Multiplier float64 `json:"multiplier"` // 退避乘数 + Jitter float64 `json:"jitter"` // 抖动比例 } // RetryPolicy 分别冻结上下文、模型和工具的重试设置。 type RetryPolicy struct { - Context RetryConfig `json:"context"` // 上下文加载/压缩重试 - Model RetryConfig `json:"model"` // 模型调用重试 - Tool RetryConfig `json:"tool"` // 工具执行重试 + Context RetryConfig `json:"context"` // 上下文加载/压缩重试 + Model RetryConfig `json:"model"` // 模型调用重试 + Tool RetryConfig `json:"tool"` // 工具执行重试 } // RunLimits 是一次 Run 的不可变执行预算。 type RunLimits struct { - MaxWallTime time.Duration `json:"max_wall_time"` // 最大执行时间 - MaxTurns int `json:"max_turns"` // 最大模型调用轮数 - MaxToolCalls int `json:"max_tool_calls"` // 最大工具调用次数 - MaxInputTokens int64 `json:"max_input_tokens"` // 最大输入 token 数(含上下文) - MaxOutputTokens int64 `json:"max_output_tokens"` // 最大输出 token 数 - MaxParallelTools int `json:"max_parallel_tools"` // 工具并行上限 + MaxWallTime time.Duration `json:"max_wall_time"` // 最大执行时间 + MaxTurns int `json:"max_turns"` // 最大模型调用轮数 + MaxToolCalls int `json:"max_tool_calls"` // 最大工具调用次数 + MaxInputTokens int64 `json:"max_input_tokens"` // 最大输入 token 数(含上下文) + MaxOutputTokens int64 `json:"max_output_tokens"` // 最大输出 token 数 + MaxParallelTools int `json:"max_parallel_tools"` // 工具并行上限 } // RunConfigSnapshot 是 Run 启动时保存的不可变配置。 type RunConfigSnapshot struct { - Mode AgentMode `json:"mode"` // 运行模式 - SystemPromptHash string `json:"system_prompt_hash"` // 系统提示哈希 - Model ModelConfig `json:"model"` // 模型配置 - ToolSetVersion string `json:"tool_set_version"` // 工具集版本 - PermissionPolicy tool.PermissionPolicy `json:"permission_policy"` // 工具权限策略 - ApprovalPolicy tool.ApprovalPolicy `json:"approval_policy"` // 审批策略 - RetryPolicy RetryPolicy `json:"retry_policy"` // 重试策略 - Limits RunLimits `json:"limits"` // 执行预算 + Mode AgentMode `json:"mode"` // 运行模式 + SystemPromptHash string `json:"system_prompt_hash"` // 系统提示哈希 + Model ModelConfig `json:"model"` // 模型配置 + ToolSetVersion string `json:"tool_set_version"` // 工具集版本 + PermissionPolicy tool.PermissionPolicy `json:"permission_policy"` // 工具权限策略 + ApprovalPolicy tool.ApprovalPolicy `json:"approval_policy"` // 审批策略 + RetryPolicy RetryPolicy `json:"retry_policy"` // 重试策略 + Limits RunLimits `json:"limits"` // 执行预算 ToolExecutionMode tool.ExecutionMode `json:"tool_execution_mode"` // 工具串行/并行模式 ToolFailurePolicy tool.FailurePolicy `json:"tool_failure_policy"` // 工具失败策略 - Profile profile.Config `json:"profile"` // Agent 配置 + Profile profile.Config `json:"profile"` // Agent 配置 } // Session 是长期存在的对话容器。 @@ -188,32 +188,32 @@ type Session struct { // Run 是 Session 内由用户触发的一次 Agent 执行。 type Run struct { - ID string `json:"id"` // Run ID - SessionID string `json:"session_id"` // 所属会话 - TriggerMessageID string `json:"trigger_message_id"` // 触发 Run 的用户消息 ID - Mode AgentMode `json:"mode"` // 运行模式 - Config RunConfigSnapshot `json:"config"` // 启动配置快照 - Status RunStatus `json:"status"` // 当前状态 - NeedsRecover bool `json:"needs_recover,omitempty"` // Handler 计算:执行已中断且 Worker 不在跑;不入库 + ID string `json:"id"` // Run ID + SessionID string `json:"session_id"` // 所属会话 + TriggerMessageID string `json:"trigger_message_id"` // 触发 Run 的用户消息 ID + Mode AgentMode `json:"mode"` // 运行模式 + Config RunConfigSnapshot `json:"config"` // 启动配置快照 + Status RunStatus `json:"status"` // 当前状态 + NeedsRecover bool `json:"needs_recover,omitempty"` // Handler 计算:执行已中断且 Worker 不在跑;不入库 CurrentTurnID *string `json:"current_turn_id,omitempty"` // 当前 Turn ID StopReason *StopReason `json:"stop_reason,omitempty"` // 结束原因 - CancelRequested bool `json:"cancel_requested"` // 是否已请求取消 - StartedAt *time.Time `json:"started_at,omitempty"` // 开始时间 - FinishedAt *time.Time `json:"finished_at,omitempty"` // 结束时间 + CancelRequested bool `json:"cancel_requested"` // 是否已请求取消 + StartedAt *time.Time `json:"started_at,omitempty"` // 开始时间 + FinishedAt *time.Time `json:"finished_at,omitempty"` // 结束时间 } // Turn 是 Run 内的一次模型调用。 type Turn struct { - ID string `json:"id"` // Turn ID - RunID string `json:"run_id"` // 所属 Run - Number int `json:"number"` // 第几轮(从 1 开始) - Status TurnStatus `json:"status"` // 当前状态 - FirstEventSeq int64 `json:"first_event_seq"` // 本轮第一个事件 seq - LastEventSeq int64 `json:"last_event_seq"` // 本轮最后一个事件 seq + ID string `json:"id"` // Turn ID + RunID string `json:"run_id"` // 所属 Run + Number int `json:"number"` // 第几轮(从 1 开始) + Status TurnStatus `json:"status"` // 当前状态 + FirstEventSeq int64 `json:"first_event_seq"` // 本轮第一个事件 seq + LastEventSeq int64 `json:"last_event_seq"` // 本轮最后一个事件 seq AssistantMsgID *string `json:"assistant_msg_id,omitempty"` // 助手消息 ID - UsageID *string `json:"usage_id,omitempty"` // 用量记录 ID - StartedAt *time.Time `json:"started_at,omitempty"` // 开始时间 - FinishedAt *time.Time `json:"finished_at,omitempty"` // 结束时间 + UsageID *string `json:"usage_id,omitempty"` // 用量记录 ID + StartedAt *time.Time `json:"started_at,omitempty"` // 开始时间 + FinishedAt *time.Time `json:"finished_at,omitempty"` // 结束时间 } // Attachment 描述与消息关联的用户输入附件。 @@ -227,98 +227,99 @@ type Attachment struct { // Message 是持久化的用户、助手、工具或系统消息。 type Message struct { - ID string `json:"id"` // 消息 ID - SessionID string `json:"session_id"` // 所属会话 - RunID *string `json:"run_id,omitempty"` // 所属 Run(可选) - TurnID *string `json:"turn_id,omitempty"` // 所属 Turn(可选) - Role MessageRole `json:"role"` // 角色 - Content json.RawMessage `json:"content"` // 内容(统一结构) + ID string `json:"id"` // 消息 ID + SessionID string `json:"session_id"` // 所属会话 + RunID *string `json:"run_id,omitempty"` // 所属 Run(可选) + TurnID *string `json:"turn_id,omitempty"` // 所属 Turn(可选) + Role MessageRole `json:"role"` // 角色 + Content json.RawMessage `json:"content"` // 内容(统一结构) Attachments []Attachment `json:"attachments,omitempty"` // 附件 ToolCalls []tool.Call `json:"tool_calls,omitempty"` // 助手消息的工具调用 - EventSeq int64 `json:"event_seq"` // 对应事件序号 - CreatedAt time.Time `json:"created_at"` // 创建时间 + EventSeq int64 `json:"event_seq"` // 对应事件序号 + CreatedAt time.Time `json:"created_at"` // 创建时间 } // CompactionSummary 是上下文快照引用的结构化摘要。 type CompactionSummary struct { - CheckpointID string `json:"checkpoint_id"` // 压缩检查点 ID - Content string `json:"content"` // 摘要内容 + CheckpointID string `json:"checkpoint_id"` // 压缩检查点 ID + Content string `json:"content"` // 摘要内容 BaseEventSeq int64 `json:"base_event_seq"` // 摘要涵盖到的事件序号 } // ContextSnapshot 是为一次 Turn 装配的上下文。 type ContextSnapshot struct { - SessionID string `json:"session_id"` // 所属会话 - BaseEventSeq int64 `json:"base_event_seq"` // 上下文起点事件序号 - Summary *CompactionSummary `json:"summary,omitempty"` // 压缩摘要 - Messages []Message `json:"messages"` // 历史消息 - Tools []tool.Definition `json:"tools"` // 本轮可见工具定义 - SystemPrompt string `json:"system_prompt"` // 注入的系统提示 + SessionID string `json:"session_id"` // 所属会话 + BaseEventSeq int64 `json:"base_event_seq"` // 上下文起点事件序号 + Summary *CompactionSummary `json:"summary,omitempty"` // 压缩摘要 + Messages []Message `json:"messages"` // 历史消息 + Tools []tool.Definition `json:"tools"` // 本轮可见工具定义 + SystemPrompt string `json:"system_prompt"` // 注入的系统提示 + WorkspaceRoot string `json:"workspace_root,omitempty"` // 会话冻结的工作目录 MemoryIndexes []string `json:"memory_indexes,omitempty"` // 冻结记忆目录 - EstimatedTokens int64 `json:"estimated_tokens"` // 估算 token 数 - Version int64 `json:"version"` // 快照版本 + EstimatedTokens int64 `json:"estimated_tokens"` // 估算 token 数 + Version int64 `json:"version"` // 快照版本 } // CompactionCheckpoint 记录持久化的上下文摘要边界。 type CompactionCheckpoint struct { - ID string `json:"id"` // 检查点 ID - SessionID string `json:"session_id"` // 所属会话 + ID string `json:"id"` // 检查点 ID + SessionID string `json:"session_id"` // 所属会话 BaseEventSeq int64 `json:"base_event_seq"` // 摘要起点事件序号 - Summary string `json:"summary"` // 摘要内容 + Summary string `json:"summary"` // 摘要内容 CreatedByRun string `json:"created_by_run"` // 创建该检查点的 Run ID - CreatedAt time.Time `json:"created_at"` // 创建时间 + CreatedAt time.Time `json:"created_at"` // 创建时间 } // ApprovalToolCall 是一条审批里的单个工具调用及其裁决。 type ApprovalToolCall struct { - ID string `json:"id"` // tool_call_id - Name string `json:"name"` // 工具名 - Arguments json.RawMessage `json:"arguments,omitempty"` // 参数 - Status ApprovalStatus `json:"status,omitempty"` // 裁决状态 - Reason string `json:"reason,omitempty"` // 裁决理由 + ID string `json:"id"` // tool_call_id + Name string `json:"name"` // 工具名 + Arguments json.RawMessage `json:"arguments,omitempty"` // 参数 + Status ApprovalStatus `json:"status,omitempty"` // 裁决状态 + Reason string `json:"reason,omitempty"` // 裁决理由 } // Approval 记录等待用户裁决的一批工具调用。 type Approval struct { - ID string `json:"id"` // 审批 ID - SessionID string `json:"session_id"` // 所属会话 - RunID string `json:"run_id"` // 所属 Run - ToolCallID string `json:"tool_call_id"` // 首个 tool_call_id - ToolCalls []ApprovalToolCall `json:"tool_calls"` // 全部工具调用及裁决 - Scope ApprovalScope `json:"scope"` // 生效范围 - Status ApprovalStatus `json:"status"` // 审批状态 - ExpiresAt time.Time `json:"expires_at"` // 过期时间 + ID string `json:"id"` // 审批 ID + SessionID string `json:"session_id"` // 所属会话 + RunID string `json:"run_id"` // 所属 Run + ToolCallID string `json:"tool_call_id"` // 首个 tool_call_id + ToolCalls []ApprovalToolCall `json:"tool_calls"` // 全部工具调用及裁决 + Scope ApprovalScope `json:"scope"` // 生效范围 + Status ApprovalStatus `json:"status"` // 审批状态 + ExpiresAt time.Time `json:"expires_at"` // 过期时间 } // AgentEvent 是 Agent 运行时产生的持久化有序事实。 type AgentEvent struct { - EventID string `json:"event_id"` // 事件 ID - SessionID string `json:"session_id"` // 所属会话 - RunID string `json:"run_id"` // 所属 Run + EventID string `json:"event_id"` // 事件 ID + SessionID string `json:"session_id"` // 所属会话 + RunID string `json:"run_id"` // 所属 Run TurnID *string `json:"turn_id,omitempty"` // 所属 Turn(可选) - Seq int64 `json:"seq"` // 会话内严格递增序号 - Type EventType `json:"type"` // 事件类型 - Version int `json:"version"` // 载荷版本 - OccurredAt time.Time `json:"occurred_at"` // 发生时间 - Payload json.RawMessage `json:"payload"` // 类型化载荷 + Seq int64 `json:"seq"` // 会话内严格递增序号 + Type EventType `json:"type"` // 事件类型 + Version int `json:"version"` // 载荷版本 + OccurredAt time.Time `json:"occurred_at"` // 发生时间 + Payload json.RawMessage `json:"payload"` // 类型化载荷 } // UsageRecord 保存单次请求的归一化用量与供应商原始用量。 type UsageRecord struct { - ID string `json:"id"` // 用量记录 ID - SessionID string `json:"session_id"` // 所属会话 - RunID string `json:"run_id"` // 所属 Run - TurnID string `json:"turn_id"` // 所属 Turn - RequestID string `json:"request_id"` // 供应商请求 ID - Provider string `json:"provider"` // 供应商 - Model string `json:"model"` // 模型名 - UsageType string `json:"usage_type"` // 用量类型 - CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` // 缓存创建输入 token - CacheReadInputTokens int64 `json:"cache_read_input_tokens"` // 缓存读取输入 token - OutputTokens int64 `json:"output_tokens"` // 输出 token - ReasoningTokens int64 `json:"reasoning_tokens"` // 推理 token - TotalTokens int64 `json:"total_tokens"` // 总 token - Estimated bool `json:"estimated"` // 是否估算 + ID string `json:"id"` // 用量记录 ID + SessionID string `json:"session_id"` // 所属会话 + RunID string `json:"run_id"` // 所属 Run + TurnID string `json:"turn_id"` // 所属 Turn + RequestID string `json:"request_id"` // 供应商请求 ID + Provider string `json:"provider"` // 供应商 + Model string `json:"model"` // 模型名 + UsageType string `json:"usage_type"` // 用量类型 + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` // 缓存创建输入 token + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` // 缓存读取输入 token + OutputTokens int64 `json:"output_tokens"` // 输出 token + ReasoningTokens int64 `json:"reasoning_tokens"` // 推理 token + TotalTokens int64 `json:"total_tokens"` // 总 token + Estimated bool `json:"estimated"` // 是否估算 RawProviderUsage json.RawMessage `json:"raw_provider_usage,omitempty"` // 供应商原始用量 - CreatedAt time.Time `json:"created_at"` // 创建时间 + CreatedAt time.Time `json:"created_at"` // 创建时间 } From 1a75c4d00071a88e35350ec8ea6551a9440355b8 Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Sat, 12 Sep 2026 22:58:20 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E8=BF=87=20CI=EF=BC=8C=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=96=E5=B7=A5=E5=85=B7=E7=B1=BB=E5=9E=8B=E5=B9=B6?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20Git=20=E9=A1=B5=20effect=20=E9=87=8C=20set?= =?UTF-8?q?State?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- apps/web/app/git-host.tsx | 23 ++++++++++------------- server/internal/agent/tools/types.go | 10 +++++----- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/apps/web/app/git-host.tsx b/apps/web/app/git-host.tsx index 12366cb..436ab49 100644 --- a/apps/web/app/git-host.tsx +++ b/apps/web/app/git-host.tsx @@ -2,29 +2,26 @@ import { GitClient } from "@codedock/core/git"; import { GitPage, GitProvider } from "@codedock/views/git"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useSyncExternalStore } from "react"; import { apiBase } from "@/lib/env"; import { readCurrentSession } from "@/lib/session"; -export function GitHost() { - const [sessionId, setSessionId] = useState(undefined); - const [ready, setReady] = useState(false); - - useEffect(() => { - setSessionId(readCurrentSession()); - setReady(true); - }, []); +function subscribeSession(onStoreChange: () => void) { + if (typeof window === "undefined") { + return () => {}; + } + window.addEventListener("storage", onStoreChange); + return () => window.removeEventListener("storage", onStoreChange); +} +export function GitHost() { + const sessionId = useSyncExternalStore(subscribeSession, readCurrentSession, () => undefined); const client = useMemo( () => new GitClient({ baseUrl: apiBase, sessionId }), [sessionId], ); - if (!ready) { - return

正在读取仓库…

; - } - return (
diff --git a/server/internal/agent/tools/types.go b/server/internal/agent/tools/types.go index 2c73b8f..979b5e3 100644 --- a/server/internal/agent/tools/types.go +++ b/server/internal/agent/tools/types.go @@ -99,22 +99,22 @@ type WriteInput struct { type GrepInput struct { Pattern string `json:"pattern"` - Path *string `json:"path,omitempty"` // 搜索起点;空则工作区根 + Path *string `json:"path,omitempty"` // 搜索起点;空则工作区根 Glob *string `json:"glob,omitempty"` IgnoreCase *bool `json:"ignoreCase,omitempty"` - Literal *bool `json:"literal,omitempty"` // 按字面量而不是正则 - Context *float64 `json:"context,omitempty"` // 匹配行上下各取几行 + Literal *bool `json:"literal,omitempty"` // 按字面量而不是正则 + Context *float64 `json:"context,omitempty"` // 匹配行上下各取几行 Limit *float64 `json:"limit,omitempty"` } type FindInput struct { Pattern string `json:"pattern"` - Path *string `json:"path,omitempty"` // 搜索起点;空则工作区根 + Path *string `json:"path,omitempty"` // 搜索起点;空则工作区根 Limit *float64 `json:"limit,omitempty"` } type LSInput struct { - Path *string `json:"path,omitempty"` // 要列出的目录;空则工作区根 + Path *string `json:"path,omitempty"` // 要列出的目录;空则工作区根 Limit *float64 `json:"limit,omitempty"` } From b40b5f0dd9a8d26a3b3e5767131c7a044448db43 Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Sun, 13 Sep 2026 11:21:12 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E8=BF=87=20CI=EF=BC=8C=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E7=9B=AE=E5=BD=95=E9=80=89=E6=8B=A9=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E5=B9=B6=E5=9B=BA=E5=AE=9A=20Git=20=E6=B5=8B=E8=AF=95=E5=88=86?= =?UTF-8?q?=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- packages/ui/lib/ime.ts | 8 ++++++-- packages/views/chat/chat-page.tsx | 2 +- server/internal/handler/git_test.go | 6 +++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/ui/lib/ime.ts b/packages/ui/lib/ime.ts index 8671f6d..4e37b92 100644 --- a/packages/ui/lib/ime.ts +++ b/packages/ui/lib/ime.ts @@ -4,12 +4,16 @@ import { useRef, type CompositionEvent, type KeyboardEvent as ReactKeyboardEvent type KeyLike = KeyboardEvent | ReactKeyboardEvent; +function flag(event: object, name: "isComposing"): boolean { + return name in event && Boolean((event as { isComposing?: boolean }).isComposing); +} + /** 输入法回车是在确认候选,不是提交。 */ export function isImeConfirm(event: KeyLike): boolean { const native = "nativeEvent" in event ? event.nativeEvent : event; return Boolean( - event.isComposing || - native.isComposing || + flag(event, "isComposing") || + flag(native, "isComposing") || event.keyCode === 229 || native.keyCode === 229 || event.key === "Process", diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index 19dfa1b..487c121 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -175,7 +175,7 @@ export function ChatPage({ setPickerOpen(false)} + onClose={() => setPickerOpen(false)} onSelect={(path) => { writeLastWorkspace(path); setWorkspaceDraft(path); diff --git a/server/internal/handler/git_test.go b/server/internal/handler/git_test.go index e24cc94..050749a 100644 --- a/server/internal/handler/git_test.go +++ b/server/internal/handler/git_test.go @@ -527,13 +527,13 @@ func TestGitPullConflictAndRemoteBranches(t *testing.T) { gitCmd(t, dir, "add", "a.txt") gitCmd(t, dir, "commit", "-m", "base") bare := t.TempDir() - gitCmd(t, bare, "init", "--bare") + gitCmd(t, bare, "init", "--bare", "-b", "main") gitCmd(t, dir, "remote", "add", "origin", bare) gitCmd(t, dir, "push", "-u", "origin", "main") parent := t.TempDir() other := filepath.Join(parent, "clone") - gitCmd(t, parent, "clone", bare, other) + gitCmd(t, parent, "clone", "-b", "main", bare, other) gitCmd(t, other, "config", "user.name", "tester") gitCmd(t, other, "config", "user.email", "tester@example.com") if err := os.WriteFile(filepath.Join(other, "a.txt"), []byte("theirs\n"), 0o644); err != nil { @@ -720,7 +720,7 @@ func TestGitPushPullRevertUndoExtras(t *testing.T) { gitCmd(t, dir, "commit", "-m", "second") bare := t.TempDir() - gitCmd(t, bare, "init", "--bare") + gitCmd(t, bare, "init", "--bare", "-b", "main") gitCmd(t, dir, "remote", "add", "origin", bare) gitCmd(t, dir, "push", "-u", "origin", "main")