Skip to content

Commit 71d19bf

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 71d19bf

8 files changed

Lines changed: 272 additions & 94 deletions

File tree

packages/opencode/.opencode/package-lock.json

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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: 82 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

@@ -218,6 +214,52 @@ export const SessionRoutes = lazy(() =>
218214
return c.json(todos)
219215
},
220216
)
217+
.post(
218+
"/:sessionID/todo",
219+
describeRoute({
220+
summary: "Create session todo",
221+
description: "Create a new todo item for the session.",
222+
operationId: "session.todo.create",
223+
responses: {
224+
200: { description: "Created todo", content: { "application/json": { schema: resolver(Todo.Info) } } },
225+
...errors(400, 404),
226+
},
227+
}),
228+
validator("param", z.object({ sessionID: SessionID.zod })),
229+
validator(
230+
"json",
231+
z.object({ content: z.string(), status: z.string().optional(), priority: z.string().optional() }),
232+
),
233+
async (c) => {
234+
const sessionID = c.req.valid("param").sessionID
235+
const body = c.req.valid("json")
236+
const todo = Todo.add(sessionID, body)
237+
return c.json(todo)
238+
},
239+
)
240+
.put(
241+
"/:sessionID/todo",
242+
describeRoute({
243+
summary: "Update session todos",
244+
description: "Replace all todos for a session (bulk update).",
245+
operationId: "session.todo.update",
246+
responses: {
247+
200: {
248+
description: "Updated todos",
249+
content: { "application/json": { schema: resolver(Todo.Info.array()) } },
250+
},
251+
...errors(400, 404),
252+
},
253+
}),
254+
validator("param", z.object({ sessionID: SessionID.zod })),
255+
validator("json", z.object({ todos: z.array(Todo.Info) })),
256+
async (c) => {
257+
const sessionID = c.req.valid("param").sessionID
258+
const body = c.req.valid("json")
259+
await Todo.update({ sessionID, todos: body.todos })
260+
return c.json(body.todos)
261+
},
262+
)
221263
.post(
222264
"/",
223265
describeRoute({
@@ -1126,15 +1168,14 @@ export const SessionRoutes = lazy(() =>
11261168
abort: c.req.raw.signal,
11271169
messages: [] as MessageV2.WithParts[],
11281170
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(() => {})
1171+
// fire-and-forget — emitter already logs on rejection
1172+
void emit.fn({
1173+
status: "running",
1174+
input: body.args,
1175+
title: val.title,
1176+
metadata: val.metadata,
1177+
time: { start: t0 },
1178+
})
11381179
},
11391180
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
11401181
await Permission.ask({
@@ -1304,17 +1345,18 @@ export const SessionRoutes = lazy(() =>
13041345
const timer = setInterval(() => stream.write("\x00OC_KEEPALIVE\x00").catch(() => {}), 15_000)
13051346

13061347
try {
1348+
const parts: Parameters<typeof SessionPrompt.prompt>[0]["parts"] = [
1349+
{ type: "text", text: body.prompt },
1350+
...(body.files ?? []).map((f) => ({
1351+
type: "file" as const,
1352+
mime: f.mime,
1353+
url: f.url,
1354+
filename: f.filename,
1355+
})),
1356+
]
13071357
const msg = await SessionPrompt.prompt({
13081358
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-
],
1359+
parts,
13181360
system: body.system,
13191361
agent: body.agent,
13201362
model,
@@ -1351,54 +1393,5 @@ export const SessionRoutes = lazy(() =>
13511393
}
13521394
})
13531395
},
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-
},
14031396
),
14041397
)

packages/opencode/src/session/todo.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,41 @@ export namespace Todo {
8888
export async function get(sessionID: SessionID) {
8989
return runPromise((svc) => svc.get(sessionID))
9090
}
91+
92+
export async function update(input: { sessionID: SessionID; todos: Info[] }) {
93+
return runPromise((svc) => svc.update(input))
94+
}
95+
96+
/** O(1) add — direct INSERT instead of delete-all + insert-all */
97+
export function add(sessionID: SessionID, todo: Partial<Info> & { content: string }) {
98+
const current = Database.use((db) =>
99+
db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
100+
)
101+
const newTodo: Info = {
102+
content: todo.content,
103+
status: todo.status ?? "pending",
104+
priority: todo.priority ?? "medium",
105+
}
106+
Database.use((db) => {
107+
db.insert(TodoTable)
108+
.values({
109+
session_id: sessionID,
110+
content: newTodo.content,
111+
status: newTodo.status,
112+
priority: newTodo.priority,
113+
position: current.length,
114+
})
115+
.run()
116+
})
117+
const updated = [
118+
...current.map((row) => ({
119+
content: row.content,
120+
status: row.status,
121+
priority: row.priority,
122+
})),
123+
newTodo,
124+
]
125+
void Bus.publish(Event.Updated, { sessionID, todos: updated })
126+
return newTodo
127+
}
91128
}
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+
})

0 commit comments

Comments
 (0)