Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton f0308ec44a fix(cli): prevent stale service replacement 2026-08-04 16:25:14 -04:00
74 changed files with 453 additions and 519 deletions
+8 -8
View File
@@ -24,7 +24,7 @@ export type ToolExecute<Parameters extends ToolSchema<any>, Success extends Tool
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
export interface ToolModelOutputInput<Parameters, Output> {
readonly id: ToolCallPart["id"]
readonly callID: ToolCallPart["id"]
readonly parameters: Parameters
readonly output: Output
}
@@ -59,7 +59,7 @@ export interface Definition<Parameters extends ToolSchema<any>, Success extends
/** @internal */
readonly _project: (
parameters: Schema.Schema.Type<Parameters>,
id: ToolCallPart["id"],
callID: ToolCallPart["id"],
output: unknown,
) => ToolOutputType
/** @internal */
@@ -173,8 +173,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
toStructuredOutput: config.toStructuredOutput,
_decode: Effect.succeed,
_encode: Effect.succeed,
_project: (parameters, id, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
_project: (parameters, callID, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
_legacyResult: config.toModelOutput === undefined && config.toStructuredOutput === undefined,
_definition: new ToolDefinition({
name: "",
@@ -193,8 +193,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
toStructuredOutput: config.toStructuredOutput,
_decode: Schema.decodeUnknownEffect(config.parameters),
_encode: Schema.encodeEffect(config.success),
_project: (parameters, id, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
_project: (parameters, callID, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
_legacyResult: false,
_definition: new ToolDefinition({
name: "",
@@ -239,12 +239,12 @@ const project = (
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
toStructuredOutput: ((output: unknown) => unknown) | undefined,
parameters: unknown,
id: ToolCallPart["id"],
callID: ToolCallPart["id"],
output: unknown,
): ToolOutputType =>
ToolOutput.make(
toStructuredOutput?.(output) ?? output,
toModelOutput?.({ id, parameters, output }) ??
toModelOutput?.({ callID, parameters, output }) ??
(typeof output === "string" ? [{ type: "text", text: output }] : []),
)
+1 -1
View File
@@ -169,7 +169,7 @@ describe("LLMClient tools", () => {
LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }),
)
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(calls).toEqual([{ callID: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
expect(dispatched.events).toEqual([
+2 -2
View File
@@ -27,8 +27,8 @@ Tool.make({
parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ forecast: Schema.NumberFromString }),
execute: () => Effect.succeed({ forecast: 1 }),
toModelOutput: ({ id, parameters, output }) => [
{ type: "text", text: `${id}:${parameters.city}:${output.forecast}` },
toModelOutput: ({ callID, parameters, output }) => [
{ type: "text", text: `${callID}:${parameters.city}:${output.forecast}` },
],
})
+14 -14
View File
@@ -72,7 +72,7 @@ export async function streamTurn(input: {
if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
await replyPermission({
client: input.client,
connection: input.connection,
@@ -120,11 +120,11 @@ export async function streamTurn(input: {
}
if (event.type === "session.tool.input.started") {
assistantMessageID = event.data.assistantMessageID
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] })
tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
await update({
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: event.data.id,
toolCallId: event.data.callID,
toolName: event.data.name,
state: { input: {} },
cwd: input.cwd,
@@ -134,13 +134,13 @@ export async function streamTurn(input: {
}
if (event.type === "session.tool.called") {
assistantMessageID = event.data.assistantMessageID
const current = tools.get(event.data.id) ?? emptyToolState()
const current = tools.get(event.data.callID) ?? emptyToolState()
current.input = event.data.input
tools.set(event.data.id, current)
tools.set(event.data.callID, current)
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.id,
toolCallId: event.data.callID,
toolName: current.name,
state: { input: current.input },
cwd: input.cwd,
@@ -149,13 +149,13 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(event.data.id)
const current = tools.get(event.data.callID)
if (!current) continue
current.metadata = event.data.metadata
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.id,
toolCallId: event.data.callID,
toolName: current.name,
state: { input: current.input },
cwd: input.cwd,
@@ -164,8 +164,8 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.success") {
const current = tools.get(event.data.id) ?? emptyToolState()
tools.delete(event.data.id)
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
await syncEditedFiles({
connection: input.connection,
writeTextFile: input.writeTextFile,
@@ -178,7 +178,7 @@ export async function streamTurn(input: {
await update({
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: event.data.id,
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
metadata: event.data.metadata,
@@ -188,12 +188,12 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.id) ?? emptyToolState()
tools.delete(event.data.id)
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
await update({
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: event.data.id,
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
metadata: event.data.metadata ?? current.metadata,
+1 -1
View File
@@ -31,7 +31,7 @@ export async function replyPermission(input: {
sessionId: input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.event.data.source?.id ?? input.event.data.id,
toolCallId: input.event.data.source?.callID ?? input.event.data.id,
toolName,
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
cwd: input.cwd,
+18 -18
View File
@@ -300,7 +300,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.input.started") {
flushStep()
tools.set(toolKey(event.data.assistantMessageID, event.data.id), {
tools.set(toolKey(event.data.assistantMessageID, event.data.callID), {
id: partID(event.id),
timestamp: time,
assistantMessageID: event.data.assistantMessageID,
@@ -312,18 +312,18 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.tool.input.ended") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) current.raw = event.data.text
continue
}
if (event.type === "session.tool.input.delta") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) current.raw = (current.raw ?? "") + event.data.delta
continue
}
if (event.type === "session.tool.called") {
flushStep()
const key = toolKey(event.data.assistantMessageID, event.data.id)
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key)
tools.set(key, {
id: current?.id ?? partID(event.id),
@@ -340,18 +340,18 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) {
current.metadata = event.data.metadata
}
continue
}
if (event.type === "session.tool.success") {
const key = toolKey(event.data.assistantMessageID, event.data.id)
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.id,
id: event.data.callID,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
@@ -365,11 +365,11 @@ export async function runNonInteractivePrompt(input: Input) {
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const part: MiniToolPart = {
partID: current.id,
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
id: event.data.id,
callID: event.data.callID,
tool: current.tool,
state: {
status: "completed",
@@ -392,14 +392,14 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.tool.failed") {
const key = toolKey(event.data.assistantMessageID, event.data.id)
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const error = event.data.error.message
const metadata = event.data.metadata ?? current.metadata
const content = event.data.content ?? nonEmptyToolContent(current.content)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.id,
id: event.data.callID,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
@@ -414,11 +414,11 @@ export async function runNonInteractivePrompt(input: Input) {
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const part: MiniToolPart = {
partID: current.id,
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
id: event.data.id,
callID: event.data.callID,
tool: current.tool,
state: {
status: "error",
@@ -578,11 +578,11 @@ export async function runNonInteractivePrompt(input: Input) {
const key = toolKey(message.id, item.id)
if (renderedTools.has(key) || item.state.status === "streaming" || item.state.status === "running") continue
const part: MiniToolPart = {
partID: projectedPartID(message.id, `tool-${item.id}`),
id: projectedPartID(message.id, `tool-${item.id}`),
sessionID: input.sessionID,
messageID: message.id,
type: "tool",
id: item.id,
callID: item.id,
tool: item.name,
state:
item.state.status === "completed"
@@ -771,8 +771,8 @@ function partID(eventID: string) {
return `prt_${eventID.replace(/^evt_/, "")}`
}
function toolKey(messageID: string, id: string) {
return `${messageID}\u0000${id}`
function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
}
function contentKey(messageID: string, ordinal: number) {
@@ -786,7 +786,7 @@ function projectedPartID(messageID: string, part: string) {
function fallbackTool(event: {
id: string
created: number
data: { assistantMessageID: string; id: string }
data: { assistantMessageID: string; callID: string }
}): ToolState {
return {
id: partID(event.id),
@@ -5,6 +5,7 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
import semver from "semver"
import { selfCommand } from "../util/process"
// The CLI's service configuration file, plus the Service.EnsureOptions binding that
@@ -104,10 +105,21 @@ export const options = Effect.fnUntraced(function* () {
return {
file,
version: OPENCODE_VERSION,
canReplace: (version: string | undefined) => canReplaceVersion(version),
command: [...selfCommand(), "serve", "--service"],
}
})
export function canReplaceVersion(serverVersion: string | undefined, clientVersion = OPENCODE_VERSION) {
if (serverVersion === undefined) return true
// Preview versions end in `<channel>-<build>[.<attempt>]`. Convert the build
// to a numeric semver identifier so next-15000 sorts after next-9999.
const server = serverVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
const client = clientVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
if (!semver.valid(server) || !semver.valid(client)) return true
return semver.lt(server, client)
}
export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile, legacyConfigFile } = yield* paths
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
+8 -8
View File
@@ -200,7 +200,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
callID: "call_ok",
name: "shell",
}),
)
@@ -208,7 +208,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
callID: "call_ok",
input: { command: "printf done", workdir: "sub" },
executed: false,
}),
@@ -217,7 +217,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
callID: "call_ok",
metadata: { phase: 1 },
}),
)
@@ -225,7 +225,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
callID: "call_ok",
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
executed: true,
@@ -235,7 +235,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
callID: "call_fail",
name: "read",
}),
)
@@ -243,7 +243,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
callID: "call_fail",
input: { path: "/workspace/missing.ts" },
executed: false,
}),
@@ -252,7 +252,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
callID: "call_fail",
metadata: { bytes: 0 },
}),
)
@@ -260,7 +260,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.failed", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
callID: "call_fail",
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
@@ -43,14 +43,14 @@ describe("acp permission behavior", () => {
permissionAsked("ses_allow", "perm_once", {
action: "shell",
metadata: { command: "printf hello" },
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
source: { type: "tool", messageID: "msg_allow", callID: "call_once" },
}),
)
send(
permissionAsked("ses_allow", "perm_always", {
action: "read",
metadata: { path: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
@@ -166,7 +166,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
id: "call_edit",
callID: "call_edit",
name: "edit",
}),
)
@@ -174,7 +174,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
id: "call_edit",
callID: "call_edit",
input: { path: "file.ts", oldString: "before", newString: "after" },
executed: false,
}),
@@ -182,7 +182,7 @@ describe("acp permission behavior", () => {
send(
permissionAsked("ses_edit", "perm_edit", {
action: "edit",
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
source: { type: "tool", messageID: "msg_edit", callID: "call_edit" },
}),
)
},
@@ -192,7 +192,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
id: "call_edit",
callID: "call_edit",
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
content: [{ type: "text", text: "edited" }],
executed: true,
@@ -256,7 +256,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
id: "call_patch",
callID: "call_patch",
name: "patch",
}),
)
@@ -264,7 +264,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
id: "call_patch",
callID: "call_patch",
input: { patchText },
executed: false,
}),
@@ -272,7 +272,7 @@ describe("acp permission behavior", () => {
send(
permissionAsked("ses_patch", "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
source: { type: "tool", messageID: "msg_patch", callID: "call_patch" },
}),
)
},
@@ -285,7 +285,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
id: "call_patch",
callID: "call_patch",
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
content: [{ type: "text", text: "patched" }],
executed: true,
@@ -499,7 +499,7 @@ function permissionAsked(
input: {
readonly action?: string
readonly metadata?: Record<string, unknown>
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
} = {},
) {
return ephemeralEvent("permission.asked", {
+8 -8
View File
@@ -109,7 +109,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
id: "call_failed_tool",
callID: "call_failed_tool",
name: "shell",
},
},
@@ -121,7 +121,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
id: "call_failed_tool",
callID: "call_failed_tool",
input: { command: "printf partial && false" },
executed: true,
},
@@ -133,7 +133,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
id: "call_failed_tool",
callID: "call_failed_tool",
metadata: { checkpoint: 1 },
},
},
@@ -145,7 +145,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
id: "call_failed_tool",
callID: "call_failed_tool",
error: { type: "unknown", message: "tool failed" },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
@@ -168,7 +168,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
id: "call_grep",
callID: "call_grep",
name: "grep",
},
},
@@ -180,7 +180,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
id: "call_grep",
callID: "call_grep",
input: { pattern: "needle" },
executed: true,
},
@@ -193,7 +193,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
id: "call_grep",
callID: "call_grep",
metadata: { matches: 2 },
content: [{ type: "text", text }],
executed: false,
@@ -561,7 +561,7 @@ describe("runNonInteractivePrompt", () => {
type: "tool_use",
part: {
type: "tool",
id: "call_failed_tool",
callID: "call_failed_tool",
tool: "shell",
state: {
status: "error",
+9
View File
@@ -47,6 +47,15 @@ test("service filenames share release channels and identify preview channels", (
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
})
test("only newer clients replace managed service versions", () => {
expect(ServiceConfig.canReplaceVersion("1.2.3", "1.2.4")).toBe(true)
expect(ServiceConfig.canReplaceVersion("1.2.4", "1.2.3")).toBe(false)
expect(ServiceConfig.canReplaceVersion("1.2.3", "1.2.3")).toBe(false)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-9999", "0.0.0-next-15000")).toBe(true)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000", "0.0.0-next-9999")).toBe(false)
expect(ServiceConfig.canReplaceVersion(undefined, "1.2.3")).toBe(true)
})
test("service config migrates from the hashed channel filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-"))
const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!)
+5 -13
View File
@@ -605,7 +605,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly callID: string
readonly name: string
}
}
@@ -619,7 +619,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly callID: string
readonly text: string
}
}
@@ -633,7 +633,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly callID: string
readonly input: { readonly [x: string]: unknown }
readonly executed: boolean
readonly state?: SessionMessage.ProviderState | undefined
@@ -649,7 +649,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly callID: string
readonly content: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -685,7 +685,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly callID: string
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly content?:
| readonly [
@@ -764,14 +764,6 @@ export type Endpoint5_26Output =
readonly reason: "auto" | "manual"
readonly text: string
readonly recent: string
readonly media?:
| ReadonlyArray<{
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | undefined
}>
| undefined
}
}
| {
+9 -1
View File
@@ -3,7 +3,13 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
import {
VersionMismatchError,
type DiscoverOptions,
type Endpoint,
type EnsureOptions,
type StopOptions,
} from "../service.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -89,6 +95,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
if (options.canReplace?.(service.version) === false)
return yield* Effect.fail(new VersionMismatchError(options.version, service.version))
yield* announce("version-mismatch", service.version)
yield* kill(service, options).pipe(Effect.ignore)
lastSpawn = 0
+37 -38
View File
@@ -121,6 +121,17 @@ export type SessionMessageCompactionRunning = {
recent: string
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
summary: string
recent: string
}
export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
@@ -286,7 +297,7 @@ export type FormExternalField = { key: string; type: "external"; url: string; ti
export type FormValue = string | number | boolean | Array<string>
export type PermissionSource = { type: "tool"; messageID: string; id: string }
export type PermissionSource = { type: "tool"; messageID: string; callID: string }
export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string }
@@ -475,7 +486,7 @@ export type Pty = {
export type QuestionOption = { label: string; description: string }
export type QuestionTool = { messageID: string; id: string }
export type QuestionTool = { messageID: string; callID: string }
export type QuestionAnswer = Array<string>
@@ -733,7 +744,7 @@ export type SessionToolInputStarted = {
type: "session.tool.input.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; name: string }
data: { sessionID: string; assistantMessageID: string; callID: string; name: string }
}
export type SessionToolInputEnded = {
@@ -743,7 +754,7 @@ export type SessionToolInputEnded = {
type: "session.tool.input.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; text: string }
data: { sessionID: string; assistantMessageID: string; callID: string; text: string }
}
export type SessionCompactionAdmitted = {
@@ -766,6 +777,16 @@ export type SessionCompactionStarted = {
data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string }
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
}
export type SessionRevertCleared = {
id: string
created: number
@@ -894,7 +915,7 @@ export type SessionToolInputDelta = {
metadata?: { [x: string]: any }
type: "session.tool.input.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; delta: string }
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
}
export type SessionToolProgress = {
@@ -903,7 +924,7 @@ export type SessionToolProgress = {
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } }
data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } }
}
export type SessionCompactionDelta = {
@@ -1206,18 +1227,6 @@ export type SessionMessageAssistantReasoning = {
export type ToolContent = ToolTextContent | ToolFileContent
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
summary: string
recent: string
media?: Array<ToolFileContent>
}
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
export type SessionMessageCompactionFailed = {
@@ -1371,7 +1380,7 @@ export type SessionToolCalled = {
data: {
sessionID: string
assistantMessageID: string
id: string
callID: string
input: { [x: string]: any }
executed: boolean
state?: SessionMessageProviderState7
@@ -1380,16 +1389,6 @@ export type SessionToolCalled = {
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string; media?: Array<ToolFileContent1> }
}
export type ModelCompatibility = { reasoningField?: ModelReasoningField }
export type ModelCost = {
@@ -1832,7 +1831,7 @@ export type SessionToolSuccess = {
data: {
sessionID: string
assistantMessageID: string
id: string
callID: string
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
executed: boolean
@@ -1850,7 +1849,7 @@ export type SessionToolFailed = {
data: {
sessionID: string
assistantMessageID: string
id: string
callID: string
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
@@ -4473,7 +4472,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null
}["id"]
readonly action: {
@@ -4482,7 +4481,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null
}["action"]
readonly resources: {
@@ -4491,7 +4490,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null
}["resources"]
readonly save?: {
@@ -4500,7 +4499,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null
}["save"]
readonly metadata?: {
@@ -4509,7 +4508,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null
}["metadata"]
readonly source?: {
@@ -4518,7 +4517,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null
}["source"]
readonly agent?: {
@@ -4527,7 +4526,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null
}["agent"]
}
+10 -1
View File
@@ -2,7 +2,14 @@ import { readFile } from "node:fs/promises"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
import {
VersionMismatchError,
type DiscoverOptions,
type Endpoint,
type Info,
type EnsureOptions,
type StopOptions,
} from "../service.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -70,6 +77,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
if (options.canReplace?.(service.version) === false)
throw new VersionMismatchError(options.version, service.version)
announce("version-mismatch", service.version)
await kill(service, options).catch(() => undefined)
lastSpawn = 0
+14
View File
@@ -28,10 +28,24 @@ export type EnsureReason = "missing" | "version-mismatch"
export type EnsureOptions = DiscoverOptions & {
/** Service command and arguments. Defaults to `opencode serve --service`. */
readonly command?: ReadonlyArray<string>
/** Decide whether a version-mismatched service may be replaced. Defaults to true. */
readonly canReplace?: (version: string | undefined) => boolean
/** Called once before spawning a new service process. */
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
}
/** A healthy service exists, but the caller's replacement policy protects it. */
export class VersionMismatchError extends Error {
override readonly name = "VersionMismatchError"
constructor(
readonly clientVersion: string | undefined,
readonly serverVersion: string | undefined,
) {
super(`Client version ${clientVersion ?? "unknown"} cannot replace server version ${serverVersion ?? "unknown"}`)
}
}
/** Options used to stop the local OpenCode service. */
export type StopOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
@@ -70,6 +70,25 @@ test("reports a failed registered service", async () => {
)
})
test("does not replace a version rejected by the caller", async () => {
const registration = await setup("graceful")
const directory = await temp()
const contender = join(directory, "contender.json")
const info = await Bun.file(registration).json()
await expect(
Service.ensure({
file: registration,
version: "old",
canReplace: () => false,
command: [process.execPath, fixture, contender, "record-start"],
}),
).rejects.toThrow("Client version old cannot replace server version test")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(process.kill(info.pid, 0)).toBe(true)
})
test("requests graceful stop of the exact service instance", async () => {
const registration = await setup("graceful")
const info = await Bun.file(registration).json()
+22
View File
@@ -107,6 +107,28 @@ test("does not spawn contenders while an incompatible service rejects replacemen
expect(existing.exitCode).toBe(null)
})
test("does not replace a version rejected by the caller", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const contender = join(directory, "contender.json")
const existing = spawn(registration, "graceful")
await waitForFile(registration)
await expect(
run(
Service.ensure({
file: registration,
version: "old",
canReplace: () => false,
command: [process.execPath, fixture, contender, "record-start"],
}),
),
).rejects.toThrow("Client version old cannot replace server version test")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(existing.exitCode).toBe(null)
})
test("a legacy health response is still replaced", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+7 -61
View File
@@ -2,7 +2,6 @@ export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Tool } from "@opencode-ai/schema/tool"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config"
import { Bus } from "../bus"
@@ -20,10 +19,9 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
const DEFAULT_KEEP_TOKENS = 8_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const MEDIA_TOKEN_ESTIMATE = 1_500
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Objective
@@ -92,7 +90,6 @@ type Plan = {
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
readonly recent: string
readonly media: readonly Tool.FileContent[]
readonly inputID?: SessionMessage.ID
}
@@ -111,15 +108,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
const isMedia = (mime: string) => {
const value = mime.toLowerCase()
return (
value.startsWith("image/") ||
value.startsWith("audio/") ||
value.startsWith("video/") ||
value === "application/pdf"
)
}
export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
content
.map((item) =>
@@ -127,24 +115,6 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
)
.join("\n")
const isEstimatedMedia = (mime: string) =>
mime.toLowerCase().startsWith("image/") || mime.toLowerCase() === "application/pdf"
export const estimateMediaTokens = (message: SessionMessage.Info) => {
if (message.type === "user")
return (message.files?.filter((file) => isEstimatedMedia(file.mime)).length ?? 0) * MEDIA_TOKEN_ESTIMATE
if (message.type !== "assistant") return 0
return (
message.content
.flatMap((part) =>
part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")
? (part.state.content ?? [])
: [],
)
.filter((content) => content.type === "file" && isEstimatedMedia(content.mime)).length * MEDIA_TOKEN_ESTIMATE
)
}
const serialize = (message: SessionMessage.Info) => {
if (message.type === "user") {
const files =
@@ -192,11 +162,7 @@ const settings = (documents: readonly Config.Entry[]) => {
const select = (
messages: readonly SessionMessage.Info[],
tokens: number,
): {
readonly head: string
readonly recent: string
readonly media: readonly Tool.FileContent[]
} | undefined => {
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = messages
.filter((message) => message.type !== "compaction" && message.type !== "system")
.flatMap((message) => {
@@ -207,7 +173,7 @@ const select = (
let total = 0
let split = conversation.length
for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index].text) + estimateMediaTokens(conversation[index].message)
const next = total + Token.estimate(conversation[index].text)
if (split < conversation.length && next > tokens) break
total = next
split = index
@@ -217,33 +183,15 @@ const select = (
const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
if (latestUser > 0) split = latestUser
}
const tail = conversation.slice(split)
return {
head: conversation
.slice(0, split)
.map((item) => item.text)
.join("\n\n"),
recent: tail.map((item) => item.text).join("\n\n"),
media: tail.flatMap((item) => {
if (item.message.type === "user")
return (
item.message.files
?.filter((file) => isMedia(file.mime))
.map((file) => ({
type: "file" as const,
uri: `data:${file.mime};base64,${file.data}`,
mime: file.mime,
name: file.name,
})) ?? []
)
if (item.message.type !== "assistant") return []
return item.message.content.flatMap((part) => {
if (part.type !== "tool" || (part.state.status !== "completed" && part.state.status !== "error")) return []
return (part.state.content ?? []).flatMap((content) =>
content.type === "file" && isMedia(content.mime) ? [content] : [],
)
})
}),
recent: conversation
.slice(split)
.map((item) => item.text)
.join("\n\n"),
}
}
@@ -271,7 +219,6 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
}),
recent: summarizeRecent ? "" : selected.recent,
media: summarizeRecent ? [] : selected.media,
}
}
@@ -371,7 +318,6 @@ const make = (dependencies: Dependencies) => {
reason: plan.reason,
text: summary,
recent: plan.recent,
media: plan.media,
})
return { status: "completed" as const }
})
+8 -10
View File
@@ -111,9 +111,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
type DraftText = WritableDraft<SessionMessage.AssistantText>
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
assistant?.content.findLast(
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
)
const latestText = (assistant: DraftAssistant | undefined) =>
@@ -331,7 +331,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
castDraft(
SessionMessage.AssistantTool.make({
type: "tool",
id: event.data.id,
id: event.data.callID,
name: event.data.name,
time: { created: event.created },
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
@@ -343,13 +343,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.tool.input.delta": () => Effect.void,
"session.tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id)
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "streaming") match.state.input = event.data.text
})
},
"session.tool.called": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id)
const match = latestTool(draft, event.data.callID)
if (match) {
match.executed = event.data.executed
match.providerState = event.data.state
@@ -366,7 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.tool.progress": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id)
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.state.metadata = event.data.metadata
}
@@ -376,7 +376,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
// never reaches into ephemeral progress history.
"session.tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id)
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
@@ -394,7 +394,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.tool.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id)
const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "streaming" || match.state.status === "running")) {
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
@@ -480,7 +480,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
media: event.data.media,
})
return
}
@@ -493,7 +492,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
media: event.data.media,
time: { created: event.created },
}),
)
+1 -1
View File
@@ -488,7 +488,7 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID: message.id,
id: tool.id,
callID: tool.id,
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
executed: tool.executed === true,
})
@@ -78,7 +78,7 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
* between a check (`tool.settled`, `stepStarted`, ...) and its mark, so check-and-mark
* stays atomic under cooperative scheduling. (2) Never require a cross-source event
* order: each publishing fiber is sequential, so per-source order holds by construction,
* and consumers fold by id/ordinal rather than global position.
* and consumers fold by callID/ordinal rather than global position.
*/
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const tools = new Map<
@@ -188,14 +188,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}),
true,
)
const toolInput = fragments("tool input", (id, value) =>
const toolInput = fragments("tool input", (callID, value) =>
Effect.gen(function* () {
const tool = tools.get(id)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
const tool = tools.get(callID)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${callID}`))
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id,
callID,
text: value,
})
}),
@@ -225,7 +225,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID,
assistantMessageID,
id: event.id,
callID: event.id,
name: event.name,
})
})
@@ -258,7 +258,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
callID: event.id,
error: {
type: "tool.input-json",
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
@@ -272,14 +272,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* flushFragments()
})
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error) {
const tool = tools.get(id)
const failTool = Effect.fnUntraced(function* (callID: string, error: SessionError.Error) {
const tool = tools.get(callID)
if (!tool || tool.settled) return false
tool.settled = true
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id,
callID,
error,
...failureSnapshot(tool),
executed: tool.providerExecuted,
@@ -289,10 +289,10 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
let failed = false
for (const [id, tool] of tools) {
for (const [callID, tool] of tools) {
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
continue
failed = (yield* failTool(id, error)) || failed
failed = (yield* failTool(callID, error)) || failed
}
return failed
})
@@ -328,9 +328,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return yield* failTools(error, scope)
})
const assistantMessageIDForTool = (id: string) => {
const tool = tools.get(id)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${id}`))
const assistantMessageIDForTool = (callID: string) => {
const tool = tools.get(callID)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
}
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
@@ -399,7 +399,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
callID: event.id,
delta: event.text,
})
return
@@ -424,7 +424,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
callID: event.id,
input: asRecord(event.input),
executed: tool.providerExecuted,
state: providerState(event.providerMetadata),
@@ -450,7 +450,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
callID: event.id,
error: { type: "tool.execution", message: stringify(event.result.value) },
...failureSnapshot(tool),
executed,
@@ -461,7 +461,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
callID: event.id,
content: hostedContent(event.result),
executed,
resultState,
@@ -478,7 +478,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
callID: event.id,
error:
event.message === `Unknown tool: ${event.name}`
? { type: "tool.unknown", message: event.message }
@@ -508,30 +508,30 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}
})
const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) {
const tool = tools.get(id)
const progress = Effect.fnUntraced(function* (callID: string, update: Tool.Metadata) {
const tool = tools.get(callID)
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
tool.progress = update
yield* bus.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id,
callID,
metadata: update,
})
})
/** Publishes one canonical terminal event for a locally executed tool call. */
const toolExecution = Effect.fnUntraced(function* (
id: string,
callID: string,
name: string,
result: Tool.Result,
) {
const tool = tools.get(id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
const tool = tools.get(callID)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
if (tool.name !== name)
return yield* Effect.die(new Error(`Tool execution name changed for ${id}: ${tool.name} -> ${name}`))
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${id}`))
return yield* Effect.die(new Error(`Tool execution name changed for ${callID}: ${tool.name} -> ${name}`))
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
tool.settled = true
const content =
typeof result.content === "string"
@@ -539,11 +539,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
: result.content === undefined
? []
: [...result.content]
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${callID}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id,
callID,
content: [content[0], ...content.slice(1)],
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
executed: tool.providerExecuted,
@@ -222,8 +222,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
Message.make({
id: message.id,
role: "user",
content: [
Message.text(`<conversation-checkpoint>
content: `<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
@@ -233,14 +232,7 @@ ${message.summary}
<recent-context>
${message.recent}
</recent-context>
</conversation-checkpoint>`),
...(message.media ?? []).map((media) => ({
type: "media" as const,
mediaType: media.mime,
data: media.uri,
filename: media.name,
})),
],
</conversation-checkpoint>`,
metadata: message.metadata,
}),
]
+3 -3
View File
@@ -93,7 +93,7 @@ const layer = Layer.effect(
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
id: context.id,
callID: context.callID,
input,
}
yield* hooks.trigger("tool", "execute.before", beforeEvent)
@@ -106,7 +106,7 @@ const layer = Layer.effect(
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
id: context.id,
callID: context.callID,
input: beforeEvent.input,
}
if ("failure" in execution) {
@@ -228,7 +228,7 @@ const layer = Layer.effect(
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
callID: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
+1 -1
View File
@@ -22,7 +22,7 @@ Location-scoped built-in layers acquire `Permission.Service` and every other req
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
callID: context.callID,
}
```
+1 -1
View File
@@ -59,7 +59,7 @@ export const layer = Layer.effectDiscard(
source: {
type: "tool",
messageID: context.messageID,
id: context.id,
callID: context.callID,
},
})
const result = yield* mcp
+1 -1
View File
@@ -129,7 +129,7 @@ export const Plugin = {
const permissionSource = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
callID: context.callID,
}
if (input.oldString === input.newString) {
return yield* new ToolFailure({
+1 -1
View File
@@ -61,7 +61,7 @@ export const Plugin = {
execute: (input, context) =>
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
+1 -1
View File
@@ -76,7 +76,7 @@ export const Plugin = {
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
+1 -1
View File
@@ -95,7 +95,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
callID: context.callID,
}
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
+2 -2
View File
@@ -70,7 +70,7 @@ export const Plugin = {
resources: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
.pipe(
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
@@ -81,7 +81,7 @@ export const Plugin = {
title: "Questions",
metadata: {
kind: "question",
tool: { messageID: context.messageID, id: context.id },
tool: { messageID: context.messageID, callID: context.callID },
},
fields: [
toField(input.questions[0], 0),
+1 -1
View File
@@ -56,7 +56,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
callID: context.callID,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory
+7 -7
View File
@@ -89,10 +89,10 @@ export const Plugin = {
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID,
id: string,
callID: string,
command: string,
) {
yield* runtime.job.wait({ id: id }).pipe(
yield* runtime.job.wait({ id: callID }).pipe(
Effect.flatMap((result) => {
const state =
result.info?.status === "completed"
@@ -111,7 +111,7 @@ export const Plugin = {
: "Command cancelled"
return runtime.session.synthetic({
sessionID,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: { source: "shell", state },
})
@@ -134,7 +134,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
callID: context.callID,
}
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
let finalTimeout = timeout
@@ -227,7 +227,7 @@ export const Plugin = {
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.id,
id: context.callID,
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
@@ -236,7 +236,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@@ -250,7 +250,7 @@ export const Plugin = {
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
+1 -1
View File
@@ -75,7 +75,7 @@ export const Plugin = {
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const directory = path.dirname(skill.location)
const files =
+1 -1
View File
@@ -159,7 +159,7 @@ export const Plugin = {
source: {
type: "tool",
messageID: context.messageID,
id: context.id,
callID: context.callID,
},
})
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
+1 -1
View File
@@ -139,7 +139,7 @@ export const Plugin = {
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const { body, contentType } = yield* Effect.gen(function* () {
+1 -1
View File
@@ -47,7 +47,7 @@ export const Plugin = {
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => {
+1 -1
View File
@@ -67,7 +67,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
callID: context.callID,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
@@ -714,7 +714,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
id: "call_hosted",
callID: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
@@ -725,7 +725,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
id: "call_failed",
callID: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
@@ -795,7 +795,7 @@ describe("DatabaseMigration", () => {
expect(JSON.parse(event!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
id: "call_hosted",
callID: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
@@ -806,7 +806,7 @@ describe("DatabaseMigration", () => {
expect(JSON.parse(failedEvent!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
id: "call_failed",
callID: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
+1 -1
View File
@@ -928,7 +928,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
source: {
type: "tool",
messageID: toolIdentity.messageID,
id: "call_mcp_permission",
callID: "call_mcp_permission",
},
})
expect(calls).toBe(0)
+5 -71
View File
@@ -22,7 +22,6 @@ import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Base64, FileAttachment } from "@opencode-ai/schema/prompt"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
@@ -114,24 +113,6 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).not.toContain(base64)
})
test("compaction estimates media context without counting base64", () => {
const image = FileAttachment.make({
data: Base64.make("a".repeat(10_000)),
mime: "image/png",
source: { type: "inline" },
name: "image.png",
})
const message = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Compare these images.",
files: [image, image, FileAttachment.make({ ...image, mime: "application/pdf" })],
time: { created: DateTime.makeUnsafe(0) },
})
expect(SessionCompaction.estimateMediaTokens(message)).toBe(4_500)
})
test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
@@ -197,7 +178,7 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
}),
)
it.effect("manual compaction preserves ordered media in the retained tail", () =>
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
const db = (yield* Database.Service).db
@@ -209,35 +190,9 @@ it.effect("manual compaction preserves ordered media in the retained tail", () =
const userMessage = {
id: SessionMessage.ID.create(),
type: "user" as const,
text: `Manual compaction should include this older conversation. ${"older context ".repeat(4_500)}`,
text: "Manual compaction should include this short conversation.",
time: { created: DateTime.makeUnsafe(0) },
}
const recentMessage = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Compare the retained media.",
files: [
FileAttachment.make({
data: Base64.make("aW1hZ2U="),
mime: "application/pdf",
source: { type: "inline" },
name: "prompt.pdf",
}),
FileAttachment.make({
data: Base64.make("aW1hZ2U="),
mime: "image/png",
source: { type: "inline" },
name: "prompt.png",
}),
],
time: { created: DateTime.makeUnsafe(1) },
})
const latestMessage = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Newest text after the retained media.",
time: { created: DateTime.makeUnsafe(2) },
})
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
@@ -273,7 +228,7 @@ it.effect("manual compaction preserves ordered media in the retained tail", () =
expect(
yield* compaction.compactManual({
session,
messages: [userMessage, recentMessage, latestMessage],
messages: [userMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toEqual({ status: "completed" })
@@ -290,30 +245,9 @@ it.effect("manual compaction preserves ordered media in the retained tail", () =
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this older conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(yield* store.context(sessionID)).toMatchObject([
{
type: "compaction",
reason: "manual",
summary: "manual summary",
recent: expect.stringMatching(
/\[User\]: Compare the retained media\.\n\[Attached application\/pdf: prompt\.pdf\]\n\[Attached image\/png: prompt\.png\]\n\n\[User\]: Newest text after the retained media\./,
),
media: [
{
type: "file",
uri: "data:application/pdf;base64,aW1hZ2U=",
mime: "application/pdf",
name: "prompt.pdf",
},
{
type: "file",
uri: "data:image/png;base64,aW1hZ2U=",
mime: "image/png",
name: "prompt.png",
},
],
},
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
])
expect(yield* store.get(sessionID)).toMatchObject({
cost: 0.0000233,
+3 -3
View File
@@ -255,19 +255,19 @@ it.effect("generates from fresh settled Session context without durable mutation
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID: activeAssistant,
id: "active-call",
callID: "active-call",
name: "echo",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID: activeAssistant,
id: "active-call",
callID: "active-call",
text: "{}",
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID: activeAssistant,
id: "active-call",
callID: "active-call",
input: {},
executed: false,
})
@@ -102,15 +102,7 @@ describe("toLLMMessages", () => {
status: "completed",
reason: "auto",
summary: "Earlier work",
recent: "Recent work\n[Attached image/png: retained.png]",
media: [
{
type: "file",
uri: "data:image/png;base64,aGVsbG8=",
mime: "image/png",
name: "retained.png",
},
],
recent: "Recent work",
time: { created },
}),
],
@@ -150,16 +142,9 @@ Earlier work
<recent-context>
Recent work
[Attached image/png: retained.png]
</recent-context>
</conversation-checkpoint>`,
},
{
type: "media",
mediaType: "image/png",
data: "data:image/png;base64,aGVsbG8=",
filename: "retained.png",
},
],
])
})
@@ -237,7 +237,7 @@ test("success event data can carry provider-executed result state", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID,
assistantMessageID: SessionMessage.ID.create(),
id: "call-old",
callID: "call-old",
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
executed: true,
resultState: {
@@ -339,7 +339,7 @@ describe("Tool", () => {
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
})
expect(contexts).toEqual([
{ sessionID, ...identity, id: Tool.CallID.make("call-context"), progress: expect.any(Function) },
{ sessionID, ...identity, callID: Tool.CallID.make("call-context"), progress: expect.any(Function) },
])
}),
)
+19 -19
View File
@@ -935,7 +935,7 @@ describe("SessionRunnerLLM", () => {
yield* TestLLM.push(TestLLM.tool("call-location", "location_context", { query: "hello" }), [])
const bus = yield* Bus.Service
const progressFiber = yield* bus.subscribe(SessionEvent.Tool.Progress).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.id === "call-location"),
Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-location"),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
@@ -949,7 +949,7 @@ describe("SessionRunnerLLM", () => {
sessionID,
agent: Agent.ID.make("build"),
messageID: expect.stringMatching(/^msg_/),
id: Tool.CallID.make("call-location"),
callID: Tool.CallID.make("call-location"),
progress: expect.any(Function),
},
])
@@ -2382,7 +2382,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"])
expect(authorizations).toMatchObject([{ sessionID, id: "call-echo" }])
expect(authorizations).toMatchObject([{ sessionID, callID: "call-echo" }])
expect(executions).toEqual(["hello"])
const context = yield* session.context(sessionID)
expect(context).toMatchObject([
@@ -2994,19 +2994,19 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
id: "call-interrupted",
callID: "call-interrupted",
name: "echo",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID,
id: "call-interrupted",
callID: "call-interrupted",
text: '{"text":"stale"}',
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
id: "call-interrupted",
callID: "call-interrupted",
input: { text: "stale" },
executed: false,
})
@@ -3051,19 +3051,19 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
id: "call-hosted-interrupted",
callID: "call-hosted-interrupted",
name: "web_search",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID,
id: "call-hosted-interrupted",
callID: "call-hosted-interrupted",
text: '{"query":"stale"}',
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
id: "call-hosted-interrupted",
callID: "call-hosted-interrupted",
input: { query: "stale" },
executed: true,
state: { itemId: "call-hosted-interrupted" },
@@ -3102,7 +3102,7 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
id: "call-pending-interrupted",
callID: "call-pending-interrupted",
name: "echo",
})
requests.length = 0
@@ -4120,7 +4120,7 @@ describe("SessionRunnerLLM", () => {
{
type: "session.tool.failed.2",
data: {
id: "call-malformed",
callID: "call-malformed",
error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" },
},
},
@@ -4220,7 +4220,7 @@ describe("SessionRunnerLLM", () => {
.all()
.pipe(Effect.orDie)
expect(durable.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
id: "call-malformed",
callID: "call-malformed",
text: raw,
})
}),
@@ -4616,13 +4616,13 @@ describe("SessionRunnerLLM", () => {
const assistant = requireAssistant(yield* session.context(sessionID))
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
expect(bus.map((event) => ({ type: event.type, id: event.data.id }))).toEqual([
{ type: "session.step.started.1", id: undefined },
{ type: "session.tool.called.1", id: "call-local-raw-failure" },
{ type: "session.tool.called.1", id: "call-hosted-raw-failure-pair" },
{ type: "session.tool.failed.2", id: "call-local-raw-failure" },
{ type: "session.tool.failed.2", id: "call-hosted-raw-failure-pair" },
{ type: "session.step.failed.1", id: undefined },
expect(bus.map((event) => ({ type: event.type, callID: event.data.callID }))).toEqual([
{ type: "session.step.started.1", callID: undefined },
{ type: "session.tool.called.1", callID: "call-local-raw-failure" },
{ type: "session.tool.called.1", callID: "call-hosted-raw-failure-pair" },
{ type: "session.tool.failed.2", callID: "call-local-raw-failure" },
{ type: "session.tool.failed.2", callID: "call-hosted-raw-failure-pair" },
{ type: "session.step.failed.1", callID: undefined },
])
expect(
bus.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"),
@@ -65,18 +65,18 @@ describe("Tool.Metadata", () => {
if (!row) return yield* Effect.die("Missing projected assistant")
return Schema.decodeUnknownSync(SessionMessage.Assistant)({ ...row.data, id: row.id, type: row.type })
})
const start = (id: string) =>
const start = (callID: string) =>
Effect.gen(function* () {
yield* service.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
id,
callID,
name: "bash",
})
yield* service.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
id,
callID,
input: { command: "pwd" },
executed: false,
})
@@ -90,7 +90,7 @@ describe("Tool.Metadata", () => {
const progress = yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
assistantMessageID,
id: "call-success",
callID: "call-success",
metadata: { phase: "checkpoint" },
})
expect((yield* readAssistant).content[0]).toMatchObject({
@@ -100,7 +100,7 @@ describe("Tool.Metadata", () => {
const success = yield* service.publish(SessionEvent.Tool.Success, {
sessionID,
assistantMessageID,
id: "call-success",
callID: "call-success",
metadata: { phase: "done" },
content: content("complete"),
executed: false,
@@ -113,13 +113,13 @@ describe("Tool.Metadata", () => {
yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
assistantMessageID,
id: "call-failed",
callID: "call-failed",
metadata: { phase: "checkpoint" },
})
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID,
id: "call-failed",
callID: "call-failed",
error: { type: "unknown", message: "boom" },
metadata: { phase: "checkpoint" },
content: content("before failure"),
+1 -1
View File
@@ -12,7 +12,7 @@ const context = {
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"),
id: Tool.CallID.make("call_execute"),
callID: Tool.CallID.make("call_execute"),
progress: () => Effect.void,
}
+2 -2
View File
@@ -166,7 +166,7 @@ describe("QuestionTool", () => {
expect(capturedInput()).toEqual({
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "call-question" } },
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
fields: [
{
key: "q0",
@@ -212,7 +212,7 @@ describe("QuestionTool", () => {
expect(capturedInput()).toEqual({
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "call-question" } },
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
fields: [
{
key: "q0",
+2 -2
View File
@@ -17,7 +17,7 @@ export interface ToolHooks {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly id: Tool.CallID
readonly callID: Tool.CallID
input: unknown
}
readonly "execute.after": {
@@ -25,7 +25,7 @@ export interface ToolHooks {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly id: Tool.CallID
readonly callID: Tool.CallID
readonly input: unknown
} & (
| {
+2 -2
View File
@@ -34,7 +34,7 @@ interface ToolHooks {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly id: Tool.CallID
readonly callID: Tool.CallID
input: unknown
}
readonly "execute.after": {
@@ -42,7 +42,7 @@ interface ToolHooks {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly id: Tool.CallID
readonly callID: Tool.CallID
readonly input: unknown
} & (
| {
+1 -1
View File
@@ -17,7 +17,7 @@ export const Source = Schema.Union([
Schema.Struct({
type: Schema.Literal("tool"),
messageID: Schema.String,
id: Schema.String,
callID: Schema.String,
}),
]).annotate({ identifier: "Permission.Source" })
export type Source = typeof Source.Type
+1 -1
View File
@@ -45,7 +45,7 @@ export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
export const Tool = Schema.Struct({
messageID: Schema.String,
id: Schema.String,
callID: Schema.String,
}).annotate({ identifier: "Question.Tool" })
export interface Tool extends Schema.Schema.Type<typeof Tool> {}
+2 -3
View File
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { optional } from "./schema.js"
import { Event } from "./event.js"
import { FinishReason } from "./llm.js"
import { Content, FileContent } from "./tool.js"
import { Content } from "./tool.js"
import { Model } from "./model.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { FileAttachment } from "./prompt.js"
@@ -365,7 +365,7 @@ export namespace Tool {
const ToolBase = {
...Base,
assistantMessageID: SessionMessage.ID,
id: Schema.String,
callID: Schema.String,
}
export namespace Input {
@@ -515,7 +515,6 @@ export namespace Compaction {
reason: Started.data.fields.reason,
text: Schema.String,
recent: Schema.String,
media: Schema.Array(FileContent).pipe(optional),
},
})
export type Ended = typeof Ended.Type
+1 -2
View File
@@ -2,7 +2,7 @@ export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { Content, FileContent } from "./tool.js"
import { Content } from "./tool.js"
import { Model } from "./model.js"
import { Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js"
@@ -222,7 +222,6 @@ export const CompactionCompleted = Schema.Struct({
reason: Schema.Literals(["auto", "manual"]),
summary: Schema.String,
recent: Schema.String,
media: Schema.Array(FileContent).pipe(optional),
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFailed> {}
+1 -1
View File
@@ -15,7 +15,7 @@ export interface Context {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly id: CallID
readonly callID: CallID
readonly progress: (update: Metadata) => Effect.Effect<void>
}
+1 -1
View File
@@ -177,7 +177,7 @@ describe("public event manifest", () => {
const tool = SessionEvent.Tool.Called.data.make({
sessionID,
assistantMessageID,
id: "call_test",
callID: "call_test",
input: {},
executed: true,
state: { itemId: "item_test" },
@@ -477,7 +477,7 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* (
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
id: context.id,
callID: context.callID,
},
}
const pending: PendingToolInvocation = {
+1 -1
View File
@@ -503,7 +503,7 @@ export namespace Backend {
sessionID: Schema.String,
agent: Schema.String,
messageID: Schema.String,
id: Schema.String,
callID: Schema.String,
}),
})
export interface ToolInvocation extends Schema.Schema.Type<typeof ToolInvocation> {}
@@ -279,7 +279,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
)
const progress: Tool.Metadata[] = []
const executeCall = (id: string, query: string) =>
const executeCall = (callID: string, query: string) =>
toolSet.execute({
sessionID: Session.ID.make("ses_simulated_tools"),
agent: Agent.ID.make("build"),
@@ -287,7 +287,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
progress: (update) => Effect.sync(() => progress.push(update)),
call: {
type: "tool-call",
id: id,
id: callID,
name: "lookup",
input: { query },
},
@@ -302,7 +302,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
sessionID: "ses_simulated_tools",
agent: "build",
messageID: "msg_simulated_tools",
id: "call_success",
callID: "call_success",
},
})
const successID = requireString(requireRecord(successInvocation.params).id)
@@ -420,25 +420,25 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
invocations.map((invocation) => {
const params = requireRecord(invocation.params)
const context = requireRecord(params.context)
return [requireString(context.id), requireString(params.id)]
return [requireString(context.callID), requireString(params.id)]
}),
)
for (const [requestID, toolID, value] of [
for (const [id, callID, value] of [
[5, "call_second", "second result"],
[6, "call_first", "first result"],
] as const) {
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: requestID,
id,
method: "tool.finish",
params: {
id: byCall.get(toolID),
id: byCall.get(callID),
output: { structured: value, content: [{ type: "text", text: value }] },
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: requestID, result: { ok: true } })
expect(yield* Queue.take(messages)).toMatchObject({ id, result: { ok: true } })
}
expect(yield* Fiber.join(concurrent[0])).toMatchObject({
output: "first result",
+9 -9
View File
@@ -208,10 +208,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
return item?.type === "compaction" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, id?: string) {
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool =>
item.type === "tool" && (id === undefined || item.id === id),
item.type === "tool" && (callID === undefined || item.id === callID),
)
},
latestText(assistant: SessionMessageAssistant | undefined) {
@@ -592,7 +592,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "tool",
id: event.data.id,
id: event.data.callID,
name: event.data.name,
time: { created: event.created },
state: { status: "streaming", input: "" },
@@ -603,7 +603,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
event.data.callID,
)
if (match?.state.status === "streaming") match.state.input += event.data.delta
})
@@ -612,7 +612,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
event.data.callID,
)
if (match?.state.status === "streaming") match.state.input = event.data.text
})
@@ -621,7 +621,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
event.data.callID,
)
if (!match) return
match.time.ran = event.created
@@ -634,7 +634,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
event.data.callID,
)
if (match?.state.status !== "running") return
match.state.metadata = event.data.metadata
@@ -644,7 +644,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
event.data.callID,
)
if (match?.state.status !== "running") return
match.state = {
@@ -662,7 +662,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
event.data.callID,
)
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
match.state = {
+2 -2
View File
@@ -373,7 +373,7 @@ function askPermission(state: State, item: Permit): void {
resources: item.patterns,
metadata: item.metadata ?? {},
save: item.always,
source: { type: "tool", messageID: item.ref.msg, id: item.ref.call },
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
tool,
},
})
@@ -805,7 +805,7 @@ function emitForm(state: State, kind: FormKind = "question"): void {
title: form.title,
metadata:
kind === "question"
? { kind: "question", tool: { messageID: ref.msg, id: ref.call } }
? { kind: "question", tool: { messageID: ref.msg, callID: ref.call } }
: { kind: "mcp", message: `Synthetic ${kind} MCP elicitation` },
fields: form.fields,
}
+17 -17
View File
@@ -164,13 +164,13 @@ function text(value: unknown): string | undefined {
return next || undefined
}
function sourceKey(messageID: string, id: string) {
return `${messageID}\u0000${id}`
function sourceKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
}
function permissionTool(request: PermissionRequest, tools: Map<string, SessionMessageAssistantTool>) {
if (request.source?.type !== "tool") return request
const tool = tools.get(sourceKey(request.source.messageID, request.source.id))
const tool = tools.get(sourceKey(request.source.messageID, request.source.callID))
return tool ? { ...request, tool } : request
}
@@ -444,7 +444,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
...new Set(
permissions.flatMap((request) => {
if (request.source?.type !== "tool") return []
const key = sourceKey(request.source.messageID, request.source.id)
const key = sourceKey(request.source.messageID, request.source.callID)
return child.toolSources.has(key) ? [] : [request.source.messageID]
}),
),
@@ -475,7 +475,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
permissions.some(
(request) =>
request.source?.type === "tool" &&
!child.toolSources.has(sourceKey(request.source.messageID, request.source.id)),
!child.toolSources.has(sourceKey(request.source.messageID, request.source.callID)),
)
)
throw new Error("Permission source tool is unavailable")
@@ -737,12 +737,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
if (event.type === "session.tool.input.started") {
if (child.finishedTools.has(sourceKey(event.data.assistantMessageID, event.data.id))) return
if (child.finishedTools.has(sourceKey(event.data.assistantMessageID, event.data.callID))) return
childTool(
child,
{
type: "tool",
id: event.data.id,
id: event.data.callID,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
@@ -752,7 +752,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
if (event.type === "session.tool.input.delta" || event.type === "session.tool.input.ended") {
const current = child.tools.get(sourceKey(event.data.assistantMessageID, event.data.id))
const current = child.tools.get(sourceKey(event.data.assistantMessageID, event.data.callID))
if (!current || current.part.state.status !== "streaming") return
childTool(
child,
@@ -769,14 +769,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
if (event.type === "session.tool.called") {
const key = sourceKey(event.data.assistantMessageID, event.data.id)
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
if (child.finishedTools.has(key)) return
const current = child.tools.get(key)
childTool(
child,
{
type: "tool",
id: event.data.id,
id: event.data.callID,
name: current?.part.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
@@ -790,7 +790,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
if (event.type === "session.tool.progress") {
const key = sourceKey(event.data.assistantMessageID, event.data.id)
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
if (child.finishedTools.has(key)) return
const current = child.tools.get(key)
const part = current?.part
@@ -798,7 +798,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
child,
{
type: "tool",
id: event.data.id,
id: event.data.callID,
name: part?.name ?? "tool",
executed: part?.executed,
providerState: part?.providerState,
@@ -819,7 +819,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
const key = sourceKey(event.data.assistantMessageID, event.data.id)
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
if (child.finishedTools.has(key)) return
const current = child.tools.get(key)
const part = current?.part
@@ -828,7 +828,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
child,
{
type: "tool",
id: event.data.id,
id: event.data.callID,
name: part?.name ?? "tool",
executed: event.data.executed,
providerState: part?.providerState,
@@ -947,11 +947,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (!active(signal)) return
if (event.type === "session.tool.input.started") {
if (canonicalToolName(event.data.name) === "subagent")
pendingCalls.set(sourceKey(event.data.assistantMessageID, event.data.id), {})
pendingCalls.set(sourceKey(event.data.assistantMessageID, event.data.callID), {})
return
}
if (event.type === "session.tool.called") {
const key = sourceKey(event.data.assistantMessageID, event.data.id)
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
if (pendingCalls.has(key)) pendingCalls.set(key, event.data.input)
return
}
@@ -961,7 +961,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
event.type !== "session.tool.failed"
)
return
const key = sourceKey(event.data.assistantMessageID, event.data.id)
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
const pending = pendingCalls.get(key)
if (event.type !== "session.tool.progress") pendingCalls.delete(key)
const found = childSessionID(record(event.data.metadata))
+26 -26
View File
@@ -91,12 +91,12 @@ type Wait = {
}
// One active session.shell call. The HTTP response is the completion signal;
// id correlates the live shell events once shell.started is observed, and
// callID correlates the live shell events once shell.started is observed, and
// abort cancels the blocking request when the user interrupts the turn.
type ShellWait = {
eventID: string
messageID: string
id?: string
callID?: string
resolve: () => void
abort: () => void
}
@@ -291,27 +291,27 @@ function streamPartKey(messageID: string, partID: string) {
return `${messageID}\u0000${partID}`
}
function permissionSourceKey(messageID: string, id: string) {
return streamPartKey(messageID, id)
function permissionSourceKey(messageID: string, callID: string) {
return streamPartKey(messageID, callID)
}
function permissionTool(request: PermissionRequest, tools: Map<string, SessionMessageAssistantTool>) {
if (request.source?.type !== "tool") return request
const tool = tools.get(permissionSourceKey(request.source.messageID, request.source.id))
const tool = tools.get(permissionSourceKey(request.source.messageID, request.source.callID))
return tool ? { ...request, tool } : request
}
// Direct shell calls use one "start" commit rendering `$ command` and one "progress"
// commit rendering the merged output (see toolEntryBody in tool.ts).
function shellCommit(
id: string,
callID: string,
command: string,
next: Pick<StreamCommit, "text" | "phase" | "toolState" | "toolError">,
): StreamCommit {
return {
kind: "tool",
source: "tool",
partID: `shell:${id}`,
partID: `shell:${callID}`,
tool: "shell",
shell: { command },
...next,
@@ -319,7 +319,7 @@ function shellCommit(
}
function shellTerminal(
id: string,
callID: string,
command: string,
shell: { status: string; exit?: number | string },
output: { output: string; cursor: number; size: number; truncated: boolean },
@@ -332,10 +332,10 @@ function shellTerminal(
: shell.status === "exited"
? `Shell exited with code ${shell.exit ?? "unknown"}`
: `Shell ${shell.status}`
if (!error) return [shellCommit(id, command, { text, phase: "progress", toolState: "completed" })]
if (!error) return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })]
return [
...(text ? [shellCommit(id, command, { text, phase: "progress", toolState: "running" })] : []),
shellCommit(id, command, { text: error, phase: "final", toolState: "error", toolError: error }),
...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []),
shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }),
]
}
@@ -570,7 +570,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const sourcePending = (key: string) =>
state.permissions.some(
(request) =>
request.source?.type === "tool" && permissionSourceKey(request.source.messageID, request.source.id) === key,
request.source?.type === "tool" && permissionSourceKey(request.source.messageID, request.source.callID) === key,
)
const pruneToolSources = () => {
@@ -647,7 +647,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
if (message.type === "shell") {
state.shellCommands.set(message.shellID, message.command)
if (state.shellWait?.messageID === message.id) state.shellWait.id = message.shellID
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shellID
const completed = message.time.completed !== undefined
if (!render) {
// Suppressed history: mark settled shells rendered so live redelivery
@@ -673,7 +673,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.shellEnded.add(message.shellID)
write(shellTerminal(message.shellID, message.command, message, message.output))
}
if (completed && state.shellWait?.id === message.shellID) state.shellWait.resolve()
if (completed && state.shellWait?.callID === message.shellID) state.shellWait.resolve()
return
}
if (message.type === "compaction") {
@@ -776,14 +776,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
) => {
const pending = new Set(
permissions.flatMap((request) =>
request.source?.type === "tool" ? [permissionSourceKey(request.source.messageID, request.source.id)] : [],
request.source?.type === "tool" ? [permissionSourceKey(request.source.messageID, request.source.callID)] : [],
),
)
const messageIDs = [
...new Set(
permissions.flatMap((request) => {
if (request.source?.type !== "tool") return []
const key = permissionSourceKey(request.source.messageID, request.source.id)
const key = permissionSourceKey(request.source.messageID, request.source.callID)
return state.toolSources.has(key) ? [] : [request.source.messageID]
}),
),
@@ -992,7 +992,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.shell.started") {
state.shellCommands.set(event.data.shell.id, event.data.shell.command)
const wait = state.shellWait
if (wait?.eventID === event.id) wait.id = event.data.shell.id
if (wait?.eventID === event.id) wait.callID = event.data.shell.id
if (state.shellStarted.has(event.data.shell.id)) return
state.shellStarted.add(event.data.shell.id)
write(
@@ -1025,7 +1025,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
commits.push(...shellTerminal(event.data.shell.id, command, event.data.shell, event.data.output))
}
const wait = state.shellWait
const owned = wait?.id === event.data.shell.id
const owned = wait?.callID === event.data.shell.id
write(commits, owned || state.wait || state.shellWait ? undefined : { phase: "idle", status: "" })
if (owned) wait.resolve()
return
@@ -1109,7 +1109,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.tool.input.started") {
renderTool(event.data.assistantMessageID, {
type: "tool",
id: event.data.id,
id: event.data.callID,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
@@ -1117,7 +1117,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "session.tool.input.delta" || event.type === "session.tool.input.ended") {
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.id))
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.callID))
if (!current || current.part.state.status !== "streaming") return
renderTool(event.data.assistantMessageID, {
...current.part,
@@ -1130,12 +1130,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "session.tool.called") {
const key = streamPartKey(event.data.assistantMessageID, event.data.id)
const key = streamPartKey(event.data.assistantMessageID, event.data.callID)
if (state.finishedTools.has(key)) return
const current = state.tools.get(key)
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.id,
id: event.data.callID,
name: current?.part.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
@@ -1146,13 +1146,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "session.tool.progress") {
const key = streamPartKey(event.data.assistantMessageID, event.data.id)
const key = streamPartKey(event.data.assistantMessageID, event.data.callID)
if (state.finishedTools.has(key)) return
const current = state.tools.get(key)
const part = current?.part
renderTool(event.data.assistantMessageID, {
type: "tool",
id: event.data.id,
id: event.data.callID,
name: part?.name ?? "tool",
executed: part?.executed,
providerState: part?.providerState,
@@ -1166,12 +1166,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.id))
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.callID))
const part = current?.part
const failed = event.type === "session.tool.failed"
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.id,
id: event.data.callID,
name: part?.name ?? "tool",
executed: event.data.executed,
providerState: part?.providerState,
+2 -2
View File
@@ -244,11 +244,11 @@ type MiniToolState =
// Retained only for the noninteractive run JSON/V1 compatibility boundary.
// Interactive Mini commits carry SessionMessageAssistantTool directly.
export type MiniToolPart = {
partID: string
id: string
sessionID: string
messageID: string
type?: "tool"
id: string
callID: string
tool: string
state: MiniToolState
}
+1 -1
View File
@@ -2227,7 +2227,7 @@ function useToolPermission(part: () => SessionMessageAssistantTool | undefined)
return createMemo(() => {
if (local.permission.mode === "auto") return false
const request = data.session.permission.list(ctx.sessionID)?.[0]
return request?.source?.type === "tool" && request.source.id === part()?.id
return request?.source?.type === "tool" && request.source.callID === part()?.id
})
}
@@ -123,7 +123,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
if (!tool) return { input: undefined, metadata: undefined }
const message = data.session.message.get(props.request.sessionID, tool.messageID)
if (message?.type !== "assistant") return { input: undefined, metadata: undefined }
const part = message.content.find((part) => part.type === "tool" && part.id === tool.id)
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
if (part?.type === "tool" && part.state.status !== "streaming") {
return { input: part.state.input, metadata: part.state.metadata }
}
+2 -2
View File
@@ -68,7 +68,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
function pendingPermissions() {
return new Set(
(data.session.permission.list(sessionID()) ?? []).flatMap((request) =>
request.source?.type === "tool" ? [request.source.id] : [],
request.source?.type === "tool" ? [request.source.callID] : [],
),
)
}
@@ -255,7 +255,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.tool.input.started", (event) => {
if (event.data.sessionID === sessionID())
appendPart(
{ messageID: event.data.assistantMessageID, partID: event.data.id },
{ messageID: event.data.assistantMessageID, partID: event.data.callID },
{ type: "tool", name: event.data.name },
)
}),
+6 -6
View File
@@ -880,7 +880,7 @@ test("completes exploration when a queued prompt is promoted", async () => {
data: {
sessionID,
assistantMessageID: "message-assistant",
id: "call-read",
callID: "call-read",
name: "read",
},
})
@@ -951,7 +951,7 @@ test("classifies live tool rows independently of their call ID", async () => {
data: {
sessionID,
assistantMessageID: "message-assistant",
id: "reasoning:0",
callID: "reasoning:0",
name: "bash",
},
})
@@ -2485,7 +2485,7 @@ test("settles pending tools when a live failure arrives", async () => {
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
id: "call-1",
callID: "call-1",
name: "bash",
},
})
@@ -2497,7 +2497,7 @@ test("settles pending tools when a live failure arrives", async () => {
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
id: "call-1",
callID: "call-1",
input: {},
executed: false,
state: { call: true },
@@ -2510,7 +2510,7 @@ test("settles pending tools when a live failure arrives", async () => {
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
id: "call-1",
callID: "call-1",
metadata: { sessionID: "session-child", status: "running" },
},
})
@@ -2533,7 +2533,7 @@ test("settles pending tools when a live failure arrives", async () => {
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
id: "call-1",
callID: "call-1",
error: { type: "unknown", message: "aborted" },
executed: false,
resultState: { result: true },
@@ -89,7 +89,7 @@ describe("run permission shared", () => {
permissionInfo(
req({
action: "shell",
source: { type: "tool", messageID: "msg-shell", id: "call-shell" },
source: { type: "tool", messageID: "msg-shell", callID: "call-shell" },
tool: canonicalToolPart(
"shell",
{
@@ -134,7 +134,7 @@ describe("run permission shared", () => {
req({
action: "websearch",
metadata: { provider: "parallel" },
source: { type: "tool", messageID: "msg-search", id: "call-search" },
source: { type: "tool", messageID: "msg-search", callID: "call-search" },
tool: canonicalToolPart(
"websearch",
{
@@ -157,7 +157,7 @@ describe("run permission shared", () => {
const request = req({
action: "edit",
resources: ["src/index.ts"],
source: { type: "tool", messageID: "msg-edit", id: "call-edit" },
source: { type: "tool", messageID: "msg-edit", callID: "call-edit" },
tool: canonicalToolPart(
"edit",
{
@@ -416,7 +416,7 @@ describe("V2 mini transport", () => {
sessionID: "ses_child",
action: "shell",
resources: ["git status --short"],
source: { type: "tool", messageID: "msg_child_source", id: "call_child_source" },
source: { type: "tool", messageID: "msg_child_source", callID: "call_child_source" },
}
const client = sdk({
streams: [events],
@@ -2032,7 +2032,7 @@ describe("V2 mini transport", () => {
created: index * 3 + 1,
type: "session.tool.input.started",
durable: durable("ses_1", index * 3),
data: { sessionID: "ses_1", assistantMessageID: messageID, id: "call_repeated", name: "read" },
data: { sessionID: "ses_1", assistantMessageID: messageID, callID: "call_repeated", name: "read" },
})
events.push({
id: `evt_repeated_called_${index}`,
@@ -2042,7 +2042,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: messageID,
id: "call_repeated",
callID: "call_repeated",
input: { path: `${index + 1}.txt` },
executed: true,
},
@@ -2055,7 +2055,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: messageID,
id: "call_repeated",
callID: "call_repeated",
metadata: {},
content: [{ type: "text", text: "" }],
executed: true,
@@ -2098,7 +2098,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
id: "call_progress",
callID: "call_progress",
name: "shell",
},
})
@@ -2110,7 +2110,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
id: "call_progress",
callID: "call_progress",
input: { command: "printf partial && false" },
executed: true,
},
@@ -2122,7 +2122,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
id: "call_progress",
callID: "call_progress",
metadata: { checkpoint: 1 },
},
})
@@ -2134,7 +2134,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
id: "call_progress",
callID: "call_progress",
error: { type: "unknown", message: "boom" },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial" }],
@@ -2933,7 +2933,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
id: "call_failed_subagent",
callID: "call_failed_subagent",
name: "subagent",
},
})
@@ -2945,7 +2945,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
id: "call_failed_subagent",
callID: "call_failed_subagent",
input: { agent: "explore", description: "Inspect failure", prompt: "inspect" },
executed: true,
},
@@ -2958,7 +2958,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
id: "call_failed_subagent",
callID: "call_failed_subagent",
error: { type: "unknown", message: "subagent failed" },
metadata: { sessionID: "ses_child_failed", status: "running" },
executed: true,
@@ -2996,7 +2996,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_subagent",
id: "call_subagent",
callID: "call_subagent",
name: "subagent",
},
})
@@ -3008,7 +3008,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_subagent",
id: "call_subagent",
callID: "call_subagent",
input: { agent: "explore", description: "Inspect progress", prompt: "inspect" },
executed: true,
},
@@ -3020,7 +3020,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_subagent",
id: "call_subagent",
callID: "call_subagent",
metadata: { sessionID: "ses_child_progress", status: "running" },
},
})
@@ -3046,7 +3046,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
id: "call_child_shell",
callID: "call_child_shell",
name: "shell",
},
})
@@ -3058,7 +3058,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
id: "call_child_shell",
callID: "call_child_shell",
input: { command: "printf child && false" },
executed: true,
},
@@ -3070,7 +3070,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
id: "call_child_shell",
callID: "call_child_shell",
metadata: { checkpoint: "child" },
},
})
@@ -3083,7 +3083,7 @@ describe("V2 mini transport", () => {
sessionID: "ses_child_progress",
action: "shell",
resources: ["printf child && false"],
source: { type: "tool", messageID: "msg_child_tool", id: "call_child_shell" },
source: { type: "tool", messageID: "msg_child_tool", callID: "call_child_shell" },
},
})
events.push({
@@ -3094,7 +3094,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
id: "call_child_shell",
callID: "call_child_shell",
error: { type: "unknown", message: "child boom" },
metadata: { checkpoint: "child" },
content: [{ type: "text", text: "child partial" }],
@@ -3531,24 +3531,24 @@ describe("V2 mini transport", () => {
footer: ui.api,
})
const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
const inputStarted = (id: string, name: string, seq: number) =>
const inputStarted = (callID: string, name: string, seq: number) =>
events.push({
id: `evt_started_${id}`,
id: `evt_started_${callID}`,
created: seq,
type: "session.tool.input.started",
durable: durable("ses_child", seq),
data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", id, name },
data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID, name },
})
const called = (id: string, input: Record<string, unknown>, seq: number) =>
const called = (callID: string, input: Record<string, unknown>, seq: number) =>
events.push({
id: `evt_called_${id}`,
id: `evt_called_${callID}`,
created: seq,
type: "session.tool.called",
durable: durable("ses_child", seq),
data: {
sessionID: "ses_child",
assistantMessageID: "msg_tool_projected",
id,
callID,
input,
executed: true,
},
@@ -3567,7 +3567,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child",
assistantMessageID: "msg_tool_projected",
id: "call_terminal",
callID: "call_terminal",
metadata: {},
content: [{ type: "text", text: "found" }],
executed: true,
@@ -3705,7 +3705,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
id: "call_sub",
callID: "call_sub",
name: "subagent",
},
})
@@ -3717,7 +3717,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
id: "call_sub",
callID: "call_sub",
input: { agent: "explore", description: "Find things", prompt: "go", background: true },
executed: true,
},
@@ -3730,7 +3730,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
id: "call_sub",
callID: "call_sub",
metadata: { sessionID: "ses_child", status: "running", output: "" },
content: [{ type: "text", text: "" }],
executed: true,
@@ -83,7 +83,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
"auto": true,
"prune": false,
"keep": {
"tokens": 15000
"tokens": 8000
},
"buffer": 20000
}
@@ -94,7 +94,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| --- | ---: | --- |
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
@@ -110,12 +110,9 @@ and relevant files.
The newest serialized context up to `keep.tokens` is retained separately. This
is not a byte-for-byte transcript: tool output is limited to 2000 characters,
and non-media attachments become textual descriptors. Media in the retained
context is attached to the checkpoint in the same order as its descriptors.
Tail selection budgets 1500 additional tokens per image or PDF as a
provider-neutral planning estimate; it does not count base64 request bytes as
text tokens. On later compactions, V2 updates the previous summary and carries
forward its retained recent context before selecting a new tail.
and file or media attachments become textual descriptors rather than embedded
data. On later compactions, V2 updates the previous summary and carries forward
its retained recent context before selecting a new tail.
The completed compaction is presented to the model as historical conversation
context, explicitly not as new instructions. Running and failed compactions are
+2 -2
View File
@@ -344,8 +344,8 @@ argument to `tools.add` to configure the registration with
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
provider.
The executor receives a second context argument containing `id`, `sessionID`,
`agent`, `messageID`, and `progress`. A tool with `output`
The executor receives a second context argument containing `sessionID`,
`agent`, `messageID`, `callID`, and `progress`. A tool with `output`
must return `output`; Effect and Standard Schema codecs validate it, while raw
JSON Schema definitions enforce JSON compatibility only. A tool
without `output` returns model-visible `content` instead.
+1 -1
View File
@@ -329,7 +329,7 @@ Control automatic context compaction and how much recent context it preserves.
"compaction": {
"auto": true,
"keep": {
"tokens": 15000
"tokens": 8000
},
"buffer": 20000
}