diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 99a1f11fb..947911e60 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -574,25 +574,12 @@ export const PromptInput: Component = (props) => { }) } - const agentList = createMemo(() => - sync.data.agent - .filter((agent) => !agent.hidden && agent.mode !== "primary") - .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), - ) - const handleAtSelect = (option: AtOption | undefined) => { if (!option) return - if (option.type === "agent") { - addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) - } else { - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) - } + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } - const atKey = (x: AtOption | undefined) => { - if (!x) return "" - return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` - } + const atKey = (x: AtOption | undefined) => x?.path ?? "" const { flat: atFlat, @@ -602,32 +589,20 @@ export const PromptInput: Component = (props) => { onKeyDown: atOnKeyDown, } = useFilteredList({ items: async (query) => { - const agents = agentList() const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...agents, ...pinned] + if (!query.trim()) return pinned const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) .map((path) => ({ type: "file", path, display: path })) - return [...agents, ...pinned, ...fileOptions] + return [...pinned, ...fileOptions] }, key: atKey, filterKeys: ["display"], - groupBy: (item) => { - if (item.type === "agent") return "agent" - if (item.recent) return "recent" - return "file" - }, - sortGroupsBy: (a, b) => { - const rank = (category: string) => { - if (category === "agent") return 0 - if (category === "recent") return 1 - return 2 - } - return rank(a.category) - rank(b.category) - }, + groupBy: (item) => (item.recent ? "recent" : "file"), + sortGroupsBy: (a, b) => (a.category === "recent" ? -1 : b.category === "recent" ? 1 : 0), onSelect: handleAtSelect, }) diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 9725cba4d..f7d288f4e 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -1,11 +1,8 @@ import { Component, For, Match, Show, Switch } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" -import { Icon } from "@opencode-ai/ui/icon" import { getDirectory, getFilename } from "@opencode-ai/util/path" -export type AtOption = - | { type: "agent"; name: string; display: string } - | { type: "file"; path: string; display: string; recent?: boolean } +export type AtOption = { type: "file"; path: string; display: string; recent?: boolean } export interface SlashCommand { id: string @@ -56,20 +53,6 @@ export const PromptPopover: Component = (props) => { {(item) => { const key = props.atKey(item) - if (item.type === "agent") { - return ( - - ) - } - const isDirectory = item.path.endsWith("/") const directory = isDirectory ? item.path : getDirectory(item.path) const filename = isDirectory ? "" : getFilename(item.path) diff --git a/packages/app/src/no-mode-picker.test.ts b/packages/app/src/no-mode-picker.test.ts new file mode 100644 index 000000000..d0c3d3850 --- /dev/null +++ b/packages/app/src/no-mode-picker.test.ts @@ -0,0 +1,78 @@ +import { test, expect } from "bun:test" +import * as fs from "node:fs/promises" +import * as path from "node:path" + +const APP_SRC = __dirname +// packages/app/src → up 2 to packages → into ui/src/components +const UI_COMPONENTS = path.resolve(__dirname, "..", "..", "ui", "src", "components") + +// Allowlist: legitimate references to mode === "primary" outside the picker context. +// Each entry: { file: relative-to-APP_SRC, line: 1-based, reason: short justification }. +// Add entries here only when you have read the line and confirmed it is not picker-related. +const MODE_PRIMARY_ALLOWLIST: { file: string; line: number; reason: string }[] = [ + { + file: "context/global-sync/utils.ts", + line: 15, + reason: "type guard isAgent: validates agent shape, accepts any of subagent|primary|all (not picker logic)", + }, +] + +async function walk(dir: string, acc: string[] = []): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) continue + await walk(full, acc) + } else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) { + acc.push(full) + } + } + return acc +} + +test("i18n bundles contain no primary-agent / mode-picker copy", async () => { + const zh = await fs.readFile(path.join(APP_SRC, "i18n", "zh.ts"), "utf8") + const en = await fs.readFile(path.join(APP_SRC, "i18n", "en.ts"), "utf8") + const re = /primary agent|default agent|agent mode|mode picker/i + expect(zh).not.toMatch(re) + expect(en).not.toMatch(re) +}) + +test('no source file in packages/app/src uses mode === "primary" outside the allowlist', async () => { + const files = await walk(APP_SRC) + const re = /mode\s*[!=]==?\s*['"]primary['"]/ + const offenders: { file: string; line: number; text: string }[] = [] + for (const file of files) { + const text = await fs.readFile(file, "utf8") + const lines = text.split(/\r?\n/) + lines.forEach((lineText, i) => { + if (!re.test(lineText)) return + const relPath = path.relative(APP_SRC, file) + const ok = MODE_PRIMARY_ALLOWLIST.some((a) => a.file === relPath && a.line === i + 1) + if (!ok) offenders.push({ file: relPath, line: i + 1, text: lineText.trim() }) + }) + } + if (offenders.length > 0) { + const summary = offenders.map((o) => ` ${o.file}:${o.line} ${o.text}`).join("\n") + throw new Error( + `Found ${offenders.length} mode === "primary" reference(s) in packages/app/src not in MODE_PRIMARY_ALLOWLIST:\n${summary}\n\nIf the reference is legitimate (not picker-related), add it to MODE_PRIMARY_ALLOWLIST in this test file with a one-line reason.`, + ) + } +}) + +test("agentList memo is gone from prompt-input.tsx", async () => { + const file = path.join(APP_SRC, "components", "prompt-input.tsx") + const text = await fs.readFile(file, "utf8") + expect(text).not.toContain("agentList") +}) + +test("message-part.tsx no longer renders agent pill", async () => { + // After Task 5, HighlightedText drops agents from allRefs and the type union no + // longer includes "agent". Source-grep guards against future regressions that + // re-introduce a styled pill via the same data-highlight marker. + const file = path.join(UI_COMPONENTS, "message-part.tsx") + const text = await fs.readFile(file, "utf8") + // Match data-highlight="agent" / 'agent' / `agent` to survive quote-style changes. + expect(text).not.toMatch(/data-highlight\s*=\s*["'`]agent["'`]/) +}) diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts index 1ecaf02c9..af7213c15 100644 --- a/packages/app/src/utils/prompt.test.ts +++ b/packages/app/src/utils/prompt.test.ts @@ -41,4 +41,106 @@ describe("extractPromptFromParts", () => { { type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" }, ]) }) + + test("issue #239: AgentPart in history restores as plain text, not as an agent inline", () => { + // Pre-#239 messages may contain a separate AgentPart record beside the text + // that already includes "@" inline. After #239 the picker is gone, so + // the AgentPart must be ignored and the @ substring should restore as + // plain text from the text part. + const parts = [ + { + id: "text_1", + type: "text", + text: "ask @researcher to look at this", + sessionID: "ses_1", + messageID: "msg_1", + }, + { + id: "agent_1", + type: "agent", + name: "researcher", + source: { value: "@researcher", start: 4, end: 15 }, + sessionID: "ses_1", + messageID: "msg_1", + }, + ] satisfies Part[] + + const result = extractPromptFromParts(parts) + + // No agent inline reconstructed + expect(result.some((p) => p.type === "agent")).toBe(false) + + // The full original text (including the literal "@researcher") restores from + // the text part as a single plain-text inline + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ type: "text", content: "ask @researcher to look at this" }) + }) + + test("issue #239: AgentPart between file references does not disturb file offsets", () => { + // File part offsets in the surrounding text must not shift even when an + // AgentPart sits between them. The agent record is dropped entirely; + // file inlines occupy their original positions. + const parts = [ + { + id: "text_1", + type: "text", + text: "open @a.ts then @bot then @b.ts", + sessionID: "ses_1", + messageID: "msg_1", + }, + { + id: "file_a", + type: "file", + mime: "text/plain", + url: "file:///workspace/a.ts", + source: { + type: "file", + path: "/workspace/a.ts", + text: { value: "@a.ts", start: 5, end: 10 }, + }, + sessionID: "ses_1", + messageID: "msg_1", + }, + { + id: "agent_1", + type: "agent", + name: "bot", + source: { value: "@bot", start: 16, end: 20 }, + sessionID: "ses_1", + messageID: "msg_1", + }, + { + id: "file_b", + type: "file", + mime: "text/plain", + url: "file:///workspace/b.ts", + source: { + type: "file", + path: "/workspace/b.ts", + text: { value: "@b.ts", start: 26, end: 31 }, + }, + sessionID: "ses_1", + messageID: "msg_1", + }, + ] satisfies Part[] + + const result = extractPromptFromParts(parts) + + // No agent in result + expect(result.some((p) => p.type === "agent")).toBe(false) + + // File parts are present at their original offsets; @bot stays inside text + const files = result.filter((p) => p.type === "file") + expect(files).toHaveLength(2) + // path strips the leading "@" from the source.text.value (extractor convention) + expect(files[0]).toMatchObject({ type: "file", path: "a.ts", start: 5, end: 10 }) + expect(files[1]).toMatchObject({ type: "file", path: "b.ts", start: 26, end: 31 }) + + // @bot stays as plain text in the surrounding text inlines + const text = result + .filter((p) => p.type === "text") + .map((p) => p.content) + .join("") + expect(text).toContain("@bot") + }) }) diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts index 35aec0071..1124f92b9 100644 --- a/packages/app/src/utils/prompt.ts +++ b/packages/app/src/utils/prompt.ts @@ -1,5 +1,5 @@ -import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2" -import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" +import type { FilePart, Part, TextPart } from "@opencode-ai/sdk/v2" +import type { FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" type Inline = | { @@ -112,18 +112,11 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin } } - if (part.type === "agent") { - const agentPart = part as MessageAgentPart - const source = agentPart.source - if (!source) continue - inline.push({ - type: "agent", - start: source.start, - end: source.end, - value: source.value, - name: agentPart.name, - }) - } + // PawWork issue #239: AgentPart records from history are intentionally NOT + // converted to inline agent pills. The original `@` substring is + // already in the surrounding text part, so it restores as plain text. + // This single point also defuses buildRequestParts (no AgentPartInput + // submitted) and renderEditor (no pill). } inline.sort((a, b) => { @@ -160,19 +153,6 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin position += content.length } - const pushAgent = (item: Extract) => { - const content = item.value - const mention: AgentPart = { - type: "agent", - name: item.name, - content, - start: position, - end: position + content.length, - } - result.push(mention) - position += content.length - } - for (const item of inline) { if (item.start < 0 || item.end < item.start) continue @@ -187,7 +167,6 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin pushText(text.slice(cursor, start)) if (item.type === "file") pushFile(item) - if (item.type === "agent") pushAgent(item) cursor = end } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index f9a2b2712..0d132deb6 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -15,14 +15,12 @@ import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { Permission } from "@/permission" import { mergeDeep, pipe, sortBy, values } from "remeda" -import { Global } from "@/global" import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" import { Effect, Context, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" -import { Runtime } from "@opencode-ai/shared/runtime" export namespace Agent { export const Info = z @@ -115,7 +113,6 @@ export namespace Agent { }) const user = Permission.fromConfig(cfg.permission ?? {}) - const projectPlansDir = Runtime.isPawWork() ? ".pawwork" : ".opencode" const agents: Record = { build: { @@ -133,30 +130,8 @@ export namespace Agent { mode: "primary", native: true, }, - plan: { - name: "plan", - description: "Plan mode. Disallows all edit tools.", - options: {}, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - question: "allow", - plan_exit: "allow", - external_directory: { - [path.join(Global.Path.data, "plans", "*")]: "allow", - }, - edit: { - "*": "deny", - [path.join(projectPlansDir, "plans", "*.md")]: "allow", - [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: - "allow", - }, - }), - user, - ), - mode: "primary", - native: true, - }, + // PawWork: visible Plan mode permanently removed (issue #239); resolve sync + // conflicts in this region to HEAD. Plan capability migrated to plan tool per #127. general: { name: "general", description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, @@ -310,11 +285,14 @@ export namespace Agent { const defaultAgent = Effect.fnUntraced(function* () { const c = yield* config.get() if (c.default_agent) { + // Gentle fallback (issue #239): if the configured default_agent is missing, + // a subagent, or hidden, ignore it and fall through. This covers configs + // outside the loadGlobal migration path (project, env, managed) that may + // still reference an agent removed by an upgrade (e.g. "plan"). const agent = agents[c.default_agent] - if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) - if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) - if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) - return agent.name + if (agent && agent.mode !== "subagent" && agent.hidden !== true) { + return agent.name + } } const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) if (!visible) throw new Error("no primary visible agent found") diff --git a/packages/opencode/src/agent/prompt/explore.txt b/packages/opencode/src/agent/prompt/explore.txt index 5761077cb..3743a56c8 100644 --- a/packages/opencode/src/agent/prompt/explore.txt +++ b/packages/opencode/src/agent/prompt/explore.txt @@ -1,18 +1,22 @@ -You are a file search specialist. You excel at thoroughly navigating and exploring codebases. +You are a research specialist. You explore information sources — code, files, and the web — to answer the user's question without modifying anything. Your strengths: -- Rapidly finding files using glob patterns -- Searching code and text with powerful regex patterns -- Reading and analyzing file contents +- Searching the web for current information (webfetch, websearch) +- Finding files and code by patterns (glob, grep) +- Reading specific file contents (read) Guidelines: -- Use Glob for broad file pattern matching -- Use Grep for searching file contents with regex -- Use Read when you know the specific file path you need to read -- Use Bash for file operations like copying, moving, or listing directory contents -- Adapt your search approach based on the thoroughness level specified by the caller -- Return file paths as absolute paths in your final response -- For clear communication, avoid using emojis -- Do not create any files, or run bash commands that modify the user's system state in any way +- Use websearch for current events, recent changes, or when the answer depends on information outside the workspace +- Use webfetch when you have a specific URL to read in full +- Use glob for file pattern matching across the workspace +- Use grep for searching file contents with regex +- Use read when you know the specific file path +- You may run read-only bash commands when no other tool fits +- You cannot edit, write, or modify any system state — that's by design -Complete the user's search request efficiently and report your findings clearly. +Output format: +- Each finding is a Markdown paragraph that names its source first, then the content. +- Source = file path with line range (e.g. `packages/foo/bar.ts:42-58`), URL (e.g. `https://docs.example.com/api#auth`), or grep location. +- When findings span code and web, you may interleave them in one response. +- For clear communication, avoid emojis. +- Return file paths as absolute paths in your final response. diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 287a48580..2edfab265 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -11,6 +11,7 @@ import { Flag } from "../flag/flag" import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" +import { migrateDefaultAgent } from "./migrate-default-agent" import { Instance, type InstanceContext } from "../project/instance" import { constants, existsSync } from "fs" import { GlobalBus } from "@/bus/global" @@ -201,7 +202,6 @@ const InfoSchema = Schema.Struct({ Schema.StructWithRest( Schema.Struct({ build: Schema.optional(AgentRef), - plan: Schema.optional(AgentRef), }), [Schema.Record(Schema.String, AgentRef)], ), @@ -210,7 +210,6 @@ const InfoSchema = Schema.Struct({ Schema.StructWithRest( Schema.Struct({ // primary - plan: Schema.optional(AgentRef), build: Schema.optional(AgentRef), // subagent general: Schema.optional(AgentRef), @@ -450,7 +449,17 @@ const rawLayer = Layer.effect( const loadGlobal = Effect.fnUntraced(function* () { let result: Info = {} for (const file of globalConfigFiles()) { - result = pipe(result, mergeDeep(yield* loadFile(path.join(Global.Path.config, file)))) + const filepath = path.join(Global.Path.config, file) + // Strip deprecated default_agent before parsing (issue #239). + // When sanitizedText is present we use it directly to avoid a redundant + // disk read AND to ensure runtime never sees a stale value even if the + // on-disk rewrite failed. + const migrated = yield* Effect.promise(() => migrateDefaultAgent(filepath)) + if (migrated.sanitizedText !== undefined) { + result = pipe(result, mergeDeep(yield* loadConfig(migrated.sanitizedText, { path: filepath }))) + } else { + result = pipe(result, mergeDeep(yield* loadFile(filepath))) + } } const legacy = path.join(Global.Path.config, "config") diff --git a/packages/opencode/src/config/migrate-default-agent.ts b/packages/opencode/src/config/migrate-default-agent.ts new file mode 100644 index 000000000..b9e0994b3 --- /dev/null +++ b/packages/opencode/src/config/migrate-default-agent.ts @@ -0,0 +1,87 @@ +import * as fs from "node:fs/promises" +import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser" +import { Log } from "../util/log" + +const defaultLogger = Log.create({ service: "config.migrate-default-agent" }) + +export type MigrationLogger = { + debug(message?: any, extra?: Record): void + info(message?: any, extra?: Record): void + warn(message?: any, extra?: Record): void + error(message?: any, extra?: Record): void +} + +const failedPaths = new Set() + +export function _resetFailedPaths(): void { + failedPaths.clear() +} +export function _hasFailedPath(p: string): boolean { + return failedPaths.has(p) +} + +export function stripDefaultAgent(text: string): { text: string; oldValue: unknown | undefined } { + const parsed = parseJsonc(text, [], { allowTrailingComma: true }) + if (parsed === undefined || parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { text, oldValue: undefined } + } + const obj = parsed as Record + if (!("default_agent" in obj)) return { text, oldValue: undefined } + const oldValue = obj.default_agent + + const edits = modify(text, ["default_agent"], undefined, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }) + const rewritten = applyEdits(text, edits) + return { text: rewritten, oldValue } +} + +export async function migrateDefaultAgent( + filepath: string, + options?: { logger?: MigrationLogger }, +): Promise<{ rewritten: boolean; sanitizedText?: string }> { + const log = options?.logger ?? defaultLogger + + let raw: string + try { + raw = await fs.readFile(filepath, "utf8") + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return { rewritten: false } + failedPaths.add(filepath) + log.warn("could not read user config; skipping migration", { path: filepath, error: String(err) }) + return { rewritten: false } + } + + const { text: sanitized, oldValue } = stripDefaultAgent(raw) + if (oldValue === undefined) return { rewritten: false } + + if (failedPaths.has(filepath)) { + log.debug("skipping disk rewrite; previous attempt failed in this process", { path: filepath }) + return { rewritten: false, sanitizedText: sanitized } + } + + const tmpPath = filepath + ".migrate.tmp" + try { + // Preserve the original file mode so users who chmod 0600 their config + // (e.g. configs containing apiKey) don't get downgraded to umask defaults. + const original = await fs.stat(filepath) + await fs.writeFile(tmpPath, sanitized, "utf8") + await fs.chmod(tmpPath, original.mode) + await fs.rename(tmpPath, filepath) + } catch (err: unknown) { + failedPaths.add(filepath) + try { + await fs.unlink(tmpPath) + } catch { + /* ignore */ + } + log.warn("could not rewrite user config; in-memory sanitization only", { + path: filepath, + error: String(err), + }) + return { rewritten: false, sanitizedText: sanitized } + } + + log.info("migrated deprecated default_agent", { path: filepath, oldValue }) + return { rewritten: true, sanitizedText: sanitized } +} diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index d77dba8d3..e169400ff 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -23,7 +23,8 @@ test("returns default native agents when no config", async () => { const agents = await Agent.list() const names = agents.map((a) => a.name) expect(names).toContain("build") - expect(names).toContain("plan") + // plan agent removed in #239 + expect(names).not.toContain("plan") expect(names).toContain("general") expect(names).toContain("explore") expect(names).toContain("compaction") @@ -48,42 +49,6 @@ test("build agent has correct default properties", async () => { }) }) -test("plan agent denies edits except .opencode/plans/*", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const plan = await Agent.get("plan") - expect(plan).toBeDefined() - // Wildcard is denied - expect(evalPerm(plan, "edit")).toBe("deny") - // But specific path is allowed - expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") - }, - }) -}) - -test("plan agent allows .pawwork plans in PawWork runtime mode", async () => { - await using tmp = await tmpdir() - const previous = process.env.PAWWORK_RUNTIME_NAMESPACE - process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork" - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const plan = await Agent.get("plan") - expect(plan).toBeDefined() - expect(Permission.evaluate("edit", ".pawwork/plans/foo.md", plan!.permission).action).toBe("allow") - expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("deny") - }, - }) - } finally { - if (previous === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE - else process.env.PAWWORK_RUNTIME_NAMESPACE = previous - } -}) - test("explore agent denies edit and write", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -269,7 +234,7 @@ test("agent steps/maxSteps config sets steps property", async () => { config: { agent: { build: { steps: 50 }, - plan: { maxSteps: 100 }, + general: { maxSteps: 100 }, }, }, }) @@ -277,9 +242,9 @@ test("agent steps/maxSteps config sets steps property", async () => { directory: tmp.path, fn: async () => { const build = await Agent.get("build") - const plan = await Agent.get("plan") + const general = await Agent.get("general") expect(build?.steps).toBe(50) - expect(plan?.steps).toBe(100) + expect(general?.steps).toBe(100) }, }) }) @@ -410,8 +375,12 @@ test("multiple custom agents can be defined", async () => { test("Agent.list keeps the default agent first and sorts the rest by name", async () => { await using tmp = await tmpdir({ config: { - default_agent: "plan", + default_agent: "my_primary", agent: { + my_primary: { + description: "Custom primary", + mode: "primary", + }, zebra: { description: "Zebra", mode: "subagent", @@ -427,7 +396,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn directory: tmp.path, fn: async () => { const names = (await Agent.list()).map((a) => a.name) - expect(names[0]).toBe("plan") + expect(names[0]).toBe("my_primary") expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b))) }, }) @@ -624,21 +593,6 @@ test("defaultAgent returns build when no default_agent config", async () => { }) }) -test("defaultAgent respects default_agent config set to plan", async () => { - await using tmp = await tmpdir({ - config: { - default_agent: "plan", - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await Agent.defaultAgent() - expect(agent).toBe("plan") - }, - }) -}) - test("defaultAgent respects default_agent config set to custom agent with mode all", async () => { await using tmp = await tmpdir({ config: { @@ -659,7 +613,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a }) }) -test("defaultAgent throws when default_agent points to subagent", async () => { +test("defaultAgent falls back to build when default_agent points to subagent", async () => { await using tmp = await tmpdir({ config: { default_agent: "explore", @@ -668,12 +622,14 @@ test("defaultAgent throws when default_agent points to subagent", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(Agent.defaultAgent()).rejects.toThrow('default agent "explore" is a subagent') + const agent = await Agent.defaultAgent() + // gentle fallback per issue #239 — invalid default_agent silently degrades + expect(agent).toBe("build") }, }) }) -test("defaultAgent throws when default_agent points to hidden agent", async () => { +test("defaultAgent falls back to build when default_agent points to hidden agent", async () => { await using tmp = await tmpdir({ config: { default_agent: "compaction", @@ -682,12 +638,13 @@ test("defaultAgent throws when default_agent points to hidden agent", async () = await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(Agent.defaultAgent()).rejects.toThrow('default agent "compaction" is hidden') + const agent = await Agent.defaultAgent() + expect(agent).toBe("build") }, }) }) -test("defaultAgent throws when default_agent points to non-existent agent", async () => { +test("defaultAgent falls back to build when default_agent points to non-existent agent", async () => { await using tmp = await tmpdir({ config: { default_agent: "does_not_exist", @@ -696,16 +653,23 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(Agent.defaultAgent()).rejects.toThrow('default agent "does_not_exist" not found') + const agent = await Agent.defaultAgent() + // covers the post-#239 case where a project/managed config still references + // a now-removed agent like "plan" + expect(agent).toBe("build") }, }) }) -test("defaultAgent returns plan when build is disabled and default_agent not set", async () => { +test("defaultAgent picks a custom primary when build is disabled", async () => { await using tmp = await tmpdir({ config: { agent: { build: { disable: true }, + my_primary: { + description: "Custom primary", + mode: "primary", + }, }, }, }) @@ -713,25 +677,23 @@ test("defaultAgent returns plan when build is disabled and default_agent not set directory: tmp.path, fn: async () => { const agent = await Agent.defaultAgent() - // build is disabled, so it should return plan (next primary agent) - expect(agent).toBe("plan") + // build is disabled, the next primary visible agent is the custom one + expect(agent).toBe("my_primary") }, }) }) -test("defaultAgent throws when all primary agents are disabled", async () => { +test("defaultAgent throws when build is disabled and no other primary visible exists", async () => { await using tmp = await tmpdir({ config: { agent: { build: { disable: true }, - plan: { disable: true }, }, }, }) await Instance.provide({ directory: tmp.path, fn: async () => { - // build and plan are disabled, no primary-capable agents remain await expect(Agent.defaultAgent()).rejects.toThrow("no primary visible agent found") }, }) diff --git a/packages/opencode/test/agent/no-plan-agent.test.ts b/packages/opencode/test/agent/no-plan-agent.test.ts new file mode 100644 index 000000000..725489030 --- /dev/null +++ b/packages/opencode/test/agent/no-plan-agent.test.ts @@ -0,0 +1,18 @@ +import { test, expect } from "bun:test" +import { Effect } from "effect" +import { provideInstance, tmpdir } from "../fixture/fixture" +import { Agent } from "../../src/agent/agent" + +test("plan agent is not registered after #239", async () => { + await using tmp = await tmpdir() + await Effect.runPromise( + provideInstance(tmp.path)( + Effect.promise(async () => { + const agents = await Agent.list() + const names = agents.map((a) => a.name) + expect(names).not.toContain("plan") + expect(names).toContain("build") // build remains as the hidden default + }), + ), + ) +}) diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index d77782354..fd6d20a44 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -21,7 +21,7 @@ test("agent color parsed from project config", async () => { $schema: "https://opencode.ai/config.json", agent: { build: { color: "#FFA500" }, - plan: { color: "primary" }, + general: { color: "primary" }, }, }), ) @@ -32,7 +32,7 @@ test("agent color parsed from project config", async () => { fn: async () => { const cfg = await load() expect(cfg.agent?.["build"]?.color).toBe("#FFA500") - expect(cfg.agent?.["plan"]?.color).toBe("primary") + expect(cfg.agent?.["general"]?.color).toBe("primary") }, }) }) @@ -45,7 +45,7 @@ test("Agent.get includes color from config", async () => { JSON.stringify({ $schema: "https://opencode.ai/config.json", agent: { - plan: { color: "#A855F7" }, + explore: { color: "#A855F7" }, build: { color: "accent" }, }, }), @@ -55,8 +55,8 @@ test("Agent.get includes color from config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const plan = await agent(tmp.path, (svc) => svc.get("plan")) - expect(plan?.color).toBe("#A855F7") + const explore = await agent(tmp.path, (svc) => svc.get("explore")) + expect(explore?.color).toBe("#A855F7") const build = await agent(tmp.path, (svc) => svc.get("build")) expect(build?.color).toBe("accent") }, diff --git a/packages/opencode/test/config/migrate-default-agent.test.ts b/packages/opencode/test/config/migrate-default-agent.test.ts new file mode 100644 index 000000000..c45548d43 --- /dev/null +++ b/packages/opencode/test/config/migrate-default-agent.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, test, expect } from "bun:test" +import * as fs from "node:fs/promises" +import * as path from "node:path" +import { parse as parseJsonc } from "jsonc-parser" +import { tmpdir } from "../fixture/fixture" +import { + stripDefaultAgent, + migrateDefaultAgent, + _resetFailedPaths, + _hasFailedPath, + type MigrationLogger, +} from "../../src/config/migrate-default-agent" + +beforeEach(() => { + _resetFailedPaths() +}) + +function makeMockLogger() { + const calls: { level: "debug" | "info" | "warn" | "error"; message: string; extra?: Record }[] = [] + const logger: MigrationLogger = { + debug: (message, extra) => calls.push({ level: "debug", message: String(message), extra }), + info: (message, extra) => calls.push({ level: "info", message: String(message), extra }), + warn: (message, extra) => calls.push({ level: "warn", message: String(message), extra }), + error: (message, extra) => calls.push({ level: "error", message: String(message), extra }), + } + return { logger, calls } +} + +test("stripDefaultAgent: returns original text + oldValue=undefined when field absent", () => { + const text = JSON.stringify({ model: "anthropic/claude-opus-4-7" }, null, 2) + const result = stripDefaultAgent(text) + expect(result.text).toBe(text) + expect(result.oldValue).toBeUndefined() +}) + +test("stripDefaultAgent: removes field from plain JSON, preserves other keys", () => { + const before = JSON.stringify({ default_agent: "plan", model: "x/y" }, null, 2) + const result = stripDefaultAgent(before) + expect(result.oldValue).toBe("plan") + const parsed = JSON.parse(result.text) + expect(parsed.default_agent).toBeUndefined() + expect(parsed.model).toBe("x/y") +}) + +test("stripDefaultAgent: handles JSONC with comments and trailing commas", () => { + // jsonc-parser's modify() drops a key together with its leading comment trivia + // (treated as part of the removed node). Trailing same-line comments and unrelated + // fields survive. PawWork writes default_agent automatically, so leading-comment + // loss next to the deprecated field is acceptable best-effort behavior. + const before = `{ + "default_agent": "plan", + "model": "anthropic/claude-opus-4-7", // trailing comment + }` + const result = stripDefaultAgent(before) + expect(result.oldValue).toBe("plan") + expect(result.text).not.toContain("default_agent") + expect(result.text).toContain("trailing comment") + // Result must remain valid JSONC and preserve the surviving field + const reparsed = parseJsonc(result.text, [], { allowTrailingComma: true }) + expect(reparsed.model).toBe("anthropic/claude-opus-4-7") +}) + +test("stripDefaultAgent: tolerates non-object JSON (returns text unchanged)", () => { + for (const text of ["[1,2,3]", "null", '"just a string"', "42"]) { + const result = stripDefaultAgent(text) + expect(result.text).toBe(text) + expect(result.oldValue).toBeUndefined() + } +}) + +test("migrateDefaultAgent: rewrites a config file containing default_agent (atomic)", async () => { + await using tmp = await tmpdir() + const cfgPath = path.join(tmp.path, "pawwork.json") + await fs.writeFile(cfgPath, JSON.stringify({ default_agent: "plan", model: "x/y" }, null, 2), "utf8") + + const { logger, calls } = makeMockLogger() + const res = await migrateDefaultAgent(cfgPath, { logger }) + + expect(res.rewritten).toBe(true) + expect(res.sanitizedText).toBeDefined() + expect(res.sanitizedText).not.toContain("default_agent") + + const after = JSON.parse(await fs.readFile(cfgPath, "utf8")) + expect(after.default_agent).toBeUndefined() + expect(after.model).toBe("x/y") + + const infoCalls = calls.filter((c) => c.level === "info" && c.message.includes("migrated deprecated default_agent")) + expect(infoCalls.length).toBe(1) + expect(infoCalls[0].extra?.oldValue).toBe("plan") +}) + +test("migrateDefaultAgent: idempotent — does not modify file when field absent", async () => { + await using tmp = await tmpdir() + const cfgPath = path.join(tmp.path, "pawwork.json") + const original = JSON.stringify({ model: "x/y" }, null, 2) + await fs.writeFile(cfgPath, original, "utf8") + const beforeMtime = (await fs.stat(cfgPath)).mtimeMs + await new Promise((r) => setTimeout(r, 10)) + + const { logger } = makeMockLogger() + const res = await migrateDefaultAgent(cfgPath, { logger }) + + expect(res.rewritten).toBe(false) + expect(res.sanitizedText).toBeUndefined() + expect((await fs.stat(cfgPath)).mtimeMs).toBe(beforeMtime) +}) + +test("migrateDefaultAgent: missing config file resolves cleanly", async () => { + await using tmp = await tmpdir() + const cfgPath = path.join(tmp.path, "does-not-exist.json") + const { logger } = makeMockLogger() + const res = await migrateDefaultAgent(cfgPath, { logger }) + expect(res.rewritten).toBe(false) +}) + +test("migrateDefaultAgent: write failure → returns sanitizedText for in-memory fallback", async () => { + await using tmp = await tmpdir() + const cfgPath = path.join(tmp.path, "pawwork.json") + await fs.writeFile(cfgPath, JSON.stringify({ default_agent: "plan", model: "x/y" }, null, 2), "utf8") + const tmpPath = cfgPath + ".migrate.tmp" + await fs.mkdir(tmpPath, { recursive: true }) + + const { logger, calls } = makeMockLogger() + const res = await migrateDefaultAgent(cfgPath, { logger }) + + expect(res.rewritten).toBe(false) + expect(res.sanitizedText).toBeDefined() + expect(res.sanitizedText).not.toContain("default_agent") + expect(_hasFailedPath(cfgPath)).toBe(true) + expect(calls.some((c) => c.level === "warn" && c.message.includes("could not rewrite"))).toBe(true) + + await fs.rmdir(tmpPath) +}) + +test("migrateDefaultAgent: preserves original file mode (0600 stays 0600)", async () => { + await using tmp = await tmpdir() + const cfgPath = path.join(tmp.path, "pawwork.json") + await fs.writeFile(cfgPath, JSON.stringify({ default_agent: "plan", apiKey: "sk-secret" }, null, 2), "utf8") + await fs.chmod(cfgPath, 0o600) + + const { logger } = makeMockLogger() + const res = await migrateDefaultAgent(cfgPath, { logger }) + + expect(res.rewritten).toBe(true) + const after = await fs.stat(cfgPath) + // mask off non-permission bits and assert exact mode preserved + expect(after.mode & 0o777).toBe(0o600) +}) + +test("migrateDefaultAgent: subsequent call after failed first still returns sanitizedText", async () => { + await using tmp = await tmpdir() + const cfgPath = path.join(tmp.path, "pawwork.json") + await fs.writeFile(cfgPath, JSON.stringify({ default_agent: "plan" }, null, 2), "utf8") + const tmpPath = cfgPath + ".migrate.tmp" + await fs.mkdir(tmpPath, { recursive: true }) + + const { logger: logger1 } = makeMockLogger() + const first = await migrateDefaultAgent(cfgPath, { logger: logger1 }) + expect(first.rewritten).toBe(false) + expect(first.sanitizedText).toBeDefined() + + await fs.rmdir(tmpPath) + + const { logger: logger2, calls: calls2 } = makeMockLogger() + const second = await migrateDefaultAgent(cfgPath, { logger: logger2 }) + expect(second.rewritten).toBe(false) + expect(second.sanitizedText).toBeDefined() + expect(second.sanitizedText).not.toContain("default_agent") + const afterDisk = await fs.readFile(cfgPath, "utf8") + expect(afterDisk).toContain("default_agent") + expect(calls2.some((c) => c.level === "debug" && c.message.includes("skipping disk rewrite"))).toBe(true) +}) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 26401e645..c0157f092 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -218,7 +218,7 @@ describe("tool.read env file permissions", () => { ["environment.ts", false], ] - for (const agentName of ["build", "plan"] as const) { + for (const agentName of ["build"] as const) { describe(`agent=${agentName}`, () => { for (const [filename, shouldAsk] of cases) { it.live(`${filename} asks=${shouldAsk}`, () => diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 8d8ebb61b..e6032f025 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -16,7 +16,6 @@ import { createStore } from "solid-js/store" import stripAnsi from "strip-ansi" import { Dynamic } from "solid-js/web" import { - AgentPart, AssistantMessage, FilePart, Message as MessageType, @@ -1028,8 +1027,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp const inlineFiles = createMemo(() => files().filter(inline)) - const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) - const model = createMemo(() => { const providerID = props.message.model?.providerID const modelID = props.message.model?.modelID @@ -1117,7 +1114,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp <>
- +
@@ -1180,19 +1177,18 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp ) } -type HighlightSegment = { text: string; type?: "file" | "agent" } +type HighlightSegment = { text: string; type?: "file" } -function HighlightedText(props: { text: string; references: FilePart[]; agents: AgentPart[] }) { +function HighlightedText(props: { text: string; references: FilePart[] }) { + // PawWork issue #239: `agents` prop removed. Past AgentPart mentions render as + // plain text (no styled pill) because the picker concept is gone. const segments = createMemo(() => { const text = props.text - const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [ + const allRefs: { start: number; end: number; type: "file" }[] = [ ...props.references .filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined) .map((r) => ({ start: r.source!.text!.start, end: r.source!.text!.end, type: "file" as const })), - ...props.agents - .filter((a) => a.source?.start !== undefined && a.source?.end !== undefined) - .map((a) => ({ start: a.source!.start, end: a.source!.end, type: "agent" as const })), ].sort((a, b) => a.start - b.start) const result: HighlightSegment[] = []