diff --git a/.sisyphus/notepads/rebase-v146/decisions.md b/.sisyphus/notepads/rebase-v146/decisions.md new file mode 100644 index 000000000000..9640c721faa6 --- /dev/null +++ b/.sisyphus/notepads/rebase-v146/decisions.md @@ -0,0 +1,3 @@ +- Rebuilt `packages/opencode/src/session/prompt.ts` and `packages/opencode/test/session/prompt-effect.test.ts` from clean `dev`, then replayed only the branch's surviving behavioral deltas. +- Preserved three prompt behaviors only: previous user agent fallback in `createUserMessage`, re-read guard before loop exit, and simple `return yield* loop(...)` in `prompt()`. +- Kept upstream abort-propagation coverage and re-added the branch's `cancel does not restart session via prompt retry loop` regression test. diff --git a/.sisyphus/notepads/rebase-v146/issues.md b/.sisyphus/notepads/rebase-v146/issues.md new file mode 100644 index 000000000000..9ec878e4346f --- /dev/null +++ b/.sisyphus/notepads/rebase-v146/issues.md @@ -0,0 +1,2 @@ +- `bun typecheck` initially failed because workspace dependencies were not installed in this jj workspace (`tsgo` missing from PATH). Running `bun install` at repo root restored the expected toolchain and `bun typecheck` then passed. +- `lsp_diagnostics` still reports TypeScript errors on `prompt.ts`, `prompt-effect.test.ts`, and the two branch regression tests even after `bun typecheck` passes; this appears to be a tsserver/LSP mismatch against the repo's `tsgo`-based typecheck path rather than a blocking `tsgo` error. diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b02e7cc81cd3..e3435a031126 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -560,6 +560,11 @@ export namespace SessionProcessor { } }), ), + // Safety timeout: if the stream hangs (stuck tool, lost context), + // interrupt after 30 minutes rather than blocking the session forever. + // The interrupt triggers onInterrupt above which calls halt(). + Effect.timeout("30 minutes"), + Effect.asVoid, Effect.catchCauseIf( (cause) => !Cause.hasInterruptsOnly(cause), (cause) => Effect.fail(Cause.squash(cause)), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ffd074d3f8b4..81b8a93d8f74 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -923,7 +923,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { - const agentName = input.agent || (yield* agents.defaultAgent()) + const prev = (yield* MessageV2.filterCompactedEffect(input.sessionID)).findLast( + (m) => m.info.role === "user" && !!m.info.agent, + ) + const agentName = input.agent || prev?.info.agent || (yield* agents.defaultAgent()) const ag = yield* agents.get(agentName) if (!ag) { const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) @@ -1355,6 +1358,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the !hasToolCalls && lastUser.id < lastAssistant.id ) { + const fresh = yield* MessageV2.filterCompactedEffect(sessionID) + const newest = fresh.findLast((m) => m.info.role === "user") + if (newest && newest.info.id > lastAssistant.id) { + yield* slog.info("continuing loop for new user") + continue + } yield* slog.info("exiting loop") break } diff --git a/packages/opencode/test/session/prompt-agent-preserve.test.ts b/packages/opencode/test/session/prompt-agent-preserve.test.ts new file mode 100644 index 000000000000..676728e3ea9e --- /dev/null +++ b/packages/opencode/test/session/prompt-agent-preserve.test.ts @@ -0,0 +1,134 @@ +import { afterEach, expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import { Instance } from "../../src/project/instance" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageID, PartID } from "../../src/session/schema" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +afterEach(async () => { + await Instance.disposeAll() +}) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +async function withoutWatcher(fn: () => Promise) { + if (process.platform !== "win32") return fn() + const prev = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER + process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true" + try { + return await fn() + } finally { + if (prev === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER + else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = prev + } +} + +test("prompt without agent field preserves session's current agent", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: "http://localhost:1/v1", + }, + }, + }, + }, + }) + await withoutWatcher(() => + Instance.provide({ + directory: tmp.path, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + const uid = MessageID.ascending() + yield* sessions.updateMessage({ + id: uid, + sessionID: session.id, + role: "user", + agent: "plan", + model: ref, + time: { created: Date.now() }, + } satisfies MessageV2.User) + yield* sessions.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: uid, + type: "text", + text: "make a plan", + } satisfies MessageV2.TextPart) + + const aid = MessageID.ascending() + yield* sessions.updateMessage({ + id: aid, + sessionID: session.id, + role: "assistant", + parentID: uid, + agent: "plan", + mode: "plan", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + finish: "stop", + } satisfies MessageV2.Assistant) + yield* sessions.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: aid, + type: "text", + text: "here is the plan", + } satisfies MessageV2.TextPart) + + const msg = yield* prompt.prompt({ + sessionID: session.id, + noReply: true, + parts: [{ type: "text", text: "notification: task completed" }], + }) + + expect(msg.info.role).toBe("user") + if (msg.info.role === "user") { + expect(msg.info.agent).toBe("plan") + } + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), + ), + }), + ) +}, 15000) diff --git a/packages/opencode/test/session/prompt-async-orphan.test.ts b/packages/opencode/test/session/prompt-async-orphan.test.ts new file mode 100644 index 000000000000..2b03e0c9e705 --- /dev/null +++ b/packages/opencode/test/session/prompt-async-orphan.test.ts @@ -0,0 +1,149 @@ +import { afterEach, expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import { Instance } from "../../src/project/instance" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageID, PartID } from "../../src/session/schema" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +afterEach(async () => { + await Instance.disposeAll() +}) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +async function withoutWatcher(fn: () => Promise) { + if (process.platform !== "win32") return fn() + const prev = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER + process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true" + try { + return await fn() + } finally { + if (prev === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER + else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = prev + } +} + +test("prompt_async to busy session does not orphan user message", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: "http://localhost:1/v1", + }, + }, + }, + }, + }) + await withoutWatcher(() => + Instance.provide({ + directory: tmp.path, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + const uid1 = MessageID.ascending() + yield* sessions.updateMessage({ + id: uid1, + sessionID: session.id, + role: "user", + agent: "build", + model: ref, + time: { created: Date.now() }, + } satisfies MessageV2.User) + yield* sessions.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: uid1, + type: "text", + text: "first question", + } satisfies MessageV2.TextPart) + + const aid = MessageID.ascending() + yield* sessions.updateMessage({ + id: aid, + sessionID: session.id, + role: "assistant", + parentID: uid1, + agent: "build", + mode: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + finish: "stop", + } satisfies MessageV2.Assistant) + yield* sessions.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: aid, + type: "text", + text: "first answer", + } satisfies MessageV2.TextPart) + + const uid2 = MessageID.ascending() + yield* sessions.updateMessage({ + id: uid2, + sessionID: session.id, + role: "user", + agent: "build", + model: ref, + time: { created: Date.now() }, + } satisfies MessageV2.User) + yield* sessions.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: uid2, + type: "text", + text: "second question (from prompt_async)", + } satisfies MessageV2.TextPart) + + const result = yield* Effect.promise(() => + Promise.race([ + Effect.runPromise(prompt.loop({ sessionID: session.id })).then(() => "exited" as const), + new Promise<"continued">((r) => setTimeout(() => r("continued"), 3000)), + ]), + ) + + expect(result).toBe("continued") + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), + ), + }), + ) +}, 15000) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 94561206e2ef..8be28b4626f8 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -747,6 +747,43 @@ it.live( 3_000, ) +it.live( + "cancel does not restart session via prompt retry loop", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "CancelNoRestart", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* llm.hang + + const fiber = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [{ type: "text", text: "hello" }], + }) + .pipe(Effect.forkChild) + + yield* llm.wait(1) + expect(yield* llm.calls).toBe(1) + + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + yield* Effect.sleep("200 millis") + expect(yield* llm.calls).toBe(1) + }), + { git: true, config: providerCfg }, + ), + 5_000, +) + it.live( "cancel records MessageAbortedError on interrupted process", () =>