Skip to content

Commit b961e15

Browse files
committed
feat(server): /exec endpoint for AI callback from scripts via child session
Scripts can delegate decisions to the LLM by POSTing a prompt. A fresh child session is created for each call so token context never accumulates. Supports structured JSON output, file attachments, model override, custom system prompt, and live streaming. The parent session inherits the last-used model by default.
1 parent 3ddc452 commit b961e15

File tree

6 files changed

+146
-94
lines changed

6 files changed

+146
-94
lines changed

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1503,10 +1503,13 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
15031503
const ctx = use()
15041504
const sync = useSync()
15051505

1506-
// Hide tool if showDetails is false and tool completed successfully
1506+
// Hide tool if showDetails is false and tool completed successfully.
1507+
// Task and status parts are always visible — they show subagent summaries
1508+
// and display-only messages that the user expects to see.
15071509
const shouldHide = createMemo(() => {
15081510
if (ctx.showDetails()) return false
15091511
if (props.part.state.status !== "completed") return false
1512+
if (props.part.tool === "task" || props.part.tool === "status") return false
15101513
return true
15111514
})
15121515

packages/opencode/src/server/routes/session.ts

Lines changed: 36 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -27,32 +27,28 @@ import { ToolRegistry } from "../../tool/registry"
2727

2828
const log = Log.create({ service: "server" })
2929

30-
/** Walk parent session messages backwards to find the model used in the last user message. */
31-
async function resolveModel(sessionID: SessionID) {
32-
const msgs = await Session.messages({ sessionID })
33-
for (let i = msgs.length - 1; i >= 0; i--) {
34-
const info = msgs[i].info
35-
if (info.role === "user" && info.model) return info.model
36-
}
37-
}
38-
3930
/** Create an emitter for external ToolPart updates (no-op when messageID is absent). */
4031
function emitter(opts: { sessionID: SessionID; messageID?: string; tool: string }) {
4132
const mid = opts.messageID ? MessageID.make(opts.messageID) : undefined
4233
const pid = mid ? PartID.ascending() : undefined
43-
const fn = (state: z.infer<typeof MessageV2.ToolState>) =>
44-
mid && pid
45-
? Session.updatePart({
46-
id: pid,
47-
messageID: mid,
48-
sessionID: opts.sessionID,
49-
type: "tool" as const,
50-
tool: opts.tool,
51-
callID: pid,
52-
external: true,
53-
state,
54-
})
55-
: undefined
34+
const fn = (state: z.infer<typeof MessageV2.ToolState>): Promise<void> => {
35+
if (!(mid && pid)) return Promise.resolve()
36+
return Session.updatePart({
37+
id: pid,
38+
messageID: mid,
39+
sessionID: opts.sessionID,
40+
type: "tool" as const,
41+
tool: opts.tool,
42+
callID: pid,
43+
external: true,
44+
state,
45+
}).then(
46+
() => {},
47+
(err) => {
48+
log.warn("external part update failed", { tool: opts.tool, error: err })
49+
},
50+
)
51+
}
5652
return { mid, pid, fn }
5753
}
5854

