Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .sisyphus/notepads/rebase-v146/decisions.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .sisyphus/notepads/rebase-v146/issues.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
134 changes: 134 additions & 0 deletions packages/opencode/test/session/prompt-agent-preserve.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(fn: () => Promise<T>) {
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)
149 changes: 149 additions & 0 deletions packages/opencode/test/session/prompt-async-orphan.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(fn: () => Promise<T>) {
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)
37 changes: 37 additions & 0 deletions packages/opencode/test/session/prompt-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
() =>
Expand Down
Loading