diff options
| author | Dax <[email protected]> | 2026-01-13 15:55:48 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-01-13 15:55:48 -0500 |
| commit | 0a3c72d6787aa3cf39b9517e32f0ad5d8dbb6184 (patch) | |
| tree | 3f000f847c8e72a875682cf715a8778080b85d4c /packages | |
| parent | 66b7a4991ee5903d0239c0d7b98c95b9c5f9e43c (diff) | |
| download | opencode-0a3c72d6787aa3cf39b9517e32f0ad5d8dbb6184.tar.gz opencode-0a3c72d6787aa3cf39b9517e32f0ad5d8dbb6184.zip | |
feat: add plan mode with enter/exit tools (#8281)
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/opencode/src/agent/agent.ts | 6 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 17 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/routes/session/question.tsx | 61 | ||||
| -rw-r--r-- | packages/opencode/src/flag/flag.ts | 1 | ||||
| -rw-r--r-- | packages/opencode/src/question/index.ts | 1 | ||||
| -rw-r--r-- | packages/opencode/src/session/index.ts | 9 | ||||
| -rw-r--r-- | packages/opencode/src/session/prompt.ts | 143 | ||||
| -rw-r--r-- | packages/opencode/src/session/prompt/build-switch.txt | 2 | ||||
| -rw-r--r-- | packages/opencode/src/tool/plan-enter.txt | 14 | ||||
| -rw-r--r-- | packages/opencode/src/tool/plan-exit.txt | 13 | ||||
| -rw-r--r-- | packages/opencode/src/tool/plan.ts | 130 | ||||
| -rw-r--r-- | packages/opencode/src/tool/registry.ts | 2 | ||||
| -rw-r--r-- | packages/opencode/test/util/lock.test.ts | 72 | ||||
| -rw-r--r-- | packages/sdk/js/src/v2/gen/types.gen.ts | 5 | ||||
| -rw-r--r-- | packages/util/src/slug.ts | 74 |
15 files changed, 504 insertions, 46 deletions
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ea9d3e3ba..6847d29ab 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -53,6 +53,8 @@ export namespace Agent { [Truncate.GLOB]: "allow", }, question: "deny", + plan_enter: "deny", + plan_exit: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", @@ -71,6 +73,7 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", + plan_enter: "allow", }), user, ), @@ -84,9 +87,10 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", + plan_exit: "allow", edit: { "*": "deny", - ".opencode/plan/*.md": "allow", + ".opencode/plans/*.md": "allow", }, }), user, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f87b811ae..b6916bc5a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -196,6 +196,23 @@ export function Session() { } }) + let lastSwitch: string | undefined = undefined + sdk.event.on("message.part.updated", (evt) => { + const part = evt.properties.part + if (part.type !== "tool") return + if (part.sessionID !== route.sessionID) return + if (part.state.status !== "completed") return + if (part.id === lastSwitch) return + + if (part.tool === "plan_exit") { + local.agent.set("build") + lastSwitch = part.id + } else if (part.tool === "plan_enter") { + local.agent.set("plan") + lastSwitch = part.id + } + }) + let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index ccc0e9b12..5e8ce2380 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -32,7 +32,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const question = createMemo(() => questions()[store.tab]) const confirm = createMemo(() => !single() && store.tab === questions().length) const options = createMemo(() => question()?.options ?? []) - const other = createMemo(() => store.selected === options().length) + const custom = createMemo(() => question()?.custom !== false) + const other = createMemo(() => custom() && store.selected === options().length) const input = createMemo(() => store.custom[store.tab] ?? "") const multi = createMemo(() => question()?.multiple === true) const customPicked = createMemo(() => { @@ -203,7 +204,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { } } else { const opts = options() - const total = opts.length + 1 // options + "Other" + const total = opts.length + (custom() ? 1 : 0) if (evt.name === "up" || evt.name === "k") { evt.preventDefault() @@ -298,35 +299,37 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { ) }} </For> - <box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}> - <box flexDirection="row" gap={1}> - <box backgroundColor={other() ? theme.backgroundElement : undefined}> - <text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}> - {options().length + 1}. Type your own answer - </text> + <Show when={custom()}> + <box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}> + <box flexDirection="row" gap={1}> + <box backgroundColor={other() ? theme.backgroundElement : undefined}> + <text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}> + {options().length + 1}. Type your own answer + </text> + </box> + <text fg={theme.success}>{customPicked() ? "✓" : ""}</text> </box> - <text fg={theme.success}>{customPicked() ? "✓" : ""}</text> + <Show when={store.editing}> + <box paddingLeft={3}> + <textarea + ref={(val: TextareaRenderable) => (textarea = val)} + focused + initialValue={input()} + placeholder="Type your own answer" + textColor={theme.text} + focusedTextColor={theme.text} + cursorColor={theme.primary} + keyBindings={bindings()} + /> + </box> + </Show> + <Show when={!store.editing && input()}> + <box paddingLeft={3}> + <text fg={theme.textMuted}>{input()}</text> + </box> + </Show> </box> - <Show when={store.editing}> - <box paddingLeft={3}> - <textarea - ref={(val: TextareaRenderable) => (textarea = val)} - focused - initialValue={input()} - placeholder="Type your own answer" - textColor={theme.text} - focusedTextColor={theme.text} - cursorColor={theme.primary} - keyBindings={bindings()} - /> - </box> - </Show> - <Show when={!store.editing && input()}> - <box paddingLeft={3}> - <text fg={theme.textMuted}>{input()}</text> - </box> - </Show> - </box> + </Show> </box> </box> </Show> diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index ad6052dec..4cdb54909 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -38,6 +38,7 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT") export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY") export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") + export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") function truthy(key: string) { const value = process.env[key]?.toLowerCase() diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 23656b53a..d18098a9c 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -24,6 +24,7 @@ export namespace Question { header: z.string().max(12).describe("Very short label (max 12 chars)"), options: z.array(Option).describe("Available choices"), multiple: z.boolean().optional().describe("Allow selecting multiple choices"), + custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), }) .meta({ ref: "QuestionInfo", diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index a204913f7..2bb9fc5f8 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,3 +1,5 @@ +import { Slug } from "@opencode-ai/util/slug" +import pat from "path" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" @@ -19,6 +21,7 @@ import { Snapshot } from "@/snapshot" import type { Provider } from "@/provider/provider" import { PermissionNext } from "@/permission/next" +import path from "path" export namespace Session { const log = Log.create({ service: "session" }) @@ -39,6 +42,7 @@ export namespace Session { export const Info = z .object({ id: Identifier.schema("session"), + slug: z.string(), projectID: z.string(), directory: z.string(), parentID: Identifier.schema("session").optional(), @@ -194,6 +198,7 @@ export namespace Session { }) { const result: Info = { id: Identifier.descending("session", input.id), + slug: Slug.create(), version: Installation.VERSION, projectID: Instance.project.id, directory: input.directory, @@ -227,6 +232,10 @@ export namespace Session { return result } + export function plan(input: { slug: string; time: { created: number } }) { + return path.join(Instance.worktree, ".opencode", "plans", [input.time.created, input.slug].join("-") + ".md") + } + export const get = fn(Identifier.schema("session"), async (id) => { const read = await Storage.read<Info>(["session", Instance.project.id, id]) return read as Info diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 34596e629..5036d3a69 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -510,9 +510,10 @@ export namespace SessionPrompt { const agent = await Agent.get(lastUser.agent) const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps - msgs = insertReminders({ + msgs = await insertReminders({ messages: msgs, agent, + session, }) const processor = SessionProcessor.create({ @@ -1185,30 +1186,140 @@ export namespace SessionPrompt { } } - function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info }) { + async function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info; session: Session.Info }) { const userMessage = input.messages.findLast((msg) => msg.info.role === "user") if (!userMessage) return input.messages - if (input.agent.name === "plan") { - userMessage.parts.push({ - id: Identifier.ascending("part"), - messageID: userMessage.info.id, - sessionID: userMessage.info.sessionID, - type: "text", - // TODO (for mr dax): update to use the anthropic full fledged one (see plan-reminder-anthropic.txt) - text: PROMPT_PLAN, - synthetic: true, - }) + + // Original logic when experimental plan mode is disabled + if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) { + if (input.agent.name === "plan") { + userMessage.parts.push({ + id: Identifier.ascending("part"), + messageID: userMessage.info.id, + sessionID: userMessage.info.sessionID, + type: "text", + text: PROMPT_PLAN, + synthetic: true, + }) + } + const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan") + if (wasPlan && input.agent.name === "build") { + userMessage.parts.push({ + id: Identifier.ascending("part"), + messageID: userMessage.info.id, + sessionID: userMessage.info.sessionID, + type: "text", + text: BUILD_SWITCH, + synthetic: true, + }) + } + return input.messages } - const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan") - if (wasPlan && input.agent.name === "build") { - userMessage.parts.push({ + + // New plan mode logic when flag is enabled + const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant") + + // Switching from plan mode to build mode + if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") { + const plan = Session.plan(input.session) + const exists = await Bun.file(plan).exists() + if (exists) { + const part = await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMessage.info.id, + sessionID: userMessage.info.sessionID, + type: "text", + text: BUILD_SWITCH.replace("{{plan}}", plan), + synthetic: true, + }) + userMessage.parts.push(part) + } + } + + // Entering plan mode + if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") { + const plan = Session.plan(input.session) + const exists = await Bun.file(plan).exists() + if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true }) + const part = await Session.updatePart({ id: Identifier.ascending("part"), messageID: userMessage.info.id, sessionID: userMessage.info.sessionID, type: "text", - text: BUILD_SWITCH, + text: `<system-reminder> +Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received. + +## Plan File Info: +${exists ? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.` : `No plan file exists yet. You should create your plan at ${plan} using the write tool.`} +You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions. + +## Plan Workflow + +### Phase 1: Initial Understanding +Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the explore subagent type. + +1. Focus on understanding the user's request and the code associated with their request + +2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase. + - Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change. + - Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning. + - Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1) + - If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns + +3. After exploring the code, use the question tool to clarify ambiguities in the user request up front. + +### Phase 2: Design +Goal: Design an implementation approach. + +Launch general agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1. + +You can launch up to 1 agent(s) in parallel. + +**Guidelines:** +- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives +- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames) + +Examples of when to use multiple agents: +- The task touches multiple parts of the codebase +- It's a large refactor or architectural change +- There are many edge cases to consider +- You'd benefit from exploring different approaches + +Example perspectives by task type: +- New feature: simplicity vs performance vs maintainability +- Bug fix: root cause vs workaround vs prevention +- Refactoring: minimal change vs clean architecture + +In the agent prompt: +- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces +- Describe requirements and constraints +- Request a detailed implementation plan + +### Phase 3: Review +Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions. +1. Read the critical files identified by agents to deepen your understanding +2. Ensure that the plans align with the user's original request +3. Use question tool to clarify any remaining questions with the user + +### Phase 4: Final Plan +Goal: Write your final plan to the plan file (the only file you can edit). +- Include only your recommended approach, not all alternatives +- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively +- Include the paths of critical files to be modified +- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests) + +### Phase 5: Call plan_exit tool +At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning. +This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons. + +**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does. + +NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins. +</system-reminder>`, synthetic: true, }) + userMessage.parts.push(part) + return input.messages } return input.messages } diff --git a/packages/opencode/src/session/prompt/build-switch.txt b/packages/opencode/src/session/prompt/build-switch.txt index 3737b74d8..40b39b95b 100644 --- a/packages/opencode/src/session/prompt/build-switch.txt +++ b/packages/opencode/src/session/prompt/build-switch.txt @@ -2,4 +2,6 @@ Your operational mode has changed from plan to build. You are no longer in read-only mode. You are permitted to make file changes, run shell commands, and utilize your arsenal of tools as needed. + +A plan file exists at {{plan}}. You should read this file and execute on the plan defined within it. </system-reminder> diff --git a/packages/opencode/src/tool/plan-enter.txt b/packages/opencode/src/tool/plan-enter.txt new file mode 100644 index 000000000..2e6a69f1f --- /dev/null +++ b/packages/opencode/src/tool/plan-enter.txt @@ -0,0 +1,14 @@ +Use this tool to suggest switching to plan agent when the user's request would benefit from planning before implementation. + +If they explicitly mention wanting to create a plan ALWAYS call this tool first. + +This tool will ask the user if they want to switch to plan agent. + +Call this tool when: +- The user's request is complex and would benefit from planning first +- You want to research and design before making changes +- The task involves multiple files or significant architectural decisions + +Do NOT call this tool: +- For simple, straightforward tasks +- When the user explicitly wants immediate implementation diff --git a/packages/opencode/src/tool/plan-exit.txt b/packages/opencode/src/tool/plan-exit.txt new file mode 100644 index 000000000..988821de3 --- /dev/null +++ b/packages/opencode/src/tool/plan-exit.txt @@ -0,0 +1,13 @@ +Use this tool when you have completed the planning phase and are ready to exit plan agent. + +This tool will ask the user if they want to switch to build agent to start implementing the plan. + +Call this tool: +- After you have written a complete plan to the plan file +- After you have clarified any questions with the user +- When you are confident the plan is ready for implementation + +Do NOT call this tool: +- Before you have created or finalized the plan +- If you still have unanswered questions about the implementation +- If the user has indicated they want to continue planning diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts new file mode 100644 index 000000000..6cb7a691c --- /dev/null +++ b/packages/opencode/src/tool/plan.ts @@ -0,0 +1,130 @@ +import z from "zod" +import path from "path" +import { Tool } from "./tool" +import { Question } from "../question" +import { Session } from "../session" +import { MessageV2 } from "../session/message-v2" +import { Identifier } from "../id/id" +import { Provider } from "../provider/provider" +import { Instance } from "../project/instance" +import EXIT_DESCRIPTION from "./plan-exit.txt" +import ENTER_DESCRIPTION from "./plan-enter.txt" + +async function getLastModel(sessionID: string) { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.model) return item.info.model + } + return Provider.defaultModel() +} + +export const PlanExitTool = Tool.define("plan_exit", { + description: EXIT_DESCRIPTION, + parameters: z.object({}), + async execute(_params, ctx) { + const session = await Session.get(ctx.sessionID) + const plan = path.relative(Instance.worktree, Session.plan(session)) + const answers = await Question.ask({ + sessionID: ctx.sessionID, + questions: [ + { + question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`, + header: "Build Agent", + custom: false, + options: [ + { label: "Yes", description: "Switch to build agent and start implementing the plan" }, + { label: "No", description: "Stay with plan agent to continue refining the plan" }, + ], + }, + ], + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + + const answer = answers[0]?.[0] + if (answer === "No") throw new Question.RejectedError() + + const model = await getLastModel(ctx.sessionID) + + const userMsg: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID: ctx.sessionID, + role: "user", + time: { + created: Date.now(), + }, + agent: "build", + model, + } + await Session.updateMessage(userMsg) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: ctx.sessionID, + type: "text", + text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`, + synthetic: true, + } satisfies MessageV2.TextPart) + + return { + title: "Switching to build agent", + output: "User approved switching to build agent. Wait for further instructions.", + metadata: {}, + } + }, +}) + +export const PlanEnterTool = Tool.define("plan_enter", { + description: ENTER_DESCRIPTION, + parameters: z.object({}), + async execute(_params, ctx) { + const session = await Session.get(ctx.sessionID) + const plan = path.relative(Instance.worktree, Session.plan(session)) + + const answers = await Question.ask({ + sessionID: ctx.sessionID, + questions: [ + { + question: `Would you like to switch to the plan agent and create a plan saved to ${plan}?`, + header: "Plan Mode", + custom: false, + options: [ + { label: "Yes", description: "Switch to plan agent for research and planning" }, + { label: "No", description: "Stay with build agent to continue making changes" }, + ], + }, + ], + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + + const answer = answers[0]?.[0] + + if (answer === "No") throw new Question.RejectedError() + + const model = await getLastModel(ctx.sessionID) + + const userMsg: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID: ctx.sessionID, + role: "user", + time: { + created: Date.now(), + }, + agent: "plan", + model, + } + await Session.updateMessage(userMsg) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: ctx.sessionID, + type: "text", + text: "User has requested to enter plan mode. Switch to plan mode and begin planning.", + synthetic: true, + } satisfies MessageV2.TextPart) + + return { + title: "Switching to plan agent", + output: `User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. The plan file will be at ${plan}. Begin planning.`, + metadata: {}, + } + }, +}) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 24faed7f0..35e378f08 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -25,6 +25,7 @@ import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { LspTool } from "./lsp" import { Truncate } from "./truncation" +import { PlanExitTool, PlanEnterTool } from "./plan" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -109,6 +110,7 @@ export namespace ToolRegistry { SkillTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []), ...custom, ] } diff --git a/packages/opencode/test/util/lock.test.ts b/packages/opencode/test/util/lock.test.ts new file mode 100644 index 000000000..b877311e3 --- /dev/null +++ b/packages/opencode/test/util/lock.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test" +import { Lock } from "../../src/util/lock" + +function tick() { + return new Promise<void>((r) => queueMicrotask(r)) +} + +async function flush(n = 5) { + for (let i = 0; i < n; i++) await tick() +} + +describe("util.lock", () => { + test("writer exclusivity: blocks reads and other writes while held", async () => { + const key = "lock:" + Math.random().toString(36).slice(2) + + const state = { + writer2: false, + reader: false, + writers: 0, + } + + // Acquire writer1 + using writer1 = await Lock.write(key) + state.writers++ + expect(state.writers).toBe(1) + + // Start writer2 candidate (should block) + const writer2Task = (async () => { + const w = await Lock.write(key) + state.writers++ + expect(state.writers).toBe(1) + state.writer2 = true + // Hold for a tick so reader cannot slip in + await tick() + return w + })() + + // Start reader candidate (should block) + const readerTask = (async () => { + const r = await Lock.read(key) + state.reader = true + return r + })() + + // Flush microtasks and assert neither acquired + await flush() + expect(state.writer2).toBe(false) + expect(state.reader).toBe(false) + + // Release writer1 + writer1[Symbol.dispose]() + state.writers-- + + // writer2 should acquire next + const writer2 = await writer2Task + expect(state.writer2).toBe(true) + + // Reader still blocked while writer2 held + await flush() + expect(state.reader).toBe(false) + + // Release writer2 + writer2[Symbol.dispose]() + state.writers-- + + // Reader should now acquire + const reader = await readerTask + expect(state.reader).toBe(true) + + reader[Symbol.dispose]() + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e423fecea..acc29d9b4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -545,6 +545,10 @@ export type QuestionInfo = { * Allow selecting multiple choices */ multiple?: boolean + /** + * Allow typing a custom answer (default: true) + */ + custom?: boolean } export type QuestionRequest = { @@ -706,6 +710,7 @@ export type PermissionRuleset = Array<PermissionRule> export type Session = { id: string + slug: string projectID: string directory: string parentID?: string diff --git a/packages/util/src/slug.ts b/packages/util/src/slug.ts new file mode 100644 index 000000000..62cf0e57b --- /dev/null +++ b/packages/util/src/slug.ts @@ -0,0 +1,74 @@ +export namespace Slug { + const ADJECTIVES = [ + "brave", + "calm", + "clever", + "cosmic", + "crisp", + "curious", + "eager", + "gentle", + "glowing", + "happy", + "hidden", + "jolly", + "kind", + "lucky", + "mighty", + "misty", + "neon", + "nimble", + "playful", + "proud", + "quick", + "quiet", + "shiny", + "silent", + "stellar", + "sunny", + "swift", + "tidy", + "witty", + ] as const + + const NOUNS = [ + "cabin", + "cactus", + "canyon", + "circuit", + "comet", + "eagle", + "engine", + "falcon", + "forest", + "garden", + "harbor", + "island", + "knight", + "lagoon", + "meadow", + "moon", + "mountain", + "nebula", + "orchid", + "otter", + "panda", + "pixel", + "planet", + "river", + "rocket", + "sailor", + "squid", + "star", + "tiger", + "wizard", + "wolf", + ] as const + + export function create() { + return [ + ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)], + NOUNS[Math.floor(Math.random() * NOUNS.length)], + ].join("-") + } +} |