@@ -1126,15 +1122,14 @@ export const SessionRoutes = lazy(() =>
11261122
abort: c.req.raw.signal,
11271123
messages: [] as MessageV2.WithParts[],
11281124
metadata(val: { title?: string; metadata?: Record<string, unknown> }) {
1129-
emit
1130-
.fn({
1131-
status: "running",
1132-
input: body.args,
1133-
title: val.title,
1134-
metadata: val.metadata,
1135-
time: { start: t0 },
1136-
})
1137-
?.catch(() => {})
1125+
// fire-and-forget — emitter already logs on rejection
1126+
void emit.fn({
1127+
status: "running",
1128+
input: body.args,
1129+
title: val.title,
1130+
metadata: val.metadata,
1131+
time: { start: t0 },
1132+
})
11381133
},
11391134
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
11401135
await Permission.ask({
@@ -1304,17 +1299,18 @@ export const SessionRoutes = lazy(() =>
13041299
const timer = setInterval(() => stream.write("\x00OC_KEEPALIVE\x00").catch(() => {}), 15_000)
13051300

13061301
try {
1302+
const parts: Parameters<typeof SessionPrompt.prompt>[0]["parts"] = [
1303+
{ type: "text", text: body.prompt },
1304+
...(body.files ?? []).map((f) => ({
1305+
type: "file" as const,
1306+
mime: f.mime,
1307+
url: f.url,
1308+
filename: f.filename,
1309+
})),
1310+
]
13071311
const msg = await SessionPrompt.prompt({
13081312
sessionID: child.id,
1309-
parts: [
1310-
{ type: "text", text: body.prompt },
1311-
...(body.files ?? []).map((f) => ({
1312-
type: "file" as const,
1313-
mime: f.mime,
1314-
url: f.url,
1315-
filename: f.filename,
1316-
})),
1317-
],
1313+
parts,
13181314
system: body.system,
13191315
agent: body.agent,
13201316
model,
@@ -1351,54 +1347,5 @@ export const SessionRoutes = lazy(() =>
13511347
}
13521348
})
13531349
},
1354-
)
1355-
// Todo CRUD — create and bulk update
1356-
.post(
1357-
"/:sessionID/todo",
1358-
describeRoute({
1359-
summary: "Create session todo",
1360-
operationId: "session.todo.create",
1361-
responses: {
1362-
200: {
1363-
description: "Updated todo list",
1364-
content: { "application/json": { schema: resolver(Todo.Info.array()) } },
1365-
},
1366-
...errors(400, 404),
1367-
},
1368-
}),
1369-
validator("param", z.object({ sessionID: SessionID.zod })),
1370-
validator("json", Todo.Info),
1371-
async (c) => {
1372-
const sessionID = c.req.valid("param").sessionID
1373-
await Session.get(sessionID)
1374-
const todo = c.req.valid("json")
1375-
const existing = await Todo.get(sessionID)
1376-
const todos = [...existing, todo]
1377-
await Todo.update({ sessionID, todos })
1378-
return c.json(todos)
1379-
},
1380-
)
1381-
.put(
1382-
"/:sessionID/todo",
1383-
describeRoute({
1384-
summary: "Update session todos",
1385-
operationId: "session.todo.update",
1386-
responses: {
1387-
200: {
1388-
description: "Updated todo list",
1389-
content: { "application/json": { schema: resolver(Todo.Info.array()) } },
1390-
},
1391-
...errors(400, 404),
1392-
},
1393-
}),
1394-
validator("param", z.object({ sessionID: SessionID.zod })),
1395-
validator("json", z.object({ todos: Todo.Info.array() })),
1396-
async (c) => {
1397-
const sessionID = c.req.valid("param").sessionID
1398-
await Session.get(sessionID)
1399-
const body = c.req.valid("json")
1400-
await Todo.update({ sessionID, todos: body.todos })
1401-
return c.json(body.todos)
1402-
},
14031350
),
14041351
)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { readFileSync } from "fs"
3+
import { resolve } from "path"
4+
5+
const src = readFileSync(resolve(import.meta.dir, "../../../src/cli/cmd/tui/routes/session/index.tsx"), "utf-8")
6+
7+
describe("ToolPart shouldHide visibility logic", () => {
8+
// Regression: shouldHide hid ALL completed tool parts, including external
9+
// task/status parts created by `oc check` / `oc status`. Because the
10+
// running→completed transition can arrive within a single render batch,
11+
// these parts were invisible before the user could see them.
12+
// The fix exempts tool types "task" and "status" from auto-hiding.
13+
14+
test("shouldHide block exempts task and status tool types", () => {
15+
// Find the shouldHide memo body
16+
const match = src.match(/const shouldHide = createMemo\(\(\) => \{([\s\S]*?)\}\)/)
17+
expect(match).not.toBeNull()
18+
const body = match![1]
19+
expect(body).toContain('"task"')
20+
expect(body).toContain('"status"')
21+
// The exemption must return false (not hidden) for those types
22+
expect(body).toMatch(/props\.part\.tool === "task".*return false|return false.*props\.part\.tool === "task"/s)
23+
})
24+
25+
test("shouldHide exemption covers both task and status in one guard", () => {
26+
// Both types should appear together in a single conditional so neither
27+
// can be removed without the other being noticed.
28+
const match = src.match(
29+
/props\.part\.tool === "task"[^;]*props\.part\.tool === "status"|props\.part\.tool === "status"[^;]*props\.part\.tool === "task"/,
30+
)
31+
expect(match).not.toBeNull()
32+
})
33+
})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { readFileSync } from "fs"
3+
import { resolve } from "path"
4+
5+
describe("session routes emitter type safety", () => {
6+
const src = readFileSync(resolve(import.meta.dir, "../../src/server/routes/session.ts"), "utf-8")
7+
8+
// Regression: the emitter fn returned `Promise<T> | undefined` with
9+
// `.catch(() => undefined as any)`. Callers had to use optional chaining
10+
// (`?.catch`) and the `as any` cast disabled type checking on the return.
11+
// The fix makes fn always return `Promise<void>` for consistent await.
12+
test("emitter fn always returns Promise<void>", () => {
13+
// Must not contain the old `as any` cast pattern
14+
expect(src).not.toContain("undefined as any")
15+
16+
// The fn signature should declare Promise<void> return type
17+
const sig = src.match(/const fn = \(state:[^)]*\):\s*Promise<void>/)
18+
expect(sig).not.toBeNull()
19+
})
20+
21+
test("emitter fn uses Promise.resolve for no-op path (not undefined)", () => {
22+
// The no-op branch must return Promise.resolve() not undefined
23+
expect(src).toContain("Promise.resolve()")
24+
})
25+
26+
// Regression: emitter rejection handler was `() => {}` which silently
27+
// swallowed errors. External ToolPart updates could fail without any
28+
// indication. The fix logs the error via the route-level logger.
29+
test("emitter fn logs errors instead of silently swallowing", () => {
30+
// The rejection handler must reference log.warn (not be empty)
31+
expect(src).toMatch(/\.then\(\s*\(\) => \{\},\s*\(err\)/)
32+
expect(src).toContain('log.warn("external part update failed"')
33+
})
34+
35+
// Regression: emit.fn() was called without `void` prefix in synchronous
36+
// callbacks (Bus subscriber, metadata callback). The returned Promise was
37+
// neither awaited nor voided, causing unhandled rejection if updatePart failed.
38+
test("fire-and-forget emit.fn calls use void prefix", () => {
39+
// The PartDelta subscriber must use `void emit.fn(`
40+
const delta = src.match(/Bus\.subscribe\(MessageV2\.Event\.PartDelta[\s\S]*?void emit\.fn\(/)
41+
expect(delta).not.toBeNull()
42+
43+
// The metadata callback must use `void emit.fn(`
44+
const meta = src.match(/metadata\([^)]*\)\s*\{[^}]*void emit\.fn\(/)
45+
expect(meta).not.toBeNull()
46+
})
47+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { readFileSync } from "fs"
3+
import { resolve } from "path"
4+
5+
describe("session routes naming conventions", () => {
6+
// Regression: the /exec endpoint used `sessionID` (uppercase D) in metadata
7+
// objects, but the TUI Task component and the built-in TaskTool both read
8+
// `metadata.sessionId` (lowercase d). The mismatch silently broke child-session
9+
// click navigation and sync. This test ensures the correct lowercase key is
10+
// always used inside metadata literals.
11+
test("exec metadata uses sessionId (lowercase d) not sessionID (uppercase D)", () => {
12+
const src = readFileSync(resolve(import.meta.dir, "../../src/server/routes/session.ts"), "utf-8")
13+
14+
// Must not find uppercase-D form inside a metadata object literal
15+
const upper = [...src.matchAll(/metadata:\s*\{[^}]*sessionID\b/g)]
16+
expect(upper.length).toBe(0)
17+
18+
// Must find the correct lowercase-d form (currently 3 occurrences in /exec)
19+
const lower = [...src.matchAll(/metadata:\s*\{[^}]*sessionId\b/g)]
20+
expect(lower.length).toBeGreaterThan(0)
21+
})
22+
})

packages/opencode/test/session/message-v2.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,7 +1448,7 @@ describe("session.message-v2.ToolPart.external", () => {
14481448
const pid = PartID.ascending()
14491449
const mid = MessageID.ascending()
14501450

1451-
function toolPart(ext?: boolean) {
1451+
function make(ext?: boolean) {
14521452
return {
14531453
id: pid,
14541454
sessionID,
@@ -1469,19 +1469,19 @@ describe("session.message-v2.ToolPart.external", () => {
14691469
}
14701470

14711471
test("schema accepts external: true", () => {
1472-
const result = MessageV2.ToolPart.safeParse(toolPart(true))
1472+
const result = MessageV2.ToolPart.safeParse(make(true))
14731473
expect(result.success).toBe(true)
14741474
if (result.success) expect(result.data.external).toBe(true)
14751475
})
14761476

14771477
test("schema accepts external: false", () => {
1478-
const result = MessageV2.ToolPart.safeParse(toolPart(false))
1478+
const result = MessageV2.ToolPart.safeParse(make(false))
14791479
expect(result.success).toBe(true)
14801480
if (result.success) expect(result.data.external).toBe(false)
14811481
})
14821482

14831483
test("schema accepts missing external field (undefined)", () => {
1484-
const result = MessageV2.ToolPart.safeParse(toolPart())
1484+
const result = MessageV2.ToolPart.safeParse(make())
14851485
expect(result.success).toBe(true)
14861486
if (result.success) expect(result.data.external).toBeUndefined()
14871487
})

0 commit comments

Comments
 (0)