Compare commits

...

7 Commits

Author SHA1 Message Date
Aiden Cline caec8d6638 refactor(core): tune retained media budget 2026-08-04 23:39:05 -05:00
Aiden Cline 0c89e24aac fix(core): budget retained image tokens 2026-08-04 22:40:58 -05:00
Aiden Cline b4ec03e5f1 refactor(core): simplify retained media 2026-08-04 22:36:05 -05:00
Aiden Cline af64ffa5d6 refactor(core): generalize retained compaction media 2026-08-04 22:24:56 -05:00
Aiden Cline 1b708aa098 feat(core): preserve compaction tail images 2026-08-04 22:13:21 -05:00
Dax Raad 53b8111121 refactor: rename tool call ID fields 2026-08-04 17:08:12 -04:00
Kit Langton 1fd817ffcf fix(client): wait for live service contenders (#40515) 2026-08-04 20:23:57 +00:00
72 changed files with 566 additions and 371 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 callID: ToolCallPart["id"]
readonly id: 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>,
callID: ToolCallPart["id"],
id: 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, callID, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
_project: (parameters, id, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, id, 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, callID, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
_project: (parameters, id, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, id, 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,
callID: ToolCallPart["id"],
id: ToolCallPart["id"],
output: unknown,
): ToolOutputType =>
ToolOutput.make(
toStructuredOutput?.(output) ?? output,
toModelOutput?.({ callID, parameters, output }) ??
toModelOutput?.({ id, 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([{ callID: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(calls).toEqual([{ id: "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: ({ callID, parameters, output }) => [
{ type: "text", text: `${callID}:${parameters.city}:${output.forecast}` },
toModelOutput: ({ id, parameters, output }) => [
{ type: "text", text: `${id}:${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?.callID ? tools.get(event.data.source.callID) : undefined
const tool = event.data.source?.id ? tools.get(event.data.source.id) : 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.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] })
await update({
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: event.data.callID,
toolCallId: event.data.id,
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.callID) ?? emptyToolState()
const current = tools.get(event.data.id) ?? emptyToolState()
current.input = event.data.input
tools.set(event.data.callID, current)
tools.set(event.data.id, current)
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolCallId: event.data.id,
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.callID)
const current = tools.get(event.data.id)
if (!current) continue
current.metadata = event.data.metadata
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolCallId: event.data.id,
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.callID) ?? emptyToolState()
tools.delete(event.data.callID)
const current = tools.get(event.data.id) ?? emptyToolState()
tools.delete(event.data.id)
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.callID,
toolCallId: event.data.id,
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.callID) ?? emptyToolState()
tools.delete(event.data.callID)
const current = tools.get(event.data.id) ?? emptyToolState()
tools.delete(event.data.id)
await update({
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: event.data.callID,
toolCallId: event.data.id,
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?.callID ?? input.event.data.id,
toolCallId: input.event.data.source?.id ?? 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.callID), {
tools.set(toolKey(event.data.assistantMessageID, event.data.id), {
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.callID))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
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.callID))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
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.callID)
const key = toolKey(event.data.assistantMessageID, event.data.id)
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.callID))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
if (current) {
current.metadata = event.data.metadata
}
continue
}
if (event.type === "session.tool.success") {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const key = toolKey(event.data.assistantMessageID, event.data.id)
const current = tools.get(key) ?? fallbackTool(event)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
id: event.data.id,
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 = {
id: current.id,
partID: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
id: event.data.id,
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.callID)
const key = toolKey(event.data.assistantMessageID, event.data.id)
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.callID,
id: event.data.id,
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 = {
id: current.id,
partID: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
id: event.data.id,
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 = {
id: projectedPartID(message.id, `tool-${item.id}`),
partID: projectedPartID(message.id, `tool-${item.id}`),
sessionID: input.sessionID,
messageID: message.id,
type: "tool",
callID: item.id,
id: 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, callID: string) {
return `${messageID}\u0000${callID}`
function toolKey(messageID: string, id: string) {
return `${messageID}\u0000${id}`
}
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; callID: string }
data: { assistantMessageID: string; id: string }
}): ToolState {
return {
id: partID(event.id),
+8 -8
View File
@@ -200,7 +200,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
id: "call_ok",
name: "shell",
}),
)
@@ -208,7 +208,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
id: "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",
callID: "call_ok",
id: "call_ok",
metadata: { phase: 1 },
}),
)
@@ -225,7 +225,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
id: "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",
callID: "call_fail",
id: "call_fail",
name: "read",
}),
)
@@ -243,7 +243,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
id: "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",
callID: "call_fail",
id: "call_fail",
metadata: { bytes: 0 },
}),
)
@@ -260,7 +260,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.failed", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
id: "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", callID: "call_once" },
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
}),
)
send(
permissionAsked("ses_allow", "perm_always", {
action: "read",
metadata: { path: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
source: { type: "tool", messageID: "msg_allow", id: "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",
callID: "call_edit",
id: "call_edit",
name: "edit",
}),
)
@@ -174,7 +174,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
id: "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", callID: "call_edit" },
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
}),
)
},
@@ -192,7 +192,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
id: "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",
callID: "call_patch",
id: "call_patch",
name: "patch",
}),
)
@@ -264,7 +264,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
id: "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", callID: "call_patch" },
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
}),
)
},
@@ -285,7 +285,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
id: "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 callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: 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",
callID: "call_failed_tool",
id: "call_failed_tool",
name: "shell",
},
},
@@ -121,7 +121,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
id: "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",
callID: "call_failed_tool",
id: "call_failed_tool",
metadata: { checkpoint: 1 },
},
},
@@ -145,7 +145,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
id: "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",
callID: "call_grep",
id: "call_grep",
name: "grep",
},
},
@@ -180,7 +180,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
id: "call_grep",
input: { pattern: "needle" },
executed: true,
},
@@ -193,7 +193,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
id: "call_grep",
metadata: { matches: 2 },
content: [{ type: "text", text }],
executed: false,
@@ -561,7 +561,7 @@ describe("runNonInteractivePrompt", () => {
type: "tool_use",
part: {
type: "tool",
callID: "call_failed_tool",
id: "call_failed_tool",
tool: "shell",
state: {
status: "error",
+13 -5
View File
@@ -605,7 +605,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly name: string
}
}
@@ -619,7 +619,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly text: string
}
}
@@ -633,7 +633,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: 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 callID: string
readonly id: 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 callID: string
readonly id: string
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly content?:
| readonly [
@@ -764,6 +764,14 @@ 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
}
}
| {
+2 -5
View File
@@ -56,7 +56,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => {
if (announced) return
@@ -84,7 +83,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const info = registration.info
const service = registration.service
if (service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.state === "ready") return Option.some(service)
@@ -97,14 +95,13 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
const failure = [...contenders].map(contenderFailure).find((error): error is Error => error !== undefined)
if (failure !== undefined) return yield* Effect.fail(failure)
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
yield* announce("missing")
+38 -37
View File
@@ -121,17 +121,6 @@ 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 } }
@@ -297,7 +286,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; callID: string }
export type PermissionSource = { type: "tool"; messageID: string; id: string }
export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string }
@@ -486,7 +475,7 @@ export type Pty = {
export type QuestionOption = { label: string; description: string }
export type QuestionTool = { messageID: string; callID: string }
export type QuestionTool = { messageID: string; id: string }
export type QuestionAnswer = Array<string>
@@ -744,7 +733,7 @@ export type SessionToolInputStarted = {
type: "session.tool.input.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; name: string }
data: { sessionID: string; assistantMessageID: string; id: string; name: string }
}
export type SessionToolInputEnded = {
@@ -754,7 +743,7 @@ export type SessionToolInputEnded = {
type: "session.tool.input.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; text: string }
data: { sessionID: string; assistantMessageID: string; id: string; text: string }
}
export type SessionCompactionAdmitted = {
@@ -777,16 +766,6 @@ 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
@@ -915,7 +894,7 @@ export type SessionToolInputDelta = {
metadata?: { [x: string]: any }
type: "session.tool.input.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
data: { sessionID: string; assistantMessageID: string; id: string; delta: string }
}
export type SessionToolProgress = {
@@ -924,7 +903,7 @@ export type SessionToolProgress = {
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } }
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } }
}
export type SessionCompactionDelta = {
@@ -1227,6 +1206,18 @@ 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 = {
@@ -1380,7 +1371,7 @@ export type SessionToolCalled = {
data: {
sessionID: string
assistantMessageID: string
callID: string
id: string
input: { [x: string]: any }
executed: boolean
state?: SessionMessageProviderState7
@@ -1389,6 +1380,16 @@ 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 = {
@@ -1831,7 +1832,7 @@ export type SessionToolSuccess = {
data: {
sessionID: string
assistantMessageID: string
callID: string
id: string
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
executed: boolean
@@ -1849,7 +1850,7 @@ export type SessionToolFailed = {
data: {
sessionID: string
assistantMessageID: string
callID: string
id: string
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
@@ -4472,7 +4473,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 callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["id"]
readonly action: {
@@ -4481,7 +4482,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 callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["action"]
readonly resources: {
@@ -4490,7 +4491,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 callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["resources"]
readonly save?: {
@@ -4499,7 +4500,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 callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["save"]
readonly metadata?: {
@@ -4508,7 +4509,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 callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["metadata"]
readonly source?: {
@@ -4517,7 +4518,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 callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["source"]
readonly agent?: {
@@ -4526,7 +4527,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 callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["agent"]
}
+2 -5
View File
@@ -37,7 +37,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@@ -65,7 +64,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const registration = await registered(options.file, true)
if (registration.service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
@@ -78,14 +76,13 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
} else {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined)
if (failure !== undefined) throw failure
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) throw failure
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
announce("missing")
+9 -3
View File
@@ -9,14 +9,20 @@ if (mode === "record-start") {
}
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated") {
if (
mode === "delayed" ||
mode === "delayed-failed" ||
mode === "coordinated" ||
mode === "coordinated-failed-loser"
) {
await appendFile(registration + ".starts", process.pid + "\n")
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
.then(() => true)
.catch(() => false)
if (!owner) process.exit()
if (mode === "coordinated") {
if (!owner) process.exit(mode === "coordinated-failed-loser" ? 1 : 0)
if (mode === "coordinated" || mode === "coordinated-failed-loser") {
while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10)
if (mode === "coordinated-failed-loser") await Bun.sleep(1_500)
} else await Bun.sleep(Number(delay))
if (mode === "delayed-failed") process.exit(1)
}
@@ -44,6 +44,24 @@ test("ensures a missing service with native promises", async () => {
}
}, 15_000)
test("waits for a live contender when another native contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
})
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
}, 15_000)
test("reports a failed registered service", async () => {
const registration = await setup("failed-owner")
+18
View File
@@ -141,6 +141,24 @@ test("waits for a slow winner while bounding lock probes", async () => {
}
}, 15_000)
test("waits for a live contender when another contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await run(
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
}),
)
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
} finally {
process.kill(info.pid, "SIGTERM")
}
}, 15_000)
test("reports a contender that fails to start", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+61 -7
View File
@@ -2,6 +2,7 @@ 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"
@@ -19,9 +20,10 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000
const DEFAULT_KEEP_TOKENS = 15_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
@@ -90,6 +92,7 @@ type Plan = {
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
readonly recent: string
readonly media: readonly Tool.FileContent[]
readonly inputID?: SessionMessage.ID
}
@@ -108,6 +111,15 @@ 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) =>
@@ -115,6 +127,24 @@ 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 =
@@ -162,7 +192,11 @@ const settings = (documents: readonly Config.Entry[]) => {
const select = (
messages: readonly SessionMessage.Info[],
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
): {
readonly head: string
readonly recent: string
readonly media: readonly Tool.FileContent[]
} | undefined => {
const conversation = messages
.filter((message) => message.type !== "compaction" && message.type !== "system")
.flatMap((message) => {
@@ -173,7 +207,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)
const next = total + Token.estimate(conversation[index].text) + estimateMediaTokens(conversation[index].message)
if (split < conversation.length && next > tokens) break
total = next
split = index
@@ -183,15 +217,33 @@ 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: conversation
.slice(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] : [],
)
})
}),
}
}
@@ -219,6 +271,7 @@ 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,
}
}
@@ -318,6 +371,7 @@ const make = (dependencies: Dependencies) => {
reason: plan.reason,
text: summary,
recent: plan.recent,
media: plan.media,
})
return { status: "completed" as const }
})
+10 -8
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, callID?: string) =>
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
assistant?.content.findLast(
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
)
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.callID,
id: event.data.id,
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.callID)
const match = latestTool(draft, event.data.id)
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.callID)
const match = latestTool(draft, event.data.id)
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.callID)
const match = latestTool(draft, event.data.id)
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.callID)
const match = latestTool(draft, event.data.id)
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.callID)
const match = latestTool(draft, event.data.id)
if (match && (match.state.status === "streaming" || match.state.status === "running")) {
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
@@ -480,6 +480,7 @@ 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
}
@@ -492,6 +493,7 @@ 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,
callID: tool.id,
id: 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 callID/ordinal rather than global position.
* and consumers fold by id/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", (callID, value) =>
const toolInput = fragments("tool input", (id, value) =>
Effect.gen(function* () {
const tool = tools.get(callID)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${callID}`))
const tool = tools.get(id)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
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,
callID: event.id,
id: 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,
callID: event.id,
id: 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* (callID: string, error: SessionError.Error) {
const tool = tools.get(callID)
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error) {
const tool = tools.get(id)
if (!tool || tool.settled) return false
tool.settled = true
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
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 [callID, tool] of tools) {
for (const [id, tool] of tools) {
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
continue
failed = (yield* failTool(callID, error)) || failed
failed = (yield* failTool(id, error)) || failed
}
return failed
})
@@ -328,9 +328,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return yield* failTools(error, scope)
})
const assistantMessageIDForTool = (callID: string) => {
const tool = tools.get(callID)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
const assistantMessageIDForTool = (id: string) => {
const tool = tools.get(id)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${id}`))
}
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,
callID: event.id,
id: 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,
callID: event.id,
id: 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,
callID: event.id,
id: 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,
callID: event.id,
id: 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,
callID: event.id,
id: 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* (callID: string, update: Tool.Metadata) {
const tool = tools.get(callID)
const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) {
const tool = tools.get(id)
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
tool.progress = update
yield* bus.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
metadata: update,
})
})
/** Publishes one canonical terminal event for a locally executed tool call. */
const toolExecution = Effect.fnUntraced(function* (
callID: string,
id: string,
name: string,
result: Tool.Result,
) {
const tool = tools.get(callID)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
const tool = tools.get(id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
if (tool.name !== name)
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}`))
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}`))
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: ${callID}`))
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
content: [content[0], ...content.slice(1)],
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
executed: tool.providerExecuted,
@@ -222,7 +222,8 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
Message.make({
id: message.id,
role: "user",
content: `<conversation-checkpoint>
content: [
Message.text(`<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
@@ -232,7 +233,14 @@ ${message.summary}
<recent-context>
${message.recent}
</recent-context>
</conversation-checkpoint>`,
</conversation-checkpoint>`),
...(message.media ?? []).map((media) => ({
type: "media" as const,
mediaType: media.mime,
data: media.uri,
filename: media.name,
})),
],
metadata: message.metadata,
}),
]
+3 -3
View File
@@ -93,7 +93,7 @@ const layer = Layer.effect(
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
id: context.id,
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,
callID: context.callID,
id: context.id,
input: beforeEvent.input,
}
if ("failure" in execution) {
@@ -228,7 +228,7 @@ const layer = Layer.effect(
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: Tool.CallID.make(input.call.id),
id: 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,
callID: context.callID,
id: context.id,
}
```
+1 -1
View File
@@ -59,7 +59,7 @@ export const layer = Layer.effectDiscard(
source: {
type: "tool",
messageID: context.messageID,
callID: context.callID,
id: context.id,
},
})
const result = yield* mcp
+1 -1
View File
@@ -129,7 +129,7 @@ export const Plugin = {
const permissionSource = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
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, callID: context.callID }
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
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, callID: context.callID }
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
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,
callID: context.callID,
id: context.id,
}
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, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
.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, callID: context.callID },
tool: { messageID: context.messageID, id: context.id },
},
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,
callID: context.callID,
id: context.id,
}
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,
callID: string,
id: string,
command: string,
) {
yield* runtime.job.wait({ id: callID }).pipe(
yield* runtime.job.wait({ id: id }).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="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
text: `<shell id="${id}" 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,
callID: context.callID,
id: context.id,
}
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.callID,
id: context.id,
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.callID, info.command)
yield* notifyWhenDone(context.sessionID, context.id, 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.callID, info.command)
yield* notifyWhenDone(context.sessionID, context.id, 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, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
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,
callID: context.callID,
id: context.id,
},
})
.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, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
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, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
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,
callID: context.callID,
id: context.id,
}
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",
callID: "call_hosted",
id: "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",
callID: "call_failed",
id: "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",
callID: "call_hosted",
id: "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",
callID: "call_failed",
id: "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,
callID: "call_mcp_permission",
id: "call_mcp_permission",
},
})
expect(calls).toBe(0)
+71 -5
View File
@@ -22,6 +22,7 @@ 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"
@@ -113,6 +114,24 @@ 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([
@@ -178,7 +197,7 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
}),
)
it.effect("manual compaction summarizes short context instead of no-op", () =>
it.effect("manual compaction preserves ordered media in the retained tail", () =>
Effect.gen(function* () {
requests = []
const db = (yield* Database.Service).db
@@ -190,9 +209,35 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
const userMessage = {
id: SessionMessage.ID.create(),
type: "user" as const,
text: "Manual compaction should include this short conversation.",
text: `Manual compaction should include this older conversation. ${"older context ".repeat(4_500)}`,
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: [] })
@@ -228,7 +273,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(
yield* compaction.compactManual({
session,
messages: [userMessage],
messages: [userMessage, recentMessage, latestMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toEqual({ status: "completed" })
@@ -245,9 +290,30 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this older conversation.")
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
{
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",
},
],
},
])
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,
callID: "active-call",
id: "active-call",
name: "echo",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
id: "active-call",
text: "{}",
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
id: "active-call",
input: {},
executed: false,
})
@@ -102,7 +102,15 @@ describe("toLLMMessages", () => {
status: "completed",
reason: "auto",
summary: "Earlier work",
recent: "Recent 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",
},
],
time: { created },
}),
],
@@ -142,9 +150,16 @@ 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(),
callID: "call-old",
id: "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, callID: Tool.CallID.make("call-context"), progress: expect.any(Function) },
{ sessionID, ...identity, id: 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.callID === "call-location"),
Stream.filter((event) => event.data.sessionID === sessionID && event.data.id === "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_/),
callID: Tool.CallID.make("call-location"),
id: 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, callID: "call-echo" }])
expect(authorizations).toMatchObject([{ sessionID, id: "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,
callID: "call-interrupted",
id: "call-interrupted",
name: "echo",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID,
callID: "call-interrupted",
id: "call-interrupted",
text: '{"text":"stale"}',
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
callID: "call-interrupted",
id: "call-interrupted",
input: { text: "stale" },
executed: false,
})
@@ -3051,19 +3051,19 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
callID: "call-hosted-interrupted",
id: "call-hosted-interrupted",
name: "web_search",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID,
callID: "call-hosted-interrupted",
id: "call-hosted-interrupted",
text: '{"query":"stale"}',
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
callID: "call-hosted-interrupted",
id: "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,
callID: "call-pending-interrupted",
id: "call-pending-interrupted",
name: "echo",
})
requests.length = 0
@@ -4120,7 +4120,7 @@ describe("SessionRunnerLLM", () => {
{
type: "session.tool.failed.2",
data: {
callID: "call-malformed",
id: "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({
callID: "call-malformed",
id: "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, 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.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.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 = (callID: string) =>
const start = (id: string) =>
Effect.gen(function* () {
yield* service.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
callID,
id,
name: "bash",
})
yield* service.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
callID,
id,
input: { command: "pwd" },
executed: false,
})
@@ -90,7 +90,7 @@ describe("Tool.Metadata", () => {
const progress = yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
assistantMessageID,
callID: "call-success",
id: "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,
callID: "call-success",
id: "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,
callID: "call-failed",
id: "call-failed",
metadata: { phase: "checkpoint" },
})
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID,
callID: "call-failed",
id: "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"),
callID: Tool.CallID.make("call_execute"),
id: 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, callID: "call-question" } },
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "call-question" } },
fields: [
{
key: "q0",
@@ -212,7 +212,7 @@ describe("QuestionTool", () => {
expect(capturedInput()).toEqual({
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "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 callID: Tool.CallID
readonly id: 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 callID: Tool.CallID
readonly id: 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 callID: Tool.CallID
readonly id: 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 callID: Tool.CallID
readonly id: 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,
callID: Schema.String,
id: 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,
callID: Schema.String,
id: Schema.String,
}).annotate({ identifier: "Question.Tool" })
export interface Tool extends Schema.Schema.Type<typeof Tool> {}
+3 -2
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 } from "./tool.js"
import { Content, FileContent } 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,
callID: Schema.String,
id: Schema.String,
}
export namespace Input {
@@ -515,6 +515,7 @@ 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
+2 -1
View File
@@ -2,7 +2,7 @@ export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { Content } from "./tool.js"
import { Content, FileContent } from "./tool.js"
import { Model } from "./model.js"
import { Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js"
@@ -222,6 +222,7 @@ 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 callID: CallID
readonly id: 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,
callID: "call_test",
id: "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,
callID: context.callID,
id: context.id,
},
}
const pending: PendingToolInvocation = {
+1 -1
View File
@@ -503,7 +503,7 @@ export namespace Backend {
sessionID: Schema.String,
agent: Schema.String,
messageID: Schema.String,
callID: Schema.String,
id: 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 = (callID: string, query: string) =>
const executeCall = (id: 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: callID,
id: id,
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",
callID: "call_success",
id: "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.callID), requireString(params.id)]
return [requireString(context.id), requireString(params.id)]
}),
)
for (const [id, callID, value] of [
for (const [requestID, toolID, value] of [
[5, "call_second", "second result"],
[6, "call_first", "first result"],
] as const) {
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id,
id: requestID,
method: "tool.finish",
params: {
id: byCall.get(callID),
id: byCall.get(toolID),
output: { structured: value, content: [{ type: "text", text: value }] },
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id, result: { ok: true } })
expect(yield* Queue.take(messages)).toMatchObject({ id: requestID, 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, callID?: string) {
latestTool(assistant: SessionMessageAssistant | undefined, id?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool =>
item.type === "tool" && (callID === undefined || item.id === callID),
item.type === "tool" && (id === undefined || item.id === id),
)
},
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.callID,
id: event.data.id,
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.callID,
event.data.id,
)
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.callID,
event.data.id,
)
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.callID,
event.data.id,
)
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.callID,
event.data.id,
)
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.callID,
event.data.id,
)
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.callID,
event.data.id,
)
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, callID: item.ref.call },
source: { type: "tool", messageID: item.ref.msg, id: 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, callID: ref.call } }
? { kind: "question", tool: { messageID: ref.msg, id: 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, callID: string) {
return `${messageID}\u0000${callID}`
function sourceKey(messageID: string, id: string) {
return `${messageID}\u0000${id}`
}
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.callID))
const tool = tools.get(sourceKey(request.source.messageID, request.source.id))
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.callID)
const key = sourceKey(request.source.messageID, request.source.id)
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.callID)),
!child.toolSources.has(sourceKey(request.source.messageID, request.source.id)),
)
)
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.callID))) return
if (child.finishedTools.has(sourceKey(event.data.assistantMessageID, event.data.id))) return
childTool(
child,
{
type: "tool",
id: event.data.callID,
id: event.data.id,
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.callID))
const current = child.tools.get(sourceKey(event.data.assistantMessageID, event.data.id))
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.callID)
const key = sourceKey(event.data.assistantMessageID, event.data.id)
if (child.finishedTools.has(key)) return
const current = child.tools.get(key)
childTool(
child,
{
type: "tool",
id: event.data.callID,
id: event.data.id,
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.callID)
const key = sourceKey(event.data.assistantMessageID, event.data.id)
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.callID,
id: event.data.id,
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.callID)
const key = sourceKey(event.data.assistantMessageID, event.data.id)
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.callID,
id: event.data.id,
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.callID), {})
pendingCalls.set(sourceKey(event.data.assistantMessageID, event.data.id), {})
return
}
if (event.type === "session.tool.called") {
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
const key = sourceKey(event.data.assistantMessageID, event.data.id)
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.callID)
const key = sourceKey(event.data.assistantMessageID, event.data.id)
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;
// callID correlates the live shell events once shell.started is observed, and
// id 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
callID?: string
id?: string
resolve: () => void
abort: () => void
}
@@ -291,27 +291,27 @@ function streamPartKey(messageID: string, partID: string) {
return `${messageID}\u0000${partID}`
}
function permissionSourceKey(messageID: string, callID: string) {
return streamPartKey(messageID, callID)
function permissionSourceKey(messageID: string, id: string) {
return streamPartKey(messageID, id)
}
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.callID))
const tool = tools.get(permissionSourceKey(request.source.messageID, request.source.id))
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(
callID: string,
id: string,
command: string,
next: Pick<StreamCommit, "text" | "phase" | "toolState" | "toolError">,
): StreamCommit {
return {
kind: "tool",
source: "tool",
partID: `shell:${callID}`,
partID: `shell:${id}`,
tool: "shell",
shell: { command },
...next,
@@ -319,7 +319,7 @@ function shellCommit(
}
function shellTerminal(
callID: string,
id: 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(callID, command, { text, phase: "progress", toolState: "completed" })]
if (!error) return [shellCommit(id, command, { text, phase: "progress", toolState: "completed" })]
return [
...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []),
shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }),
...(text ? [shellCommit(id, command, { text, phase: "progress", toolState: "running" })] : []),
shellCommit(id, 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.callID) === key,
request.source?.type === "tool" && permissionSourceKey(request.source.messageID, request.source.id) === 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.callID = message.shellID
if (state.shellWait?.messageID === message.id) state.shellWait.id = 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?.callID === message.shellID) state.shellWait.resolve()
if (completed && state.shellWait?.id === 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.callID)] : [],
request.source?.type === "tool" ? [permissionSourceKey(request.source.messageID, request.source.id)] : [],
),
)
const messageIDs = [
...new Set(
permissions.flatMap((request) => {
if (request.source?.type !== "tool") return []
const key = permissionSourceKey(request.source.messageID, request.source.callID)
const key = permissionSourceKey(request.source.messageID, request.source.id)
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.callID = event.data.shell.id
if (wait?.eventID === event.id) wait.id = 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?.callID === event.data.shell.id
const owned = wait?.id === 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.callID,
id: event.data.id,
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.callID))
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.id))
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.callID)
const key = streamPartKey(event.data.assistantMessageID, event.data.id)
if (state.finishedTools.has(key)) return
const current = state.tools.get(key)
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
id: event.data.id,
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.callID)
const key = streamPartKey(event.data.assistantMessageID, event.data.id)
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.callID,
id: event.data.id,
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.callID))
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.id))
const part = current?.part
const failed = event.type === "session.tool.failed"
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
id: event.data.id,
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 = {
id: string
partID: string
sessionID: string
messageID: string
type?: "tool"
callID: string
id: 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.callID === part()?.id
return request?.source?.type === "tool" && request.source.id === 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.callID)
const part = message.content.find((part) => part.type === "tool" && part.id === tool.id)
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.callID] : [],
request.source?.type === "tool" ? [request.source.id] : [],
),
)
}
@@ -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.callID },
{ messageID: event.data.assistantMessageID, partID: event.data.id },
{ 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",
callID: "call-read",
id: "call-read",
name: "read",
},
})
@@ -951,7 +951,7 @@ test("classifies live tool rows independently of their call ID", async () => {
data: {
sessionID,
assistantMessageID: "message-assistant",
callID: "reasoning:0",
id: "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",
callID: "call-1",
id: "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",
callID: "call-1",
id: "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",
callID: "call-1",
id: "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",
callID: "call-1",
id: "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", callID: "call-shell" },
source: { type: "tool", messageID: "msg-shell", id: "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", callID: "call-search" },
source: { type: "tool", messageID: "msg-search", id: "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", callID: "call-edit" },
source: { type: "tool", messageID: "msg-edit", id: "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", callID: "call_child_source" },
source: { type: "tool", messageID: "msg_child_source", id: "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, callID: "call_repeated", name: "read" },
data: { sessionID: "ses_1", assistantMessageID: messageID, id: "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,
callID: "call_repeated",
id: "call_repeated",
input: { path: `${index + 1}.txt` },
executed: true,
},
@@ -2055,7 +2055,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: messageID,
callID: "call_repeated",
id: "call_repeated",
metadata: {},
content: [{ type: "text", text: "" }],
executed: true,
@@ -2098,7 +2098,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
callID: "call_progress",
id: "call_progress",
name: "shell",
},
})
@@ -2110,7 +2110,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
callID: "call_progress",
id: "call_progress",
input: { command: "printf partial && false" },
executed: true,
},
@@ -2122,7 +2122,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
callID: "call_progress",
id: "call_progress",
metadata: { checkpoint: 1 },
},
})
@@ -2134,7 +2134,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
callID: "call_progress",
id: "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",
callID: "call_failed_subagent",
id: "call_failed_subagent",
name: "subagent",
},
})
@@ -2945,7 +2945,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
callID: "call_failed_subagent",
id: "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",
callID: "call_failed_subagent",
id: "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",
callID: "call_subagent",
id: "call_subagent",
name: "subagent",
},
})
@@ -3008,7 +3008,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_subagent",
callID: "call_subagent",
id: "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",
callID: "call_subagent",
id: "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",
callID: "call_child_shell",
id: "call_child_shell",
name: "shell",
},
})
@@ -3058,7 +3058,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
callID: "call_child_shell",
id: "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",
callID: "call_child_shell",
id: "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", callID: "call_child_shell" },
source: { type: "tool", messageID: "msg_child_tool", id: "call_child_shell" },
},
})
events.push({
@@ -3094,7 +3094,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
callID: "call_child_shell",
id: "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 = (callID: string, name: string, seq: number) =>
const inputStarted = (id: string, name: string, seq: number) =>
events.push({
id: `evt_started_${callID}`,
id: `evt_started_${id}`,
created: seq,
type: "session.tool.input.started",
durable: durable("ses_child", seq),
data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID, name },
data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", id, name },
})
const called = (callID: string, input: Record<string, unknown>, seq: number) =>
const called = (id: string, input: Record<string, unknown>, seq: number) =>
events.push({
id: `evt_called_${callID}`,
id: `evt_called_${id}`,
created: seq,
type: "session.tool.called",
durable: durable("ses_child", seq),
data: {
sessionID: "ses_child",
assistantMessageID: "msg_tool_projected",
callID,
id,
input,
executed: true,
},
@@ -3567,7 +3567,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child",
assistantMessageID: "msg_tool_projected",
callID: "call_terminal",
id: "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",
callID: "call_sub",
id: "call_sub",
name: "subagent",
},
})
@@ -3717,7 +3717,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
callID: "call_sub",
id: "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",
callID: "call_sub",
id: "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": 8000
"tokens": 15000
},
"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` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `keep.tokens` | `15000` | 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,9 +110,12 @@ 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 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.
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.
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 `sessionID`,
`agent`, `messageID`, `callID`, and `progress`. A tool with `output`
The executor receives a second context argument containing `id`, `sessionID`,
`agent`, `messageID`, 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": 8000
"tokens": 15000
},
"buffer": 20000
}