diff --git a/.opencode/command/auto-drive.md b/.opencode/command/auto-drive.md new file mode 100644 index 000000000000..01766cc40876 --- /dev/null +++ b/.opencode/command/auto-drive.md @@ -0,0 +1,9 @@ +--- +description: autonomous Auto-Drive continuous execution mode +--- + +Please execute the following task with Auto-Drive enabled. +Continuously and autonomously execute all necessary steps, code modifications, and test verifications until the entire task is 100% finished without stopping mid-way for confirmation. + +Task: +$ARGUMENTS diff --git a/.opencode/command/autodrive.md b/.opencode/command/autodrive.md new file mode 100644 index 000000000000..01766cc40876 --- /dev/null +++ b/.opencode/command/autodrive.md @@ -0,0 +1,9 @@ +--- +description: autonomous Auto-Drive continuous execution mode +--- + +Please execute the following task with Auto-Drive enabled. +Continuously and autonomously execute all necessary steps, code modifications, and test verifications until the entire task is 100% finished without stopping mid-way for confirmation. + +Task: +$ARGUMENTS diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 481b7922cf36..70a7b040e148 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -24,6 +24,7 @@ import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { useSettings } from "@/context/settings" import { createSessionTabs } from "@/pages/session/helpers" import { showToast } from "@/utils/toast" import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" @@ -58,6 +59,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { variantControlVisible={!props.controller.model.loading} attachKeybind={command.keybindParts("file.attach")} attachShortcut={command.keybind("file.attach")} + extraControl={} modelControl={ settings.general.autoDrive() + + return ( + + settings.general.setAutoDrive(!enabled())} + > + + 自动领航 + + + + + ) +} + export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { const sdk = useSDK() const sync = useSync() diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 8adaa1f0fdf9..72ab69faeee4 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -347,6 +347,35 @@ export const SettingsGeneral: Component = () => { /> + +
+ settings.general.setAutoDrive(checked)} + /> +
+
+ + + +
+ settings.general.setAutoDrivePrompt(value)} + placeholder="请继续执行下一步计划。" + variant="normal" + size="small" + /> +
+
+
+ + +
+ settings.general.setAutoDrive(checked)} + /> +
+
+ + + +
+ settings.general.setAutoDrivePrompt(event.currentTarget.value)} + placeholder="请继续执行下一步计划。" + appearance="base" + /> +
+
+
+ { - if (store.general?.followup !== "queue") return - setStore("general", "followup", "steer") - }) - return { ready, get current() { @@ -369,12 +370,21 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setReleaseNotes(value: boolean) { setStore("general", "releaseNotes", value) }, - followup: withFallback( - () => (store.general?.followup === "queue" ? "steer" : store.general?.followup), - defaultSettings.general.followup, - ), + followup: withFallback(() => store.general?.followup, defaultSettings.general.followup), setFollowup(value: "queue" | "steer") { - setStore("general", "followup", value === "queue" ? "steer" : value) + setStore("general", "followup", value) + }, + autoDrive: withFallback(() => store.general?.autoDrive, defaultSettings.general.autoDrive), + setAutoDrive(value: boolean) { + setStore("general", "autoDrive", value) + }, + autoDrivePrompt: withFallback(() => store.general?.autoDrivePrompt, defaultSettings.general.autoDrivePrompt), + setAutoDrivePrompt(value: string) { + setStore("general", "autoDrivePrompt", value) + }, + autoDriveMaxRuns: withFallback(() => store.general?.autoDriveMaxRuns, defaultSettings.general.autoDriveMaxRuns), + setAutoDriveMaxRuns(value: number) { + setStore("general", "autoDriveMaxRuns", value) }, showFileTree, setShowFileTree(value: boolean) { diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 1df1338b71e5..2a374c5e5e96 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -235,7 +235,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi { timeCreated: Date.now(), type: "user", data: { text: value.text }, - delivery: value.delivery ?? "steer", + delivery: value.delivery ?? "queue", } }, async command(value: SessionCommandInput) { @@ -261,7 +261,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi { timeCreated: Date.now(), type: "user", data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() }, - delivery: value.delivery ?? "steer", + delivery: value.delivery ?? "queue", } }, async shell(value: SessionShellInput & LegacyPrompt) { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c76486968b07..a1f8a04e3478 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -102,6 +102,21 @@ export class Info extends Schema.Class("Config.Info")({ plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ description: "Ordered external plugin packages to load", }), + auto_drive: Schema.Union([ + Schema.Boolean, + Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.optional), + prompt: Schema.String.pipe(Schema.optional), + max_runs: Schema.Number.pipe(Schema.optional), + supervisor: Schema.Boolean.pipe(Schema.optional), + memory: Schema.Boolean.pipe(Schema.optional), + contextual: Schema.Boolean.pipe(Schema.optional), + }), + ]) + .pipe(Schema.optional) + .annotate({ + description: "Auto-Drive automatic continuation configuration", + }), experimental: ConfigExperimental.Experimental.pipe(Schema.optional), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), }) {} diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index cbafd68b5025..86c3b17760ea 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -20,6 +20,16 @@ export const Plugin = define({ command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.subtask = true }) + draft.update("autodrive", (command) => { + command.template = + "Please execute the following task with Auto-Drive enabled. Continuously execute subsequent steps, code modifications, and test verifications until fully finished without stopping mid-way for confirmation.\n\nTask:\n$ARGUMENTS" + command.description = "autonomous Auto-Drive continuous execution mode" + }) + draft.update("auto-drive", (command) => { + command.template = + "Please execute the following task with Auto-Drive enabled. Continuously execute subsequent steps, code modifications, and test verifications until fully finished without stopping mid-way for confirmation.\n\nTask:\n$ARGUMENTS" + command.description = "autonomous Auto-Drive continuous execution mode" + }) }) }), }) diff --git a/packages/core/src/session/auto-drive.ts b/packages/core/src/session/auto-drive.ts new file mode 100644 index 000000000000..9ae98bb7e464 --- /dev/null +++ b/packages/core/src/session/auto-drive.ts @@ -0,0 +1,172 @@ +export * as AutoDrive from "./auto-drive" + +export const DEFAULT_MAX_RUNS = 5 +export const DEFAULT_PROMPT = "请继续执行下一步计划。" +export const MEMORY_RELATIVE_PATH = ".opencode/auto-drive.md" + +export interface Context { + readonly initialGoal?: string + readonly lastText: string + readonly playbookMarkdown?: string + readonly customPrompt?: string + readonly contextual?: boolean +} + +export interface Decision { + readonly continue: boolean + readonly reason?: string + readonly nextPrompt: string + readonly updateMemory?: string +} + +// Continuation detection patterns (evaluated on the trailing chunk / concluding text of the assistant message) +const CONTINUATION_PATTERNS = [ + // Chinese questions asking to continue + /(?:是否|要不要|需要|如需|请确认是否)(?:继续|进行下一步|执行下一步|推进|开始下一步)/i, + /(?:回复|输入|发送)["'“‘]?(?:继续|下一步)["'”’]?/i, + // Chinese next steps statements + /(?:下一步|后续)(?:计划|步骤|工作|安排|任务|操作)[::]/i, + /(?:接下来|下一步|随后|紧接着)(?:我将|我们将|准备|会|需要|打算)(?:继续|开始|执行|实现|修改|创建|添加|测试|排查|编写|完善)/i, + // English questions asking to continue + /(?:would you like|do you want|should I|shall I)(?:\s+me)?\s+to\s+(?:continue|proceed|move on|take the next step|start)/i, + /let me know if you(?:'d| would)? like (?:me )?to (?:continue|proceed)/i, + /please (?:reply|type|say|send)\s+["']?continue["']?/i, + // English next steps statements + /(?:^|\n)\s*(?:next steps?|upcoming steps?|remaining tasks?)[::]/i, + /(?:next|then|now),?\s+I (?:will|shall|can|am ready to) (?:proceed to|continue with|start|implement|create|update|add|test|fix|run)/i, + /ready to (?:proceed with|continue with|start|move to) (?:the next step|the implementation|the next phase)/i, + // Maximum steps reached notices + /maximum(?: number of)? steps(?: allowed)?.*reached/i, + /达到(?:最大)?步数限制/i, +] + +// Patterns that indicate user decision / multiple choice is needed (should NOT blindly auto-continue) +const CHOICE_PROMPT_PATTERNS = [ + /(?:请选择|你希望|你想要|哪种方案|哪个选项|请确认以下方案)/i, + /(?:which (?:option|approach|alternative|one)|please choose|please select|let me know which)/i, +] + +// Completion patterns that indicate everything is finished +const COMPLETION_PATTERNS = [ + /(?:所有任务已全部完成|已完成全部工作|全部实施完毕|已顺利完成|任务全部完成)/i, + /(?:all tasks (?:are )?completed|everything is (?:done|complete)|all steps have been completed)/i, +] + +export const detect = (text: string): boolean => { + if (!text || text.trim().length === 0) return false + + const trimmed = text.trim() + const tail = trimmed.length > 1500 ? trimmed.slice(-1500) : trimmed + + // If asking user to choose between specific options, do not auto-drive + if (CHOICE_PROMPT_PATTERNS.some((pattern) => pattern.test(tail))) { + return false + } + + // If explicitly stated that all tasks are finished and no continuation requested + if ( + COMPLETION_PATTERNS.some((pattern) => pattern.test(tail)) && + !/(?:是否继续|继续|continue|proceed)/i.test(tail) + ) { + return false + } + + return CONTINUATION_PATTERNS.some((pattern) => pattern.test(tail)) +} + +export const promptFor = (input: Context | string): string => { + if (typeof input === "string") return input.trim().length > 0 ? input : DEFAULT_PROMPT + if (input.customPrompt && input.customPrompt.trim().length > 0) return input.customPrompt + if (input.contextual && input.initialGoal && input.initialGoal.trim().length > 0) { + const snippet = input.initialGoal.trim().slice(0, 150).replace(/\n/g, " ") + return `【自动领航指令】请紧扣初始任务目标「${snippet}」,结合当前工作进展与下一步安排,继续自主推进执行。` + } + return DEFAULT_PROMPT +} + +export const buildSupervisorPrompt = (context: Context): string => { + const goal = context.initialGoal?.trim() || "(用户尚未指定明确初始目标)" + const memory = context.playbookMarkdown?.trim() || "(暂无沉淀的策略记忆)" + const lastExcerpt = context.lastText.trim().slice(-2000) + + return `You are the Auto-Drive Supervisor for an autonomous software engineering session. +A primary worker agent just completed its current turn. +Analyze whether the worker has remaining tasks, next steps, or unverified work, or if it should stop. + + +${goal} + + + +${memory} + + + +${lastExcerpt} + + +Decision Rules: +1. "continue": true if the worker explicitly stated upcoming steps, requested confirmation to proceed, or has obvious unfinished milestones towards the initial goal. +2. "continue": false if the entire user goal is completely achieved and verified, or if the worker requires a subjective decision/choice from the human user. +3. "next_prompt": If continuing, provide a concise, actionable instruction in Chinese directing the worker on what exact step to take next. If custom prompt is provided, incorporate it. +4. "update_memory": Optional. If the worker achieved a milestone or clarified a new rule/checklist, return an updated summary section for the memory file. Otherwise omit or null. + +Respond ONLY with a valid JSON object matching this schema: +{ + "continue": boolean, + "reason": string, + "next_prompt": string, + "update_memory": string | null +}` +} + +export const parseSupervisorDecision = (raw: string, context: Context): Decision => { + try { + const jsonMatch = raw.match(/\{[\s\S]*\}/) + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]) + if (typeof parsed.continue === "boolean") { + return { + continue: parsed.continue, + reason: typeof parsed.reason === "string" ? parsed.reason : undefined, + nextPrompt: + typeof parsed.next_prompt === "string" && parsed.next_prompt.trim().length > 0 + ? parsed.next_prompt + : promptFor(context), + updateMemory: + typeof parsed.update_memory === "string" && parsed.update_memory.trim().length > 0 + ? parsed.update_memory + : undefined, + } + } + } + } catch { + // Fall back to heuristic detection + } + + const shouldContinue = detect(context.lastText) + return { + continue: shouldContinue, + reason: shouldContinue ? "Heuristic continuation detected" : "No continuation cues found", + nextPrompt: promptFor(context), + } +} + +export const defaultPlaybookTemplate = (projectName?: string): string => { + const name = projectName || "Project" + return `# ${name} Auto-Drive Playbook & Memory + +## 1. 核心目标与工程规范 +* 自动化持续推进任务,确保代码改动具备完备性与自测验证。 +* 遇到构建或测试报错优先自我分析并修复。 +* 保持精简提交,重要变更记录于对应文档。 + +## 2. 活跃工作流与任务清单 (Active Roadmap) +- [ ] 初始任务阶段 +- [ ] 核心逻辑实现 +- [ ] 自动化测试与验证 + +## 3. 经验与偏好沉淀 (Learned Patterns) +- 遵循现行代码库风格与规范。 +` +} diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 874086a06bdb..742af6d99b0c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -29,6 +29,7 @@ import { SessionCompaction } from "../compaction" import { SessionEvent } from "../event" import { SessionHistory } from "../history" import { SessionInput } from "../input" +import { SessionMessage } from "../message" import { SessionSchema } from "../schema" import { SessionStore } from "../store" import { type RunError, Service } from "./index" @@ -36,9 +37,15 @@ import { SessionRunnerModel } from "./model" import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" import { MAX_STEPS_PROMPT } from "./max-steps" +import { AutoDrive } from "../auto-drive" +import { Prompt } from "../prompt" +import { SessionMessageTable } from "../sql" +import { and, asc, desc, eq } from "drizzle-orm" import { Snapshot } from "../../snapshot" import { makeLocationNode } from "../../effect/app-node" import { llmClient } from "../../effect/app-node-platform" +import path from "path" +import fs from "fs/promises" /** * Runs one durable coding-agent Session until it settles. @@ -387,6 +394,101 @@ const layer = Layer.effect( ) }) + const getLastAssistantText = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) { + const row = yield* db + .select({ data: SessionMessageTable.data }) + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant"))) + .orderBy(desc(SessionMessageTable.seq)) + .limit(1) + .get() + .pipe(Effect.orDie) + const data = row?.data as { finish?: string; content?: Array<{ type: string; text?: string }> } | undefined + if (!data || (data.finish && data.finish !== "stop")) return undefined + const content = data.content + if (!content) return undefined + return content + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n") + }) + + const getInceptionUserPrompt = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) { + const row = yield* db + .select({ data: SessionMessageTable.data }) + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user"))) + .orderBy(asc(SessionMessageTable.seq)) + .limit(1) + .get() + .pipe(Effect.orDie) + const content = (row?.data as { content?: Array<{ type: string; text?: string }> })?.content + if (!content) return undefined + return content + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n") + }) + + const getPlaybook = Effect.fnUntraced(function* (projectDir: string) { + const memoryPath = path.join(projectDir, AutoDrive.MEMORY_RELATIVE_PATH) + const content = yield* Effect.promise(async () => { + try { + const file = Bun.file(memoryPath) + if (!(await file.exists())) return undefined + return await file.text() + } catch { + return undefined + } + }) + return content + }) + + const savePlaybook = Effect.fnUntraced(function* (projectDir: string, content: string) { + const memoryPath = path.join(projectDir, AutoDrive.MEMORY_RELATIVE_PATH) + yield* Effect.promise(async () => { + try { + await fs.mkdir(path.dirname(memoryPath), { recursive: true }) + await Bun.write(memoryPath, content) + } catch { + // ignore + } + }) + }) + + const runSupervisor = Effect.fnUntraced(function* ( + session: SessionSchema.Info, + context: AutoDrive.Context, + ) { + const resolvedModel = yield* models.resolve(session).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!resolvedModel) return undefined + + const supervisorPrompt = AutoDrive.buildSupervisorPrompt(context) + const chunks: string[] = [] + let failed = false + + yield* llm + .stream( + LLM.request({ + model: resolvedModel, + messages: [Message.user(supervisorPrompt)], + tools: [], + generation: { maxTokens: 1024 }, + }), + ) + .pipe( + Stream.runForEach((event) => { + if (LLMEvent.is.providerError(event)) failed = true + if (LLMEvent.is.textDelta(event)) chunks.push(event.text) + return Effect.void + }), + Effect.catch(() => Effect.succeed(undefined)), + ) + + if (failed || chunks.length === 0) return undefined + return AutoDrive.parseSupervisorDecision(chunks.join(""), context) + }) + const run = Effect.fn("SessionRunner.run")(function* (input: { readonly sessionID: SessionSchema.ID readonly force: boolean @@ -397,6 +499,7 @@ const layer = Layer.effect( yield* failInterruptedTools(input.sessionID) let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined let shouldRun = input.force || hasSteer || hasQueue + let autoDriveRuns = 0 while (shouldRun) { let needsContinuation = true let step = 1 @@ -407,8 +510,83 @@ const layer = Layer.effect( promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") } - shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue") - promotion = shouldRun ? "queue" : undefined + const hasNextQueue = yield* SessionInput.hasPending(db, input.sessionID, "queue") + if (hasNextQueue) { + autoDriveRuns = 0 + shouldRun = true + promotion = "queue" + continue + } + const configEntries = yield* config.entries() + const autoDriveConfig = Config.latest(configEntries, "auto_drive") + const autoDriveEnabled = + typeof autoDriveConfig === "boolean" ? autoDriveConfig : (autoDriveConfig?.enabled ?? true) + const autoDriveMaxRuns = + (typeof autoDriveConfig === "object" ? autoDriveConfig.max_runs : undefined) ?? AutoDrive.DEFAULT_MAX_RUNS + const customAutoDrivePrompt = typeof autoDriveConfig === "object" ? autoDriveConfig.prompt : undefined + const autoDriveSupervisorEnabled = + typeof autoDriveConfig === "object" ? (autoDriveConfig.supervisor ?? false) : false + const autoDriveMemoryEnabled = + typeof autoDriveConfig === "object" ? (autoDriveConfig.memory ?? false) : false + const autoDriveContextual = + typeof autoDriveConfig === "object" ? (autoDriveConfig.contextual ?? false) : false + + if (autoDriveEnabled && autoDriveRuns < autoDriveMaxRuns) { + const lastText = yield* getLastAssistantText(input.sessionID) + if (lastText) { + const initialGoal = yield* getInceptionUserPrompt(input.sessionID) + const playbook = autoDriveMemoryEnabled + ? yield* getPlaybook(location.project.directory) + : undefined + + const autoDriveContext: AutoDrive.Context = { + initialGoal, + lastText, + playbookMarkdown: playbook, + customPrompt: customAutoDrivePrompt, + contextual: autoDriveContextual, + } + + let shouldContinue = false + let nextPromptText = AutoDrive.promptFor(autoDriveContext) + + if (autoDriveSupervisorEnabled) { + const session = yield* getSession(input.sessionID).pipe(Effect.catch(() => Effect.succeed(undefined))) + const supervisorDecision = session + ? yield* runSupervisor(session, autoDriveContext).pipe( + Effect.timeout("15 seconds"), + Effect.catch(() => Effect.succeed(undefined)), + ) + : undefined + if (supervisorDecision) { + shouldContinue = supervisorDecision.continue + nextPromptText = supervisorDecision.nextPrompt + if (autoDriveMemoryEnabled && supervisorDecision.updateMemory) { + yield* savePlaybook(location.project.directory, supervisorDecision.updateMemory) + } + } else { + shouldContinue = AutoDrive.detect(lastText) + } + } else { + shouldContinue = AutoDrive.detect(lastText) + } + + if (shouldContinue) { + autoDriveRuns++ + yield* SessionInput.admit(db, events, { + id: SessionMessage.ID.create(), + sessionID: input.sessionID, + prompt: Prompt.make({ text: nextPromptText }), + delivery: "queue", + }) + shouldRun = true + promotion = "queue" + continue + } + } + } + shouldRun = false + promotion = undefined } }) diff --git a/packages/core/test/session-auto-drive.test.ts b/packages/core/test/session-auto-drive.test.ts new file mode 100644 index 000000000000..aa54981d8378 --- /dev/null +++ b/packages/core/test/session-auto-drive.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test" +import { AutoDrive } from "../src/session/auto-drive" + +describe("AutoDrive.detect", () => { + test("detects Chinese question asking to continue", () => { + expect(AutoDrive.detect("第一部分已完成。是否继续进行下一步修改?")).toBe(true) + expect(AutoDrive.detect("配置已更新,如需继续请回复【继续】。")).toBe(true) + expect(AutoDrive.detect("当前任务准备就绪,请确认是否继续执行下一步。")).toBe(true) + }) + + test("detects Chinese next step statements", () => { + expect(AutoDrive.detect("基础结构已搭建好。\n下一步计划:\n1. 编写测试用例\n2. 修复边界条件")).toBe(true) + expect(AutoDrive.detect("已完成函数实现,接下来我将开始添加单元测试。")).toBe(true) + expect(AutoDrive.detect("排查完成,随后我们将修改配置文件。")).toBe(true) + }) + + test("detects English question asking to continue", () => { + expect(AutoDrive.detect("First stage done. Would you like me to continue with the next step?")).toBe(true) + expect(AutoDrive.detect("I have created the files. Please reply continue to proceed.")).toBe(true) + expect(AutoDrive.detect("Refactoring complete. Let me know if you would like me to continue.")).toBe(true) + }) + + test("detects English next step statements", () => { + expect(AutoDrive.detect("Summary of changes.\nNext steps:\n1. Run tests\n2. Verify output")).toBe(true) + expect(AutoDrive.detect("Now I will implement the test suite.")).toBe(true) + expect(AutoDrive.detect("I am ready to proceed with the implementation.")).toBe(true) + }) + + test("detects maximum steps reached notices", () => { + expect( + AutoDrive.detect( + "CRITICAL - MAXIMUM STEPS REACHED\nThe maximum number of steps allowed for this task has been reached.", + ), + ).toBe(true) + expect(AutoDrive.detect("已达到最大步数限制,剩余任务如下:...")).toBe(true) + }) + + test("does not trigger when user decision/choice is required", () => { + expect(AutoDrive.detect("这里有两种实现方式:\n1. 方案A\n2. 方案B\n请选择你希望使用的方案。")).toBe(false) + expect(AutoDrive.detect("Which option do you prefer? Option 1 or Option 2?")).toBe(false) + }) + + test("does not trigger on full task completion", () => { + expect(AutoDrive.detect("所有任务已全部完成,代码均已测试通过。")).toBe(false) + expect(AutoDrive.detect("All tasks are completed and verified successfully.")).toBe(false) + }) + + test("handles empty or whitespace strings safely", () => { + expect(AutoDrive.detect("")).toBe(false) + expect(AutoDrive.detect(" ")).toBe(false) + }) +}) + +describe("AutoDrive.promptFor", () => { + test("returns DEFAULT_PROMPT when input is empty string or empty context", () => { + expect(AutoDrive.promptFor("")).toBe(AutoDrive.DEFAULT_PROMPT) + expect(AutoDrive.promptFor({ lastText: "some output" })).toBe(AutoDrive.DEFAULT_PROMPT) + }) + + test("prefers customPrompt when provided", () => { + const prompt = AutoDrive.promptFor({ + lastText: "some output", + customPrompt: "自定义继续跑!", + initialGoal: "重构数据库", + }) + expect(prompt).toBe("自定义继续跑!") + }) + + test("synthesizes contextual prompt when initialGoal is present", () => { + const prompt = AutoDrive.promptFor({ + lastText: "下一步:增加单元测试", + initialGoal: "实现一个用户登录认证系统,支持 OAuth2 和 JWT", + contextual: true, + }) + expect(prompt).toContain("【自动领航指令】") + expect(prompt).toContain("实现一个用户登录认证系统,支持 OAuth2 和 JWT") + expect(prompt).toContain("继续自主推进执行") + }) +}) + +describe("AutoDrive.buildSupervisorPrompt", () => { + test("embeds initialGoal, playbook, and lastText", () => { + const context: AutoDrive.Context = { + initialGoal: "开发高性能缓存模块", + playbookMarkdown: "## 核心规范\n- 覆盖率必须达到 90%", + lastText: "模块核心代码已完成。接下来我将编写缓存淘汰单元测试。", + } + const prompt = AutoDrive.buildSupervisorPrompt(context) + expect(prompt).toContain("开发高性能缓存模块") + expect(prompt).toContain("覆盖率必须达到 90%") + expect(prompt).toContain("模块核心代码已完成。接下来我将编写缓存淘汰单元测试。") + expect(prompt).toContain('"continue": boolean') + }) +}) + +describe("AutoDrive.parseSupervisorDecision", () => { + test("correctly parses valid JSON decision to continue", () => { + const raw = `Here is my evaluation: +\`\`\`json +{ + "continue": true, + "reason": "The worker has remaining tests to write", + "next_prompt": "请按照规划,完成 LRU 淘汰测试用例编写。", + "update_memory": "## 进展更新\\n- [x] 核心代码完成" +} +\`\`\` +` + const context: AutoDrive.Context = { + lastText: "接下来写单元测试", + initialGoal: "开发缓存模块", + } + const decision = AutoDrive.parseSupervisorDecision(raw, context) + expect(decision.continue).toBe(true) + expect(decision.reason).toBe("The worker has remaining tests to write") + expect(decision.nextPrompt).toBe("请按照规划,完成 LRU 淘汰测试用例编写。") + expect(decision.updateMemory).toContain("## 进展更新") + }) + + test("correctly parses valid JSON decision to stop", () => { + const raw = JSON.stringify({ + continue: false, + reason: "All requested features are verified and done", + next_prompt: "", + update_memory: null, + }) + const context: AutoDrive.Context = { + lastText: "任务已完成", + initialGoal: "实现小工具", + } + const decision = AutoDrive.parseSupervisorDecision(raw, context) + expect(decision.continue).toBe(false) + expect(decision.reason).toBe("All requested features are verified and done") + }) + + test("gracefully falls back to heuristic detection on malformed JSON", () => { + const raw = "I think we should proceed because next steps are listed." + const context: AutoDrive.Context = { + lastText: "下一步计划:编写测试用例并验证", + initialGoal: "修复bug", + contextual: true, + } + const decision = AutoDrive.parseSupervisorDecision(raw, context) + expect(decision.continue).toBe(true) + expect(decision.nextPrompt).toContain("【自动领航指令】") + }) +}) + +describe("AutoDrive.defaultPlaybookTemplate", () => { + test("generates template with project name", () => { + const content = AutoDrive.defaultPlaybookTemplate("OpenCodeEngine") + expect(content).toContain("# OpenCodeEngine Auto-Drive Playbook & Memory") + expect(content).toContain("## 1. 核心目标与工程规范") + expect(content).toContain("## 2. 活跃工作流与任务清单 (Active Roadmap)") + }) +}) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index cc58b43b2957..b9e7772fd35a 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -3463,4 +3463,38 @@ describe("SessionRunnerLLM", () => { ) }), ) + + it.effect("auto-drives continuation when assistant outputs next steps", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Build the feature" }), resume: false }) + + requests.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-1" }), + LLMEvent.textDelta({ id: "text-1", text: "已完成第一部分。\n下一步计划:\n1. 编写单元测试" }), + LLMEvent.textEnd({ id: "text-1" }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-2" }), + LLMEvent.textDelta({ id: "text-2", text: "所有任务已全部完成。" }), + LLMEvent.textEnd({ id: "text-2" }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + ] + + yield* session.resume(sessionID) + + expect(requests).toHaveLength(2) + expect(userTexts(requests[0]!)).toEqual(["Build the feature"]) + expect(userTexts(requests[1]!)).toEqual(["Build the feature", "请继续执行下一步计划。"]) + }), + ) }) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 057754cd9ef8..d68e23dc0733 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -86,6 +86,22 @@ const layer = Layer.effect( subtask: true, hints: hints(PROMPT_REVIEW), } + commands["autodrive"] = { + name: "autodrive", + description: "autonomous Auto-Drive continuous execution mode", + source: "command", + template: + "Please execute the following task with Auto-Drive enabled. Continuously execute subsequent steps, code modifications, and test verifications until fully finished without stopping mid-way for confirmation.\n\nTask:\n$ARGUMENTS", + hints: ["$ARGUMENTS"], + } + commands["auto-drive"] = { + name: "auto-drive", + description: "autonomous Auto-Drive continuous execution mode", + source: "command", + template: + "Please execute the following task with Auto-Drive enabled. Continuously execute subsequent steps, code modifications, and test verifications until fully finished without stopping mid-way for confirmation.\n\nTask:\n$ARGUMENTS", + hints: ["$ARGUMENTS"], + } for (const [name, command] of Object.entries(cfg.command ?? {})) { commands[name] = { diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 284c0f0ade41..d17479a1df03 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -28,7 +28,7 @@ export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_JITTER_FACTOR = 0.25 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout -export const RETRY_MAX_RETRIES = 5 +export const RETRY_MAX_RETRIES = Infinity const RETRYABLE_MESSAGE_PATTERNS = [ /429|500|502|503|504|524/i, diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 20c8678cf0a7..e95d15baa807 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -124,7 +124,7 @@ describe("session.retry.delay", () => { }), ) - it.instance("policy stops after five retries", () => + it.instance("policy retries without a fixed retry cap", () => Effect.gen(function* () { const attempts: number[] = [] const error = apiError({ "retry-after-ms": "0" }) @@ -139,11 +139,9 @@ describe("session.retry.delay", () => { }), ) - yield* Effect.forEach(Array.from({ length: SessionRetry.RETRY_MAX_RETRIES + 1 }), () => - Effect.ignore(step(error)), - ) + yield* Effect.forEach(Array.from({ length: 10 }), () => Effect.ignore(step(error))) - expect(attempts).toStrictEqual([1, 2, 3, 4, 5]) + expect(attempts).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) }), ) }) diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index ff4ff0f1d408..c684cb2c1608 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -41,6 +41,7 @@ export type PromptInputV2Props = { borderUnderlay?: boolean class?: string modelControl?: JSX.Element + extraControl?: JSX.Element variantControlVisible?: boolean attachKeybind?: string[] attachShortcut?: string @@ -253,6 +254,9 @@ export function PromptInputV2(props: PromptInputV2Props) { )} + + {props.extraControl} +