+ ? Def
+ : T extends Effect.Effect, any, any>
+ ? Def
+ : never
+
function wrap(
id: string,
init: (() => Promise>) | DefWithoutID,
@@ -98,24 +105,27 @@ export namespace Tool {
}
}
- export function define(
- id: string,
+ export function define(
+ id: ID,
init: (() => Promise>) | DefWithoutID,
- ): Info {
+ ): Info & { id: ID } {
return {
id,
init: wrap(id, init),
}
}
- export function defineEffect(
- id: string,
+ export function defineEffect(
+ id: ID,
init: Effect.Effect<(() => Promise>) | DefWithoutID, never, R>,
- ): Effect.Effect, never, R> {
- return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
+ ): Effect.Effect, never, R> & { id: ID } {
+ return Object.assign(
+ Effect.map(init, (next) => ({ id, init: wrap(id, next) })),
+ { id },
+ )
}
- export function init(info: Info): Effect.Effect {
+ export function init(info: Info
): Effect.Effect> {
return Effect.gen(function* () {
const init = yield* Effect.promise(() => info.init())
return {
diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts
index 8559977758bc..eca562a0f53a 100644
--- a/packages/opencode/test/server/project-init-git.test.ts
+++ b/packages/opencode/test/server/project-init-git.test.ts
@@ -19,7 +19,7 @@ afterEach(async () => {
describe("project.initGit endpoint", () => {
test("initializes git and reloads immediately", async () => {
await using tmp = await tmpdir()
- const app = Server.Default()
+ const app = Server.Default().app
const seen: { directory?: string; payload: { type: string } }[] = []
const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt)
@@ -76,7 +76,7 @@ describe("project.initGit endpoint", () => {
test("does not reload when the project is already git", async () => {
await using tmp = await tmpdir({ git: true })
- const app = Server.Default()
+ const app = Server.Default().app
const seen: { directory?: string; payload: { type: string } }[] = []
const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt)
diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts
index e6dba676ce35..004c2900a208 100644
--- a/packages/opencode/test/server/session-actions.test.ts
+++ b/packages/opencode/test/server/session-actions.test.ts
@@ -42,7 +42,7 @@ describe("session action routes", () => {
fn: async () => {
const session = await Session.create({})
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
- const app = Server.Default()
+ const app = Server.Default().app
const res = await app.request(`/session/${session.id}/abort`, {
method: "POST",
@@ -66,7 +66,7 @@ describe("session action routes", () => {
const msg = await user(session.id, "hello")
const busy = spyOn(SessionPrompt, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id))
const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id)
- const app = Server.Default()
+ const app = Server.Default().app
const res = await app.request(`/session/${session.id}/message/${msg.id}`, {
method: "DELETE",
diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts
index 89e6fba5c5fd..7ba95f3b1ec8 100644
--- a/packages/opencode/test/server/session-messages.test.ts
+++ b/packages/opencode/test/server/session-messages.test.ts
@@ -60,7 +60,7 @@ describe("session messages endpoint", () => {
fn: async () => {
const session = await Session.create({})
const ids = await fill(session.id, 5)
- const app = Server.Default()
+ const app = Server.Default().app
const a = await app.request(`/session/${session.id}/message?limit=2`)
expect(a.status).toBe(200)
@@ -89,7 +89,7 @@ describe("session messages endpoint", () => {
fn: async () => {
const session = await Session.create({})
const ids = await fill(session.id, 3)
- const app = Server.Default()
+ const app = Server.Default().app
const res = await app.request(`/session/${session.id}/message`)
expect(res.status).toBe(200)
@@ -109,7 +109,7 @@ describe("session messages endpoint", () => {
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
- const app = Server.Default()
+ const app = Server.Default().app
const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`)
expect(bad.status).toBe(400)
@@ -131,7 +131,7 @@ describe("session messages endpoint", () => {
fn: async () => {
const session = await Session.create({})
await fill(session.id, 520)
- const app = Server.Default()
+ const app = Server.Default().app
const res = await app.request(`/session/${session.id}/message?limit=510`)
expect(res.status).toBe(200)
diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts
index 345b4314675b..7558b4a6b624 100644
--- a/packages/opencode/test/server/session-select.test.ts
+++ b/packages/opencode/test/server/session-select.test.ts
@@ -21,7 +21,7 @@ describe("tui.selectSession endpoint", () => {
const session = await Session.create({})
// #when
- const app = Server.Default()
+ const app = Server.Default().app
const response = await app.request("/tui/select-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -47,7 +47,7 @@ describe("tui.selectSession endpoint", () => {
const nonExistentSessionID = "ses_nonexistent123"
// #when
- const app = Server.Default()
+ const app = Server.Default().app
const response = await app.request("/tui/select-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -69,7 +69,7 @@ describe("tui.selectSession endpoint", () => {
const invalidSessionID = "invalid_session_id"
// #when
- const app = Server.Default()
+ const app = Server.Default().app
const response = await app.request("/tui/select-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts
index 799bb3e2aeb1..c37371d9f871 100644
--- a/packages/opencode/test/session/compaction.test.ts
+++ b/packages/opencode/test/session/compaction.test.ts
@@ -139,7 +139,6 @@ function fake(
get message() {
return msg
},
- abort: Effect.fn("TestSessionProcessor.abort")(() => Effect.void),
partFromToolCall() {
return {
id: PartID.ascending(),
diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts
index 3634d6fb7ec8..64a5d3e4b257 100644
--- a/packages/opencode/test/session/message-v2.test.ts
+++ b/packages/opencode/test/session/message-v2.test.ts
@@ -570,6 +570,81 @@ describe("session.message-v2.toModelMessage", () => {
])
})
+ test("forwards partial bash output for aborted tool calls", async () => {
+ const userID = "m-user"
+ const assistantID = "m-assistant"
+ const output = [
+ "31403",
+ "12179",
+ "4575",
+ "",
+ "",
+ "User aborted the command",
+ "",
+ ].join("\n")
+
+ const input: MessageV2.WithParts[] = [
+ {
+ info: userInfo(userID),
+ parts: [
+ {
+ ...basePart(userID, "u1"),
+ type: "text",
+ text: "run tool",
+ },
+ ] as MessageV2.Part[],
+ },
+ {
+ info: assistantInfo(assistantID, userID),
+ parts: [
+ {
+ ...basePart(assistantID, "a1"),
+ type: "tool",
+ callID: "call-1",
+ tool: "bash",
+ state: {
+ status: "error",
+ input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
+ error: "Tool execution aborted",
+ metadata: { interrupted: true, output },
+ time: { start: 0, end: 1 },
+ },
+ },
+ ] as MessageV2.Part[],
+ },
+ ]
+
+ expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
+ {
+ role: "user",
+ content: [{ type: "text", text: "run tool" }],
+ },
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "call-1",
+ toolName: "bash",
+ input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
+ providerExecuted: undefined,
+ },
+ ],
+ },
+ {
+ role: "tool",
+ content: [
+ {
+ type: "tool-result",
+ toolCallId: "call-1",
+ toolName: "bash",
+ output: { type: "text", value: output },
+ },
+ ],
+ },
+ ])
+ })
+
test("filters assistant messages with non-abort errors", async () => {
const assistantID = "m-assistant"
diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts
index 5f1912c6087d..26f412c7eefa 100644
--- a/packages/opencode/test/session/processor-effect.test.ts
+++ b/packages/opencode/test/session/processor-effect.test.ts
@@ -21,7 +21,7 @@ import { Log } from "../../src/util/log"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
-import { reply, TestLLMServer } from "../lib/llm-server"
+import { raw, reply, TestLLMServer } from "../lib/llm-server"
Log.init({ print: false })
@@ -218,6 +218,93 @@ it.live("session.processor effect tests capture llm input cleanly", () =>
),
)
+it.live("session.processor effect tests preserve text start time", () =>
+ provideTmpdirServer(
+ ({ dir, llm }) =>
+ Effect.gen(function* () {
+ const gate = defer()
+ const { processors, session, provider } = yield* boot()
+
+ yield* llm.push(
+ raw({
+ head: [
+ {
+ id: "chatcmpl-test",
+ object: "chat.completion.chunk",
+ choices: [{ delta: { role: "assistant" } }],
+ },
+ {
+ id: "chatcmpl-test",
+ object: "chat.completion.chunk",
+ choices: [{ delta: { content: "hello" } }],
+ },
+ ],
+ wait: gate.promise,
+ tail: [
+ {
+ id: "chatcmpl-test",
+ object: "chat.completion.chunk",
+ choices: [{ delta: {}, finish_reason: "stop" }],
+ },
+ ],
+ }),
+ )
+
+ const chat = yield* session.create({})
+ const parent = yield* user(chat.id, "hi")
+ const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
+ const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
+ const handle = yield* processors.create({
+ assistantMessage: msg,
+ sessionID: chat.id,
+ model: mdl,
+ })
+
+ const run = yield* handle
+ .process({
+ user: {
+ id: parent.id,
+ sessionID: chat.id,
+ role: "user",
+ time: parent.time,
+ agent: parent.agent,
+ model: { providerID: ref.providerID, modelID: ref.modelID },
+ } satisfies MessageV2.User,
+ sessionID: chat.id,
+ model: mdl,
+ agent: agent(),
+ system: [],
+ messages: [{ role: "user", content: "hi" }],
+ tools: {},
+ })
+ .pipe(Effect.forkChild)
+
+ yield* Effect.promise(async () => {
+ const stop = Date.now() + 500
+ while (Date.now() < stop) {
+ const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text")
+ if (text?.time?.start) return
+ await Bun.sleep(10)
+ }
+ throw new Error("timed out waiting for text part")
+ })
+ yield* Effect.sleep("20 millis")
+ gate.resolve()
+
+ const exit = yield* Fiber.await(run)
+ const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text")
+
+ expect(Exit.isSuccess(exit)).toBe(true)
+ expect(text?.text).toBe("hello")
+ expect(text?.time?.start).toBeDefined()
+ expect(text?.time?.end).toBeDefined()
+ if (!text?.time?.start || !text.time.end) return
+ expect(text.time.start).toBeLessThan(text.time.end)
+ }),
+ { git: true, config: (url) => providerCfg(url) },
+ ),
+)
+
it.live("session.processor effect tests stop after token overflow requests compaction", () =>
provideTmpdirServer(
({ dir, llm }) =>
@@ -636,9 +723,6 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
yield* Fiber.interrupt(run)
const exit = yield* Fiber.await(run)
- if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
- yield* handle.abort()
- }
const parts = MessageV2.parts(msg.id)
const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
@@ -650,6 +734,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
expect(call?.state.status).toBe("error")
if (call?.state.status === "error") {
expect(call.state.error).toBe("Tool execution aborted")
+ expect(call.state.metadata?.interrupted).toBe(true)
expect(call.state.time.end).toBeDefined()
}
}),
@@ -708,9 +793,6 @@ it.live("session.processor effect tests record aborted errors and idle state", (
yield* Fiber.interrupt(run)
const exit = yield* Fiber.await(run)
- if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
- yield* handle.abort()
- }
yield* Effect.promise(() => seen.promise)
const stored = MessageV2.get({ sessionID: chat.id, messageID: msg.id })
const state = yield* sts.get(chat.id)
diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts
index 1c92c38428c0..5e45ebf83263 100644
--- a/packages/opencode/test/session/prompt-effect.test.ts
+++ b/packages/opencode/test/session/prompt-effect.test.ts
@@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
-import { expect, spyOn } from "bun:test"
+import { expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path"
import z from "zod"
@@ -29,7 +29,6 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
-import { TaskTool } from "../../src/tool/task"
import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log"
@@ -703,34 +702,27 @@ it.live(
"cancel finalizes subtask tool state",
() =>
provideTmpdirInstance(
- (dir) =>
+ () =>
Effect.gen(function* () {
const ready = defer()
const aborted = defer()
- const init = spyOn(TaskTool, "init").mockImplementation(async () => ({
- description: "task",
- parameters: z.object({
- description: z.string(),
- prompt: z.string(),
- subagent_type: z.string(),
- task_id: z.string().optional(),
- command: z.string().optional(),
- }),
- execute: async (_args, ctx) => {
- ready.resolve()
- ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
- await new Promise(() => {})
- return {
- title: "",
- metadata: {
- sessionId: SessionID.make("task"),
- model: ref,
- },
- output: "",
- }
- },
- }))
- yield* Effect.addFinalizer(() => Effect.sync(() => init.mockRestore()))
+ const registry = yield* ToolRegistry.Service
+ const { task } = yield* registry.named()
+ const original = task.execute
+ task.execute = async (_args, ctx) => {
+ ready.resolve()
+ ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
+ await new Promise(() => {})
+ return {
+ title: "",
+ metadata: {
+ sessionId: SessionID.make("task"),
+ model: ref,
+ },
+ output: "",
+ }
+ }
+ yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
const { prompt, chat } = yield* boot()
const msg = yield* user(chat.id, "hello")
@@ -1324,3 +1316,109 @@ unix(
),
30_000,
)
+
+// Abort signal propagation tests for inline tool execution
+
+/** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */
+function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
+ const ready = defer()
+ const aborted = defer()
+ const original = tool.execute
+ tool.execute = async (_args: any, ctx: any) => {
+ ready.resolve()
+ ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
+ await new Promise(() => {})
+ return { title: "", metadata: {}, output: "" }
+ }
+ const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
+ return { ready, aborted, restore }
+}
+
+it.live(
+ "interrupt propagates abort signal to read tool via file part (text/plain)",
+ () =>
+ provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const registry = yield* ToolRegistry.Service
+ const { read } = yield* registry.named()
+ const { ready, aborted, restore } = hangUntilAborted(read)
+ yield* restore
+
+ const prompt = yield* SessionPrompt.Service
+ const sessions = yield* Session.Service
+ const chat = yield* sessions.create({ title: "Abort Test" })
+
+ const testFile = path.join(dir, "test.txt")
+ yield* Effect.promise(() => Bun.write(testFile, "hello world"))
+
+ const fiber = yield* prompt
+ .prompt({
+ sessionID: chat.id,
+ agent: "build",
+ parts: [
+ { type: "text", text: "read this" },
+ { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" },
+ ],
+ })
+ .pipe(Effect.forkChild)
+
+ yield* Effect.promise(() => ready.promise)
+ yield* Fiber.interrupt(fiber)
+
+ yield* Effect.promise(() =>
+ Promise.race([
+ aborted.promise,
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
+ ),
+ ]),
+ )
+ }),
+ { git: true, config: cfg },
+ ),
+ 30_000,
+)
+
+it.live(
+ "interrupt propagates abort signal to read tool via file part (directory)",
+ () =>
+ provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const registry = yield* ToolRegistry.Service
+ const { read } = yield* registry.named()
+ const { ready, aborted, restore } = hangUntilAborted(read)
+ yield* restore
+
+ const prompt = yield* SessionPrompt.Service
+ const sessions = yield* Session.Service
+ const chat = yield* sessions.create({ title: "Abort Test" })
+
+ const fiber = yield* prompt
+ .prompt({
+ sessionID: chat.id,
+ agent: "build",
+ parts: [
+ { type: "text", text: "read this" },
+ { type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" },
+ ],
+ })
+ .pipe(Effect.forkChild)
+
+ yield* Effect.promise(() => ready.promise)
+ yield* Fiber.interrupt(fiber)
+
+ yield* Effect.promise(() =>
+ Promise.race([
+ aborted.promise,
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
+ ),
+ ]),
+ )
+ }),
+ { git: true, config: cfg },
+ ),
+ 30_000,
+)
diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts
index a714f1147345..e76401ae75c8 100644
--- a/packages/opencode/test/storage/json-migration.test.ts
+++ b/packages/opencode/test/storage/json-migration.test.ts
@@ -1,6 +1,6 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { Database } from "bun:sqlite"
-import { drizzle } from "drizzle-orm/bun-sqlite"
+import { drizzle, SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import path from "path"
import fs from "fs/promises"
@@ -89,18 +89,21 @@ function createTestDb() {
name: entry.name,
}))
.sort((a, b) => a.timestamp - b.timestamp)
- migrate(drizzle({ client: sqlite }), migrations)
- return sqlite
+ const db = drizzle({ client: sqlite })
+ migrate(db, migrations)
+
+ return [sqlite, db] as const
}
describe("JSON to SQLite migration", () => {
let storageDir: string
let sqlite: Database
+ let db: SQLiteBunDatabase
beforeEach(async () => {
storageDir = await setupStorageDir()
- sqlite = createTestDb()
+ ;[sqlite, db] = createTestDb()
})
afterEach(async () => {
@@ -118,11 +121,10 @@ describe("JSON to SQLite migration", () => {
sandboxes: ["/test/sandbox"],
})
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.projects).toBe(1)
- const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
@@ -143,11 +145,10 @@ describe("JSON to SQLite migration", () => {
}),
)
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.projects).toBe(1)
- const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id
@@ -164,11 +165,10 @@ describe("JSON to SQLite migration", () => {
commands: { start: "npm run dev" },
})
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.projects).toBe(1)
- const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_with_commands"))
@@ -185,11 +185,10 @@ describe("JSON to SQLite migration", () => {
sandboxes: [],
})
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.projects).toBe(1)
- const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_no_commands"))
@@ -216,9 +215,8 @@ describe("JSON to SQLite migration", () => {
share: { url: "https://example.com/share" },
})
- await JsonMigration.run(sqlite)
+ await JsonMigration.run(db)
- const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_test456def"))
@@ -247,12 +245,11 @@ describe("JSON to SQLite migration", () => {
JSON.stringify({ ...fixtures.part }),
)
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.messages).toBe(1)
expect(stats?.parts).toBe(1)
- const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
@@ -287,12 +284,11 @@ describe("JSON to SQLite migration", () => {
}),
)
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.messages).toBe(1)
expect(stats?.parts).toBe(1)
- const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
@@ -329,11 +325,10 @@ describe("JSON to SQLite migration", () => {
}),
)
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.messages).toBe(1)
- const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id
@@ -367,11 +362,10 @@ describe("JSON to SQLite migration", () => {
}),
)
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.parts).toBe(1)
- const db = drizzle({ client: sqlite })
const parts = db.select().from(PartTable).all()
expect(parts.length).toBe(1)
expect(parts[0].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id
@@ -392,7 +386,7 @@ describe("JSON to SQLite migration", () => {
}),
)
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.sessions).toBe(0)
})
@@ -420,11 +414,10 @@ describe("JSON to SQLite migration", () => {
time: { created: 1700000000000, updated: 1700000001000 },
})
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.sessions).toBe(1)
- const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_migrated"))
@@ -452,11 +445,10 @@ describe("JSON to SQLite migration", () => {
}),
)
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.sessions).toBe(1)
- const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id
@@ -471,10 +463,9 @@ describe("JSON to SQLite migration", () => {
sandboxes: [],
})
- await JsonMigration.run(sqlite)
- await JsonMigration.run(sqlite)
+ await JsonMigration.run(db)
+ await JsonMigration.run(db)
- const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
})
@@ -507,11 +498,10 @@ describe("JSON to SQLite migration", () => {
]),
)
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.todos).toBe(2)
- const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(2)
expect(todos[0].content).toBe("First todo")
@@ -540,9 +530,8 @@ describe("JSON to SQLite migration", () => {
]),
)
- await JsonMigration.run(sqlite)
+ await JsonMigration.run(db)
- const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(3)
@@ -570,11 +559,10 @@ describe("JSON to SQLite migration", () => {
]
await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData))
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.permissions).toBe(1)
- const db = drizzle({ client: sqlite })
const permissions = db.select().from(PermissionTable).all()
expect(permissions.length).toBe(1)
expect(permissions[0].project_id).toBe("proj_test123abc")
@@ -600,11 +588,10 @@ describe("JSON to SQLite migration", () => {
}),
)
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats?.shares).toBe(1)
- const db = drizzle({ client: sqlite })
const shares = db.select().from(SessionShareTable).all()
expect(shares.length).toBe(1)
expect(shares[0].session_id).toBe("ses_test456def")
@@ -616,7 +603,7 @@ describe("JSON to SQLite migration", () => {
test("returns empty stats when storage directory does not exist", async () => {
await fs.rm(storageDir, { recursive: true, force: true })
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats.projects).toBe(0)
expect(stats.sessions).toBe(0)
@@ -637,12 +624,11 @@ describe("JSON to SQLite migration", () => {
})
await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json")
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats.projects).toBe(1)
expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true)
- const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
@@ -666,10 +652,9 @@ describe("JSON to SQLite migration", () => {
]),
)
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats.todos).toBe(2)
- const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(2)
expect(todos[0].content).toBe("keep-0")
@@ -714,13 +699,12 @@ describe("JSON to SQLite migration", () => {
JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }),
)
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
expect(stats.todos).toBe(1)
expect(stats.permissions).toBe(1)
expect(stats.shares).toBe(1)
- const db = drizzle({ client: sqlite })
expect(db.select().from(TodoTable).all().length).toBe(1)
expect(db.select().from(PermissionTable).all().length).toBe(1)
expect(db.select().from(SessionShareTable).all().length).toBe(1)
@@ -823,7 +807,7 @@ describe("JSON to SQLite migration", () => {
)
await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope")
- const stats = await JsonMigration.run(sqlite)
+ const stats = await JsonMigration.run(db)
// Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename)
// Sessions: ses_test456def (valid), ses_missing_project (now uses dir path),
@@ -837,7 +821,6 @@ describe("JSON to SQLite migration", () => {
expect(stats.shares).toBe(1)
expect(stats.errors.length).toBeGreaterThanOrEqual(6)
- const db = drizzle({ client: sqlite })
expect(db.select().from(ProjectTable).all().length).toBe(2)
expect(db.select().from(SessionTable).all().length).toBe(3)
expect(db.select().from(MessageTable).all().length).toBe(1)
diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts
index fe936a242aaf..8ebfa59d2313 100644
--- a/packages/opencode/test/tool/task.test.ts
+++ b/packages/opencode/test/tool/task.test.ts
@@ -1,50 +1,412 @@
-import { Effect } from "effect"
-import { afterEach, describe, expect, test } from "bun:test"
+import { afterEach, describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
+import { Config } from "../../src/config/config"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
-import { TaskDescription } from "../../src/tool/task"
-import { tmpdir } from "../fixture/fixture"
+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 { ModelID, ProviderID } from "../../src/provider/schema"
+import { TaskDescription, TaskTool } from "../../src/tool/task"
+import { provideTmpdirInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
afterEach(async () => {
await Instance.disposeAll()
})
+const ref = {
+ providerID: ProviderID.make("test"),
+ modelID: ModelID.make("test-model"),
+}
+
+const it = testEffect(
+ Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer),
+)
+
+const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
+ const session = yield* Session.Service
+ const chat = yield* session.create({ title })
+ const user = yield* session.updateMessage({
+ id: MessageID.ascending(),
+ role: "user",
+ sessionID: chat.id,
+ agent: "build",
+ model: ref,
+ time: { created: Date.now() },
+ })
+ const assistant: MessageV2.Assistant = {
+ id: MessageID.ascending(),
+ role: "assistant",
+ parentID: user.id,
+ sessionID: chat.id,
+ mode: "build",
+ agent: "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() },
+ }
+ yield* session.updateMessage(assistant)
+ return { chat, assistant }
+})
+
+function reply(input: Parameters[0], text: string): MessageV2.WithParts {
+ const id = MessageID.ascending()
+ return {
+ info: {
+ id,
+ role: "assistant",
+ parentID: input.messageID ?? MessageID.ascending(),
+ sessionID: input.sessionID,
+ mode: input.agent ?? "general",
+ agent: input.agent ?? "general",
+ cost: 0,
+ path: { cwd: "/tmp", root: "/tmp" },
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ modelID: input.model?.modelID ?? ref.modelID,
+ providerID: input.model?.providerID ?? ref.providerID,
+ time: { created: Date.now() },
+ finish: "stop",
+ },
+ parts: [
+ {
+ id: PartID.ascending(),
+ messageID: id,
+ sessionID: input.sessionID,
+ type: "text",
+ text,
+ },
+ ],
+ }
+}
+
describe("tool.task", () => {
- test("description sorts subagents by name and is stable across calls", async () => {
- await using tmp = await tmpdir({
- config: {
- agent: {
- zebra: {
- description: "Zebra agent",
- mode: "subagent",
+ it.live("description sorts subagents by name and is stable across calls", () =>
+ provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const agent = yield* Agent.Service
+ const build = yield* agent.get("build")
+ const first = yield* TaskDescription(build)
+ const second = yield* TaskDescription(build)
+
+ expect(first).toBe(second)
+
+ const alpha = first.indexOf("- alpha: Alpha agent")
+ const explore = first.indexOf("- explore:")
+ const general = first.indexOf("- general:")
+ const zebra = first.indexOf("- zebra: Zebra agent")
+
+ expect(alpha).toBeGreaterThan(-1)
+ expect(explore).toBeGreaterThan(alpha)
+ expect(general).toBeGreaterThan(explore)
+ expect(zebra).toBeGreaterThan(general)
+ }),
+ {
+ config: {
+ agent: {
+ zebra: {
+ description: "Zebra agent",
+ mode: "subagent",
+ },
+ alpha: {
+ description: "Alpha agent",
+ mode: "subagent",
+ },
},
- alpha: {
- description: "Alpha agent",
- mode: "subagent",
+ },
+ },
+ ),
+ )
+
+ it.live("description hides denied subagents for the caller", () =>
+ provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const agent = yield* Agent.Service
+ const build = yield* agent.get("build")
+ const description = yield* TaskDescription(build)
+
+ expect(description).toContain("- alpha: Alpha agent")
+ expect(description).not.toContain("- zebra: Zebra agent")
+ }),
+ {
+ config: {
+ permission: {
+ task: {
+ "*": "allow",
+ zebra: "deny",
+ },
+ },
+ agent: {
+ zebra: {
+ description: "Zebra agent",
+ mode: "subagent",
+ },
+ alpha: {
+ description: "Alpha agent",
+ mode: "subagent",
+ },
},
},
},
- })
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
- const first = await Effect.runPromise(TaskDescription(agent))
- const second = await Effect.runPromise(TaskDescription(agent))
-
- expect(first).toBe(second)
-
- const alpha = first.indexOf("- alpha: Alpha agent")
- const explore = first.indexOf("- explore:")
- const general = first.indexOf("- general:")
- const zebra = first.indexOf("- zebra: Zebra agent")
-
- expect(alpha).toBeGreaterThan(-1)
- expect(explore).toBeGreaterThan(alpha)
- expect(general).toBeGreaterThan(explore)
- expect(zebra).toBeGreaterThan(general)
+ ),
+ )
+
+ it.live("execute resumes an existing task session from task_id", () =>
+ provideTmpdirInstance(() =>
+ Effect.gen(function* () {
+ const sessions = yield* Session.Service
+ const { chat, assistant } = yield* seed()
+ const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
+ const tool = yield* TaskTool
+ const def = yield* Effect.promise(() => tool.init())
+ const resolve = SessionPrompt.resolvePromptParts
+ const prompt = SessionPrompt.prompt
+ let seen: Parameters[0] | undefined
+
+ SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
+ SessionPrompt.prompt = async (input) => {
+ seen = input
+ return reply(input, "resumed")
+ }
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ SessionPrompt.resolvePromptParts = resolve
+ SessionPrompt.prompt = prompt
+ }),
+ )
+
+ const result = yield* Effect.promise(() =>
+ def.execute(
+ {
+ description: "inspect bug",
+ prompt: "look into the cache key path",
+ subagent_type: "general",
+ task_id: child.id,
+ },
+ {
+ sessionID: chat.id,
+ messageID: assistant.id,
+ agent: "build",
+ abort: new AbortController().signal,
+ messages: [],
+ metadata() {},
+ ask: async () => {},
+ },
+ ),
+ )
+
+ const kids = yield* sessions.children(chat.id)
+ expect(kids).toHaveLength(1)
+ expect(kids[0]?.id).toBe(child.id)
+ expect(result.metadata.sessionId).toBe(child.id)
+ expect(result.output).toContain(`task_id: ${child.id}`)
+ expect(seen?.sessionID).toBe(child.id)
+ }),
+ ),
+ )
+
+ it.live("execute asks by default and skips checks when bypassed", () =>
+ provideTmpdirInstance(() =>
+ Effect.gen(function* () {
+ const { chat, assistant } = yield* seed()
+ const tool = yield* TaskTool
+ const def = yield* Effect.promise(() => tool.init())
+ const resolve = SessionPrompt.resolvePromptParts
+ const prompt = SessionPrompt.prompt
+ const calls: unknown[] = []
+
+ SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
+ SessionPrompt.prompt = async (input) => reply(input, "done")
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ SessionPrompt.resolvePromptParts = resolve
+ SessionPrompt.prompt = prompt
+ }),
+ )
+
+ const exec = (extra?: { bypassAgentCheck?: boolean }) =>
+ Effect.promise(() =>
+ def.execute(
+ {
+ description: "inspect bug",
+ prompt: "look into the cache key path",
+ subagent_type: "general",
+ },
+ {
+ sessionID: chat.id,
+ messageID: assistant.id,
+ agent: "build",
+ abort: new AbortController().signal,
+ extra,
+ messages: [],
+ metadata() {},
+ ask: async (input) => {
+ calls.push(input)
+ },
+ },
+ ),
+ )
+
+ yield* exec()
+ yield* exec({ bypassAgentCheck: true })
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]).toEqual({
+ permission: "task",
+ patterns: ["general"],
+ always: ["*"],
+ metadata: {
+ description: "inspect bug",
+ subagent_type: "general",
+ },
+ })
+ }),
+ ),
+ )
+
+ it.live("execute creates a child when task_id does not exist", () =>
+ provideTmpdirInstance(() =>
+ Effect.gen(function* () {
+ const sessions = yield* Session.Service
+ const { chat, assistant } = yield* seed()
+ const tool = yield* TaskTool
+ const def = yield* Effect.promise(() => tool.init())
+ const resolve = SessionPrompt.resolvePromptParts
+ const prompt = SessionPrompt.prompt
+ let seen: Parameters[0] | undefined
+
+ SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
+ SessionPrompt.prompt = async (input) => {
+ seen = input
+ return reply(input, "created")
+ }
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ SessionPrompt.resolvePromptParts = resolve
+ SessionPrompt.prompt = prompt
+ }),
+ )
+
+ const result = yield* Effect.promise(() =>
+ def.execute(
+ {
+ description: "inspect bug",
+ prompt: "look into the cache key path",
+ subagent_type: "general",
+ task_id: "ses_missing",
+ },
+ {
+ sessionID: chat.id,
+ messageID: assistant.id,
+ agent: "build",
+ abort: new AbortController().signal,
+ messages: [],
+ metadata() {},
+ ask: async () => {},
+ },
+ ),
+ )
+
+ const kids = yield* sessions.children(chat.id)
+ expect(kids).toHaveLength(1)
+ expect(kids[0]?.id).toBe(result.metadata.sessionId)
+ expect(result.metadata.sessionId).not.toBe("ses_missing")
+ expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
+ expect(seen?.sessionID).toBe(result.metadata.sessionId)
+ }),
+ ),
+ )
+
+ it.live("execute shapes child permissions for task, todowrite, and primary tools", () =>
+ provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const sessions = yield* Session.Service
+ const { chat, assistant } = yield* seed()
+ const tool = yield* TaskTool
+ const def = yield* Effect.promise(() => tool.init())
+ const resolve = SessionPrompt.resolvePromptParts
+ const prompt = SessionPrompt.prompt
+ let seen: Parameters[0] | undefined
+
+ SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
+ SessionPrompt.prompt = async (input) => {
+ seen = input
+ return reply(input, "done")
+ }
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ SessionPrompt.resolvePromptParts = resolve
+ SessionPrompt.prompt = prompt
+ }),
+ )
+
+ const result = yield* Effect.promise(() =>
+ def.execute(
+ {
+ description: "inspect bug",
+ prompt: "look into the cache key path",
+ subagent_type: "reviewer",
+ },
+ {
+ sessionID: chat.id,
+ messageID: assistant.id,
+ agent: "build",
+ abort: new AbortController().signal,
+ messages: [],
+ metadata() {},
+ ask: async () => {},
+ },
+ ),
+ )
+
+ const child = yield* sessions.get(result.metadata.sessionId)
+ expect(child.parentID).toBe(chat.id)
+ expect(child.permission).toEqual([
+ {
+ permission: "todowrite",
+ pattern: "*",
+ action: "deny",
+ },
+ {
+ permission: "bash",
+ pattern: "*",
+ action: "allow",
+ },
+ {
+ permission: "read",
+ pattern: "*",
+ action: "allow",
+ },
+ ])
+ expect(seen?.tools).toEqual({
+ todowrite: false,
+ bash: false,
+ read: false,
+ })
+ }),
+ {
+ config: {
+ agent: {
+ reviewer: {
+ mode: "subagent",
+ permission: {
+ task: "allow",
+ },
+ },
+ },
+ experimental: {
+ primary_tools: ["bash", "read"],
+ },
+ },
},
- })
- })
+ ),
+ )
})
diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts
index 5233f10816b5..00e9dcc96cef 100644
--- a/packages/opencode/test/tool/webfetch.test.ts
+++ b/packages/opencode/test/tool/webfetch.test.ts
@@ -17,58 +17,25 @@ const ctx = {
ask: async () => {},
}
-type TimerID = ReturnType
-
-async function withFetch(
- mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise,
- fn: () => Promise,
-) {
- const originalFetch = globalThis.fetch
- globalThis.fetch = mockFetch as unknown as typeof fetch
- try {
- await fn()
- } finally {
- globalThis.fetch = originalFetch
- }
-}
-
-async function withTimers(fn: (state: { ids: TimerID[]; cleared: TimerID[] }) => Promise) {
- const set = globalThis.setTimeout
- const clear = globalThis.clearTimeout
- const ids: TimerID[] = []
- const cleared: TimerID[] = []
-
- globalThis.setTimeout = ((...args: Parameters) => {
- const id = set(...args)
- ids.push(id)
- return id
- }) as typeof setTimeout
-
- globalThis.clearTimeout = ((id?: TimerID) => {
- if (id !== undefined) cleared.push(id)
- return clear(id)
- }) as typeof clearTimeout
-
- try {
- await fn({ ids, cleared })
- } finally {
- ids.forEach(clear)
- globalThis.setTimeout = set
- globalThis.clearTimeout = clear
- }
+async function withFetch(fetch: (req: Request) => Response | Promise, fn: (url: URL) => Promise) {
+ using server = Bun.serve({ port: 0, fetch })
+ await fn(server.url)
}
describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
await withFetch(
- async () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
- async () => {
+ () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
+ async (url) => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
- const result = await webfetch.execute({ url: "https://example.com/image.png", format: "markdown" }, ctx)
+ const result = await webfetch.execute(
+ { url: new URL("/image.png", url).toString(), format: "markdown" },
+ ctx,
+ )
expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
@@ -87,17 +54,17 @@ describe("tool.webfetch", () => {
test("keeps svg as text output", async () => {
const svg = ''
await withFetch(
- async () =>
+ () =>
new Response(svg, {
status: 200,
headers: { "content-type": "image/svg+xml; charset=UTF-8" },
}),
- async () => {
+ async (url) => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
- const result = await webfetch.execute({ url: "https://example.com/image.svg", format: "html" }, ctx)
+ const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
expect(result.output).toContain("