Merge remote-tracking branch 'origin/v2' into media-attachment
# Conflicts: # packages/core/test/session-compaction.test.ts
This commit is contained in:
commit
6cc8195572
93 changed files with 5662 additions and 3800 deletions
|
|
@ -1,14 +1,5 @@
|
||||||
import type {
|
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
EventSubscribeOutput,
|
import type { ReasoningPart, StepFinishPart, StepStartPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
OpenCodeClient,
|
|
||||||
} from "@opencode-ai/client/promise"
|
|
||||||
import type {
|
|
||||||
ReasoningPart,
|
|
||||||
StepFinishPart,
|
|
||||||
StepStartPart,
|
|
||||||
TextPart,
|
|
||||||
ToolPart,
|
|
||||||
} from "@opencode-ai/sdk/v2"
|
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
import { UI } from "./ui"
|
import { UI } from "./ui"
|
||||||
|
|
@ -169,8 +160,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
event.type === "session.execution.settled" &&
|
event.type === "session.execution.interrupted" &&
|
||||||
event.data.outcome === "interrupted" &&
|
event.data.reason === "user" &&
|
||||||
(interrupted || permissionRejected || questionRejected || formCancelled)
|
(interrupted || permissionRejected || questionRejected || formCancelled)
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
|
|
@ -194,11 +185,12 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.text.started") {
|
if (event.type === "session.text.started") {
|
||||||
starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
|
starts.set("text", { id: partID(event.id), timestamp: time })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.text.ended") {
|
if (event.type === "session.text.ended") {
|
||||||
const started = starts.get(event.data.textID)
|
const started = starts.get("text")
|
||||||
|
starts.delete("text")
|
||||||
const part: TextPart = {
|
const part: TextPart = {
|
||||||
id: started?.id ?? partID(event.id),
|
id: started?.id ?? partID(event.id),
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|
@ -212,18 +204,19 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.reasoning.started") {
|
if (event.type === "session.reasoning.started") {
|
||||||
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
|
starts.set("reasoning", { id: partID(event.id), timestamp: time })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.ended" && input.thinking) {
|
if (event.type === "session.reasoning.ended" && input.thinking) {
|
||||||
const started = starts.get(event.data.reasoningID)
|
const started = starts.get("reasoning")
|
||||||
|
starts.delete("reasoning")
|
||||||
const part: ReasoningPart = {
|
const part: ReasoningPart = {
|
||||||
id: started?.id ?? partID(event.id),
|
id: started?.id ?? partID(event.id),
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: event.data.text,
|
text: event.data.text,
|
||||||
metadata: event.data.providerMetadata,
|
metadata: event.data.state,
|
||||||
time: { start: started?.timestamp ?? time, end: time },
|
time: { start: started?.timestamp ?? time, end: time },
|
||||||
}
|
}
|
||||||
if (emit("reasoning", time, { part })) continue
|
if (emit("reasoning", time, { part })) continue
|
||||||
|
|
@ -261,10 +254,10 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
id: current?.id ?? partID(event.id),
|
id: current?.id ?? partID(event.id),
|
||||||
timestamp: current?.timestamp ?? time,
|
timestamp: current?.timestamp ?? time,
|
||||||
assistantMessageID: event.data.assistantMessageID,
|
assistantMessageID: event.data.assistantMessageID,
|
||||||
tool: event.data.tool,
|
tool: current?.tool ?? "tool",
|
||||||
input: event.data.input,
|
input: event.data.input,
|
||||||
raw: current?.raw,
|
raw: current?.raw,
|
||||||
provider: event.data.provider,
|
provider: { executed: event.data.executed, state: event.data.state },
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -291,7 +284,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
outputPaths: event.data.outputPaths,
|
outputPaths: event.data.outputPaths,
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
providerCall: current.provider,
|
providerCall: current.provider,
|
||||||
providerResult: event.data.provider,
|
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||||
rawInput: current.raw,
|
rawInput: current.raw,
|
||||||
},
|
},
|
||||||
time: { start: current.timestamp, end: time },
|
time: { start: current.timestamp, end: time },
|
||||||
|
|
@ -318,7 +311,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
metadata: {
|
metadata: {
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
providerCall: current.provider,
|
providerCall: current.provider,
|
||||||
providerResult: event.data.provider,
|
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||||
rawInput: current.raw,
|
rawInput: current.raw,
|
||||||
},
|
},
|
||||||
time: { start: current.timestamp, end: time },
|
time: { start: current.timestamp, end: time },
|
||||||
|
|
@ -353,16 +346,25 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.settled") {
|
if (event.type === "session.execution.failed") {
|
||||||
if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) {
|
if (!emittedError && !questionRejected && !formCancelled) {
|
||||||
emittedError = true
|
emittedError = true
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
const error = event.data.error ?? { type: "unknown", message: "Session execution failed" }
|
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||||
if (!emit("error", time, { error })) UI.error(error.message)
|
|
||||||
}
|
}
|
||||||
if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.execution.interrupted") {
|
||||||
|
if (event.data.reason === "user" && interrupted) process.exitCode = 130
|
||||||
|
if (event.data.reason !== "user" && !emittedError) {
|
||||||
|
emittedError = true
|
||||||
|
process.exitCode = 1
|
||||||
|
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
|
||||||
|
if (!emit("error", time, { error })) UI.error(error.message)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "session.execution.succeeded") return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,54 +35,60 @@ export function outputText(content: ReadonlyArray<{ type: string; text?: string
|
||||||
export function legacyTool(input: {
|
export function legacyTool(input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID: string
|
messageID: string
|
||||||
callID: string
|
tool: SessionMessageAssistantTool
|
||||||
name: string
|
|
||||||
state: SessionMessageAssistantTool["state"]
|
|
||||||
time: SessionMessageAssistantTool["time"]
|
|
||||||
provider?: SessionMessageAssistantTool["provider"]
|
|
||||||
}): ToolPart {
|
}): ToolPart {
|
||||||
|
const tool = input.tool
|
||||||
|
const providerCall =
|
||||||
|
tool.executed === undefined && tool.providerState === undefined
|
||||||
|
? undefined
|
||||||
|
: { executed: tool.executed, state: tool.providerState }
|
||||||
|
const providerResult =
|
||||||
|
tool.executed === undefined && tool.providerResultState === undefined
|
||||||
|
? undefined
|
||||||
|
: { executed: tool.executed, state: tool.providerResultState }
|
||||||
const base = {
|
const base = {
|
||||||
id: `prt_${input.callID}`,
|
id: `prt_${tool.id}`,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
messageID: input.messageID,
|
messageID: input.messageID,
|
||||||
type: "tool" as const,
|
type: "tool" as const,
|
||||||
callID: input.callID,
|
callID: tool.id,
|
||||||
tool: input.name,
|
tool: tool.name,
|
||||||
}
|
}
|
||||||
if (input.state.status === "pending") {
|
if (tool.state.status === "pending") {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
state: { status: "pending", input: {}, raw: input.state.input },
|
state: { status: "pending", input: {}, raw: tool.state.input },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (input.state.status === "running") {
|
if (tool.state.status === "running") {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
state: {
|
state: {
|
||||||
status: "running",
|
status: "running",
|
||||||
input: input.state.input,
|
input: tool.state.input,
|
||||||
title: input.name,
|
title: tool.name,
|
||||||
metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider },
|
metadata: { structured: tool.state.structured, content: tool.state.content, providerCall },
|
||||||
time: { start: input.time.ran ?? input.time.created },
|
time: { start: tool.time.ran ?? tool.time.created },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (input.state.status === "completed") {
|
if (tool.state.status === "completed") {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: input.state.input,
|
input: tool.state.input,
|
||||||
output: outputText(input.state.content),
|
output: outputText(tool.state.content),
|
||||||
title: input.name,
|
title: tool.name,
|
||||||
metadata: {
|
metadata: {
|
||||||
structured: input.state.structured,
|
structured: tool.state.structured,
|
||||||
content: input.state.content,
|
content: tool.state.content,
|
||||||
outputPaths: input.state.outputPaths,
|
outputPaths: tool.state.outputPaths,
|
||||||
result: input.state.result,
|
result: tool.state.result,
|
||||||
providerCall: input.provider,
|
providerCall,
|
||||||
|
providerResult,
|
||||||
},
|
},
|
||||||
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
|
time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,15 +96,16 @@ export function legacyTool(input: {
|
||||||
...base,
|
...base,
|
||||||
state: {
|
state: {
|
||||||
status: "error",
|
status: "error",
|
||||||
input: input.state.input,
|
input: tool.state.input,
|
||||||
error: input.state.error.message,
|
error: tool.state.error.message,
|
||||||
metadata: {
|
metadata: {
|
||||||
structured: input.state.structured,
|
structured: tool.state.structured,
|
||||||
content: input.state.content,
|
content: tool.state.content,
|
||||||
result: input.state.result,
|
result: tool.state.result,
|
||||||
providerCall: input.provider,
|
providerCall,
|
||||||
|
providerResult,
|
||||||
},
|
},
|
||||||
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
|
time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,6 +145,7 @@ type ToolTrack = {
|
||||||
name: string
|
name: string
|
||||||
input: Record<string, unknown>
|
input: Record<string, unknown>
|
||||||
started: number
|
started: number
|
||||||
|
providerState?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChildState = {
|
type ChildState = {
|
||||||
|
|
@ -225,6 +233,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||||
const hydrationOverflow = new Set<string>()
|
const hydrationOverflow = new Set<string>()
|
||||||
const hydrations = new Map<string, Promise<void>>()
|
const hydrations = new Map<string, Promise<void>>()
|
||||||
let selected: string | undefined
|
let selected: string | undefined
|
||||||
|
const fragmentKey = (messageID: string, partID: string) => `${messageID}\u0000${partID}`
|
||||||
|
|
||||||
const ensureChild = (sessionID: string): ChildState => {
|
const ensureChild = (sessionID: string): ChildState => {
|
||||||
const existing = children.get(sessionID)
|
const existing = children.get(sessionID)
|
||||||
|
|
@ -305,11 +314,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||||
const part = legacyTool({
|
const part = legacyTool({
|
||||||
sessionID: child.sessionID,
|
sessionID: child.sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
callID: item.id,
|
tool: item,
|
||||||
name: item.name,
|
|
||||||
state: item.state,
|
|
||||||
time: item.time,
|
|
||||||
provider: item.provider,
|
|
||||||
})
|
})
|
||||||
if (item.state.status === "pending") return
|
if (item.state.status === "pending") return
|
||||||
child.callIDs.add(item.id)
|
child.callIDs.add(item.id)
|
||||||
|
|
@ -339,31 +344,37 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||||
}
|
}
|
||||||
if (message.type !== "assistant") continue
|
if (message.type !== "assistant") continue
|
||||||
child.messageIDs.add(message.id)
|
child.messageIDs.add(message.id)
|
||||||
|
let textOrdinal = 0
|
||||||
|
let reasoningOrdinal = 0
|
||||||
for (const item of message.content) {
|
for (const item of message.content) {
|
||||||
if (item.type === "text") {
|
if (item.type === "text") {
|
||||||
child.text.set(item.id, item.text)
|
const id = `text:${textOrdinal++}`
|
||||||
child.projectedText.set(item.id, item.text)
|
const key = fragmentKey(message.id, id)
|
||||||
setFrame(child, `text:${item.id}`, {
|
child.text.set(key, item.text)
|
||||||
|
child.projectedText.set(key, item.text)
|
||||||
|
setFrame(child, key, {
|
||||||
kind: "assistant",
|
kind: "assistant",
|
||||||
source: "assistant",
|
source: "assistant",
|
||||||
text: item.text,
|
text: item.text,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: message.id,
|
messageID: message.id,
|
||||||
partID: item.id,
|
partID: id,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (item.type === "reasoning") {
|
if (item.type === "reasoning") {
|
||||||
child.reasoning.set(item.id, item.text)
|
const id = `reasoning:${reasoningOrdinal++}`
|
||||||
child.projectedReasoning.set(item.id, item.text)
|
const key = fragmentKey(message.id, id)
|
||||||
|
child.reasoning.set(key, item.text)
|
||||||
|
child.projectedReasoning.set(key, item.text)
|
||||||
if (input.thinking)
|
if (input.thinking)
|
||||||
setFrame(child, `reasoning:${item.id}`, {
|
setFrame(child, key, {
|
||||||
kind: "reasoning",
|
kind: "reasoning",
|
||||||
source: "reasoning",
|
source: "reasoning",
|
||||||
text: `Thinking: ${item.text}`,
|
text: `Thinking: ${item.text}`,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: message.id,
|
messageID: message.id,
|
||||||
partID: item.id,
|
partID: id,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -467,74 +478,88 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||||
input.emit()
|
input.emit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.text.started") {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.type === "session.text.delta") {
|
if (event.type === "session.text.delta") {
|
||||||
const projected = child.projectedText.get(event.data.textID)
|
const id = `text:${event.data.ordinal}`
|
||||||
|
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||||
|
const projected = child.projectedText.get(key)
|
||||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||||
if (projected && covered >= 0) {
|
if (projected && covered >= 0) {
|
||||||
child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length))
|
child.projectedText.set(key, projected.slice(covered + event.data.delta.length))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const next = (child.text.get(event.data.textID) ?? "") + event.data.delta
|
const next = (child.text.get(key) ?? "") + event.data.delta
|
||||||
child.text.set(event.data.textID, next)
|
child.text.set(key, next)
|
||||||
setFrame(child, `text:${event.data.textID}`, {
|
setFrame(child, key, {
|
||||||
kind: "assistant",
|
kind: "assistant",
|
||||||
source: "assistant",
|
source: "assistant",
|
||||||
text: next,
|
text: next,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: event.data.textID,
|
partID: id,
|
||||||
})
|
})
|
||||||
touch(child, event.created)
|
touch(child, event.created)
|
||||||
notifyDetail(child)
|
notifyDetail(child)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.text.ended") {
|
if (event.type === "session.text.ended") {
|
||||||
child.text.set(event.data.textID, event.data.text)
|
const id = `text:${event.data.ordinal}`
|
||||||
child.projectedText.delete(event.data.textID)
|
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||||
setFrame(child, `text:${event.data.textID}`, {
|
child.text.set(key, event.data.text)
|
||||||
|
child.projectedText.delete(key)
|
||||||
|
setFrame(child, key, {
|
||||||
kind: "assistant",
|
kind: "assistant",
|
||||||
source: "assistant",
|
source: "assistant",
|
||||||
text: event.data.text,
|
text: event.data.text,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: event.data.textID,
|
partID: id,
|
||||||
})
|
})
|
||||||
touch(child, event.created)
|
touch(child, event.created)
|
||||||
notifyDetail(child)
|
notifyDetail(child)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.reasoning.started") {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.type === "session.reasoning.delta") {
|
if (event.type === "session.reasoning.delta") {
|
||||||
const projected = child.projectedReasoning.get(event.data.reasoningID)
|
const id = `reasoning:${event.data.ordinal}`
|
||||||
|
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||||
|
const projected = child.projectedReasoning.get(key)
|
||||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||||
if (projected && covered >= 0) {
|
if (projected && covered >= 0) {
|
||||||
child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length))
|
child.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta
|
const next = (child.reasoning.get(key) ?? "") + event.data.delta
|
||||||
child.reasoning.set(event.data.reasoningID, next)
|
child.reasoning.set(key, next)
|
||||||
if (!input.thinking) return
|
if (!input.thinking) return
|
||||||
setFrame(child, `reasoning:${event.data.reasoningID}`, {
|
setFrame(child, key, {
|
||||||
kind: "reasoning",
|
kind: "reasoning",
|
||||||
source: "reasoning",
|
source: "reasoning",
|
||||||
text: `Thinking: ${next}`,
|
text: `Thinking: ${next}`,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: event.data.reasoningID,
|
partID: id,
|
||||||
})
|
})
|
||||||
notifyDetail(child)
|
notifyDetail(child)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.ended") {
|
if (event.type === "session.reasoning.ended") {
|
||||||
child.reasoning.set(event.data.reasoningID, event.data.text)
|
const id = `reasoning:${event.data.ordinal}`
|
||||||
child.projectedReasoning.delete(event.data.reasoningID)
|
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||||
|
child.reasoning.set(key, event.data.text)
|
||||||
|
child.projectedReasoning.delete(key)
|
||||||
if (!input.thinking) return
|
if (!input.thinking) return
|
||||||
setFrame(child, `reasoning:${event.data.reasoningID}`, {
|
setFrame(child, key, {
|
||||||
kind: "reasoning",
|
kind: "reasoning",
|
||||||
source: "reasoning",
|
source: "reasoning",
|
||||||
text: `Thinking: ${event.data.text}`,
|
text: `Thinking: ${event.data.text}`,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: event.data.reasoningID,
|
partID: id,
|
||||||
})
|
})
|
||||||
notifyDetail(child)
|
notifyDetail(child)
|
||||||
return
|
return
|
||||||
|
|
@ -548,17 +573,19 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||||
if (child.finishedTools.has(event.data.callID)) return
|
if (child.finishedTools.has(event.data.callID)) return
|
||||||
const current = child.tools.get(event.data.callID)
|
const current = child.tools.get(event.data.callID)
|
||||||
child.tools.set(event.data.callID, {
|
child.tools.set(event.data.callID, {
|
||||||
name: event.data.tool,
|
name: current?.name ?? "tool",
|
||||||
input: event.data.input,
|
input: event.data.input,
|
||||||
started: current?.started ?? event.created,
|
started: current?.started ?? event.created,
|
||||||
|
providerState: event.data.state,
|
||||||
})
|
})
|
||||||
childTool(
|
childTool(
|
||||||
child,
|
child,
|
||||||
structuredClone({
|
structuredClone({
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: event.data.callID,
|
id: event.data.callID,
|
||||||
name: event.data.tool,
|
name: current?.name ?? "tool",
|
||||||
provider: event.data.provider,
|
executed: event.data.executed,
|
||||||
|
providerState: event.data.state,
|
||||||
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
||||||
time: { created: current?.started ?? event.created, ran: event.created },
|
time: { created: current?.started ?? event.created, ran: event.created },
|
||||||
}) as SessionMessageAssistantTool,
|
}) as SessionMessageAssistantTool,
|
||||||
|
|
@ -578,7 +605,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: event.data.callID,
|
id: event.data.callID,
|
||||||
name: current?.name ?? "tool",
|
name: current?.name ?? "tool",
|
||||||
provider: event.data.provider,
|
executed: event.data.executed,
|
||||||
|
providerState: current?.providerState,
|
||||||
|
providerResultState: event.data.resultState,
|
||||||
state: failed
|
state: failed
|
||||||
? {
|
? {
|
||||||
status: "error",
|
status: "error",
|
||||||
|
|
@ -608,6 +637,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||||
notifyDetail(child)
|
notifyDetail(child)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.step.ended") return
|
||||||
if (event.type === "session.step.failed") {
|
if (event.type === "session.step.failed") {
|
||||||
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
|
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
|
||||||
kind: "error",
|
kind: "error",
|
||||||
|
|
@ -620,9 +650,23 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||||
notifyDetail(child)
|
notifyDetail(child)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.settled") {
|
if (event.type === "session.execution.started") {
|
||||||
|
child.status = "running"
|
||||||
|
touch(child, event.created)
|
||||||
|
input.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.type === "session.execution.succeeded" ||
|
||||||
|
event.type === "session.execution.failed" ||
|
||||||
|
event.type === "session.execution.interrupted"
|
||||||
|
) {
|
||||||
child.status =
|
child.status =
|
||||||
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
|
event.type === "session.execution.succeeded"
|
||||||
|
? "completed"
|
||||||
|
: event.type === "session.execution.interrupted"
|
||||||
|
? "cancelled"
|
||||||
|
: "error"
|
||||||
touch(child, event.created)
|
touch(child, event.created)
|
||||||
input.emit()
|
input.emit()
|
||||||
}
|
}
|
||||||
|
|
@ -644,8 +688,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||||
|
|
||||||
return {
|
return {
|
||||||
main(event) {
|
main(event) {
|
||||||
|
if (event.type === "session.tool.input.started") {
|
||||||
|
if (event.data.name === "subagent") pendingCalls.set(event.data.callID, {})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.type === "session.tool.called") {
|
if (event.type === "session.tool.called") {
|
||||||
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
|
if (pendingCalls.has(event.data.callID)) pendingCalls.set(event.data.callID, event.data.input)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.failed") {
|
if (event.type === "session.tool.failed") {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
import type {
|
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
EventSubscribeOutput,
|
|
||||||
OpenCodeClient,
|
|
||||||
} from "@opencode-ai/client/promise"
|
|
||||||
import type {
|
import type {
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
SessionMessage,
|
SessionMessage,
|
||||||
SessionMessageAssistant,
|
|
||||||
SessionMessageAssistantTool,
|
SessionMessageAssistantTool,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
|
|
@ -101,6 +97,7 @@ type ToolState = {
|
||||||
input: Record<string, unknown>
|
input: Record<string, unknown>
|
||||||
started: number
|
started: number
|
||||||
running: boolean
|
running: boolean
|
||||||
|
providerState?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
|
|
@ -264,8 +261,7 @@ function shellTerminal(
|
||||||
: shell.status === "exited"
|
: shell.status === "exited"
|
||||||
? `Shell exited with code ${shell.exit ?? "unknown"}`
|
? `Shell exited with code ${shell.exit ?? "unknown"}`
|
||||||
: `Shell ${shell.status}`
|
: `Shell ${shell.status}`
|
||||||
if (!error)
|
if (!error) return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })]
|
||||||
return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })]
|
|
||||||
return [
|
return [
|
||||||
...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []),
|
...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []),
|
||||||
shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }),
|
shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }),
|
||||||
|
|
@ -310,9 +306,7 @@ async function resolveSelectedModel(input: StreamInput, next: Pick<SessionTurnIn
|
||||||
.then((response) => response.model)
|
.then((response) => response.model)
|
||||||
if (session) return { ...session, variant: next.variant }
|
if (session) return { ...session, variant: next.variant }
|
||||||
|
|
||||||
const fallback = await input.sdk.model
|
const fallback = await input.sdk.model.default(undefined, { signal: next.signal }).then((response) => response.data)
|
||||||
.default(undefined, { signal: next.signal })
|
|
||||||
.then((response) => response.data)
|
|
||||||
if (!fallback) return
|
if (!fallback) return
|
||||||
return { providerID: fallback.providerID, id: fallback.id, variant: next.variant }
|
return { providerID: fallback.providerID, id: fallback.id, variant: next.variant }
|
||||||
}
|
}
|
||||||
|
|
@ -393,11 +387,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
const part = legacyTool({
|
const part = legacyTool({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
callID: item.id,
|
tool: item,
|
||||||
name: item.name,
|
|
||||||
state: item.state,
|
|
||||||
time: item.time,
|
|
||||||
provider: item.provider,
|
|
||||||
})
|
})
|
||||||
if (item.state.status === "pending") return
|
if (item.state.status === "pending") return
|
||||||
if (item.state.status === "running") {
|
if (item.state.status === "running") {
|
||||||
|
|
@ -408,6 +398,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
input: item.state.input,
|
input: item.state.input,
|
||||||
started: item.time.ran ?? item.time.created,
|
started: item.time.ran ?? item.time.created,
|
||||||
running: true,
|
running: true,
|
||||||
|
providerState: item.providerState,
|
||||||
})
|
})
|
||||||
write([toolCommit(part, "start")], { phase: "running", status: `running ${item.name}` })
|
write([toolCommit(part, "start")], { phase: "running", status: `running ${item.name}` })
|
||||||
return
|
return
|
||||||
|
|
@ -479,9 +470,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
}
|
}
|
||||||
if (message.type !== "assistant") return
|
if (message.type !== "assistant") return
|
||||||
state.messageIDs.add(message.id)
|
state.messageIDs.add(message.id)
|
||||||
|
let textOrdinal = 0
|
||||||
|
let reasoningOrdinal = 0
|
||||||
for (const item of message.content) {
|
for (const item of message.content) {
|
||||||
if (item.type === "text") {
|
if (item.type === "text") {
|
||||||
const key = streamPartKey(message.id, item.id)
|
const id = `text:${textOrdinal++}`
|
||||||
|
const key = streamPartKey(message.id, id)
|
||||||
const sent = state.text.get(key)?.length ?? 0
|
const sent = state.text.get(key)?.length ?? 0
|
||||||
state.text.set(key, item.text)
|
state.text.set(key, item.text)
|
||||||
if (render) state.projectedText.set(key, item.text)
|
if (render) state.projectedText.set(key, item.text)
|
||||||
|
|
@ -493,13 +487,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
text: item.text.slice(sent),
|
text: item.text.slice(sent),
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: message.id,
|
messageID: message.id,
|
||||||
partID: item.id,
|
partID: id,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (item.type === "reasoning") {
|
if (item.type === "reasoning") {
|
||||||
const key = streamPartKey(message.id, item.id)
|
const id = `reasoning:${reasoningOrdinal++}`
|
||||||
|
const key = streamPartKey(message.id, id)
|
||||||
const sent = state.reasoning.get(key)?.length ?? 0
|
const sent = state.reasoning.get(key)?.length ?? 0
|
||||||
state.reasoning.set(key, item.text)
|
state.reasoning.set(key, item.text)
|
||||||
if (render) state.projectedReasoning.set(key, item.text)
|
if (render) state.projectedReasoning.set(key, item.text)
|
||||||
|
|
@ -511,7 +506,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent),
|
text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent),
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: message.id,
|
messageID: message.id,
|
||||||
partID: item.id,
|
partID: id,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
continue
|
continue
|
||||||
|
|
@ -626,8 +621,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
if (owned) wait.resolve()
|
if (owned) wait.resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.text.started") {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.type === "session.text.delta") {
|
if (event.type === "session.text.delta") {
|
||||||
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
|
const id = `text:${event.data.ordinal}`
|
||||||
|
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||||
const projected = state.projectedText.get(key)
|
const projected = state.projectedText.get(key)
|
||||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||||
if (projected && covered >= 0) {
|
if (projected && covered >= 0) {
|
||||||
|
|
@ -643,13 +642,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
text: event.data.delta,
|
text: event.data.delta,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: event.data.textID,
|
partID: id,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.text.ended") {
|
if (event.type === "session.text.ended") {
|
||||||
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
|
const id = `text:${event.data.ordinal}`
|
||||||
|
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||||
const previous = state.text.get(key) ?? ""
|
const previous = state.text.get(key) ?? ""
|
||||||
if (event.data.text.length > previous.length)
|
if (event.data.text.length > previous.length)
|
||||||
write([
|
write([
|
||||||
|
|
@ -659,15 +659,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
text: event.data.text.slice(previous.length),
|
text: event.data.text.slice(previous.length),
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: event.data.textID,
|
partID: id,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
state.text.set(key, event.data.text)
|
state.text.set(key, event.data.text)
|
||||||
state.projectedText.delete(key)
|
state.projectedText.delete(key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.reasoning.started") {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.type === "session.reasoning.delta") {
|
if (event.type === "session.reasoning.delta") {
|
||||||
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
|
const id = `reasoning:${event.data.ordinal}`
|
||||||
|
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||||
const projected = state.projectedReasoning.get(key)
|
const projected = state.projectedReasoning.get(key)
|
||||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||||
if (projected && covered >= 0) {
|
if (projected && covered >= 0) {
|
||||||
|
|
@ -684,13 +688,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
|
text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: event.data.reasoningID,
|
partID: id,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.ended") {
|
if (event.type === "session.reasoning.ended") {
|
||||||
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
|
const id = `reasoning:${event.data.ordinal}`
|
||||||
|
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||||
const previous = state.reasoning.get(key) ?? ""
|
const previous = state.reasoning.get(key) ?? ""
|
||||||
if (input.thinking && event.data.text.length > previous.length)
|
if (input.thinking && event.data.text.length > previous.length)
|
||||||
write([
|
write([
|
||||||
|
|
@ -700,7 +705,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`,
|
text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: event.data.reasoningID,
|
partID: id,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
state.reasoning.set(key, event.data.text)
|
state.reasoning.set(key, event.data.text)
|
||||||
|
|
@ -723,8 +728,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
const item = structuredClone({
|
const item = structuredClone({
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: event.data.callID,
|
id: event.data.callID,
|
||||||
name: event.data.tool,
|
name: current?.name ?? "tool",
|
||||||
provider: event.data.provider,
|
executed: event.data.executed,
|
||||||
|
providerState: event.data.state,
|
||||||
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
||||||
time: { created: current?.started ?? event.created, ran: event.created },
|
time: { created: current?.started ?? event.created, ran: event.created },
|
||||||
}) as SessionMessageAssistantTool
|
}) as SessionMessageAssistantTool
|
||||||
|
|
@ -739,7 +745,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: event.data.callID,
|
id: event.data.callID,
|
||||||
name: current?.name ?? "tool",
|
name: current?.name ?? "tool",
|
||||||
provider: event.data.provider,
|
executed: event.data.executed,
|
||||||
|
providerState: current?.providerState,
|
||||||
|
providerResultState: event.data.resultState,
|
||||||
state: failed
|
state: failed
|
||||||
? {
|
? {
|
||||||
status: "error",
|
status: "error",
|
||||||
|
|
@ -791,7 +799,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
event.data.tokens.cache.write
|
event.data.tokens.cache.write
|
||||||
const usage = total > 0 ? total.toLocaleString() : ""
|
const usage = total > 0 ? total.toLocaleString() : ""
|
||||||
write([], {
|
write([], {
|
||||||
phase: event.data.finish === "tool-calls" ? "running" : "idle",
|
|
||||||
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
|
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -802,21 +809,33 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }])
|
write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.settled") {
|
if (event.type === "session.execution.started") {
|
||||||
|
write([], { phase: "running" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.type === "session.execution.succeeded" ||
|
||||||
|
event.type === "session.execution.failed" ||
|
||||||
|
event.type === "session.execution.interrupted"
|
||||||
|
) {
|
||||||
write([], { phase: "idle", status: "" })
|
write([], { phase: "idle", status: "" })
|
||||||
const current = state.wait
|
const current = state.wait
|
||||||
if (!current || (!current.promoted && !current.interrupted)) return
|
if (!current || (!current.promoted && !current.interrupted)) return
|
||||||
state.wait = undefined
|
state.wait = undefined
|
||||||
if (current.interrupted) {
|
if (current.interrupted && event.type === "session.execution.interrupted" && event.data.reason === "user") {
|
||||||
current.resolve()
|
current.resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.data.outcome === "failure") {
|
if (event.type === "session.execution.failed") {
|
||||||
if (current.failureRendered) {
|
if (current.failureRendered) {
|
||||||
current.resolve()
|
current.resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
current.reject(new Error(event.data.error ? errorMessage(event.data.error) : "Session execution failed"))
|
current.reject(new Error(errorMessage(event.data.error)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "session.execution.interrupted") {
|
||||||
|
current.reject(new Error(`Session interrupted: ${event.data.reason}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
current.resolve()
|
current.resolve()
|
||||||
|
|
@ -1014,18 +1033,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.agent) {
|
if (next.agent) {
|
||||||
await input.sdk.session.switchAgent(
|
await input.sdk.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
||||||
{ sessionID: input.sessionID, agent: next.agent },
|
|
||||||
{ signal: next.signal },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const selected = await resolveSelectedModel(input, next)
|
const selected = await resolveSelectedModel(input, next)
|
||||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||||
if (selected)
|
if (selected)
|
||||||
await input.sdk.session.switchModel(
|
await input.sdk.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||||
{ sessionID: input.sessionID, model: selected },
|
|
||||||
{ signal: next.signal },
|
|
||||||
)
|
|
||||||
|
|
||||||
const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile))
|
const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile))
|
||||||
const attachments = [
|
const attachments = [
|
||||||
|
|
|
||||||
|
|
@ -1006,23 +1006,20 @@ export type SessionContextOutput = {
|
||||||
readonly agent: string
|
readonly agent: string
|
||||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||||
readonly content: ReadonlyArray<
|
readonly content: ReadonlyArray<
|
||||||
| { readonly type: "text"; readonly id: string; readonly text: string }
|
| { readonly type: "text"; readonly text: string }
|
||||||
| {
|
| {
|
||||||
readonly type: "reasoning"
|
readonly type: "reasoning"
|
||||||
readonly id: string
|
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
readonly state?: { readonly [x: string]: JsonValue }
|
||||||
readonly time?: { readonly created: number; readonly completed?: number }
|
readonly time?: { readonly created: number; readonly completed?: number }
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: "tool"
|
readonly type: "tool"
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly provider?: {
|
readonly executed?: boolean
|
||||||
readonly executed: boolean
|
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
readonly providerResultState?: { readonly [x: string]: JsonValue }
|
||||||
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
|
||||||
}
|
|
||||||
readonly state:
|
readonly state:
|
||||||
| { readonly status: "pending"; readonly input: string }
|
| { readonly status: "pending"; readonly input: string }
|
||||||
| {
|
| {
|
||||||
|
|
@ -1061,7 +1058,7 @@ export type SessionContextOutput = {
|
||||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||||
>
|
>
|
||||||
readonly structured: { readonly [x: string]: JsonValue }
|
readonly structured: { readonly [x: string]: JsonValue }
|
||||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
readonly result?: JsonValue
|
readonly result?: JsonValue
|
||||||
}
|
}
|
||||||
readonly time: {
|
readonly time: {
|
||||||
|
|
@ -1073,7 +1070,7 @@ export type SessionContextOutput = {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||||
readonly finish?: string
|
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||||
readonly cost?: number
|
readonly cost?: number
|
||||||
readonly tokens?: {
|
readonly tokens?: {
|
||||||
readonly input: number
|
readonly input: number
|
||||||
|
|
@ -1081,7 +1078,12 @@ export type SessionContextOutput = {
|
||||||
readonly reasoning: number
|
readonly reasoning: number
|
||||||
readonly cache: { readonly read: number; readonly write: number }
|
readonly cache: { readonly read: number; readonly write: number }
|
||||||
}
|
}
|
||||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
readonly error?: { readonly type: string; readonly message: string }
|
||||||
|
readonly retry?: {
|
||||||
|
readonly attempt: number
|
||||||
|
readonly at: number
|
||||||
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: "compaction"
|
readonly type: "compaction"
|
||||||
|
|
@ -1213,6 +1215,45 @@ export type SessionLogOutput =
|
||||||
readonly delivery: "steer" | "queue"
|
readonly delivery: "steer" | "queue"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly created: number
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
|
readonly type: "session.execution.started"
|
||||||
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: { readonly sessionID: string }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly created: number
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
|
readonly type: "session.execution.succeeded"
|
||||||
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: { readonly sessionID: string }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly created: number
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
|
readonly type: "session.execution.failed"
|
||||||
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly created: number
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
|
readonly type: "session.execution.interrupted"
|
||||||
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" }
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly created: number
|
readonly created: number
|
||||||
|
|
@ -1322,7 +1363,7 @@ export type SessionLogOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly finish: string
|
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||||
readonly cost: number
|
readonly cost: number
|
||||||
readonly tokens: {
|
readonly tokens: {
|
||||||
readonly input: number
|
readonly input: number
|
||||||
|
|
@ -1344,7 +1385,7 @@ export type SessionLogOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -1354,7 +1395,7 @@ export type SessionLogOutput =
|
||||||
readonly type: "session.text.started"
|
readonly type: "session.text.started"
|
||||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
|
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number }
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
|
|
@ -1366,7 +1407,7 @@ export type SessionLogOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly textID: string
|
readonly ordinal: number
|
||||||
readonly text: string
|
readonly text: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1380,8 +1421,8 @@ export type SessionLogOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly reasoningID: string
|
readonly ordinal: number
|
||||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
readonly state?: { readonly [x: string]: unknown }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -1394,9 +1435,9 @@ export type SessionLogOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly reasoningID: string
|
readonly ordinal: number
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
readonly state?: { readonly [x: string]: unknown }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -1438,12 +1479,9 @@ export type SessionLogOutput =
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly callID: string
|
readonly callID: string
|
||||||
readonly tool: string
|
|
||||||
readonly input: { readonly [x: string]: unknown }
|
readonly input: { readonly [x: string]: unknown }
|
||||||
readonly provider: {
|
readonly executed: boolean
|
||||||
readonly executed: boolean
|
readonly state?: { readonly [x: string]: unknown }
|
||||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -1482,10 +1520,8 @@ export type SessionLogOutput =
|
||||||
>
|
>
|
||||||
readonly outputPaths?: ReadonlyArray<string>
|
readonly outputPaths?: ReadonlyArray<string>
|
||||||
readonly result?: unknown
|
readonly result?: unknown
|
||||||
readonly provider: {
|
readonly executed: boolean
|
||||||
readonly executed: boolean
|
readonly resultState?: { readonly [x: string]: unknown }
|
||||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -1499,32 +1535,25 @@ export type SessionLogOutput =
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly callID: string
|
readonly callID: string
|
||||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
readonly result?: unknown
|
readonly result?: unknown
|
||||||
readonly provider: {
|
readonly executed: boolean
|
||||||
readonly executed: boolean
|
readonly resultState?: { readonly [x: string]: unknown }
|
||||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly created: number
|
readonly created: number
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
readonly type: "session.retried"
|
readonly type: "session.retry.scheduled"
|
||||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
readonly attempt: number
|
readonly attempt: number
|
||||||
readonly error: {
|
readonly at: number
|
||||||
readonly message: string
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
readonly statusCode?: number
|
|
||||||
readonly isRetryable: boolean
|
|
||||||
readonly responseHeaders?: { readonly [x: string]: string }
|
|
||||||
readonly responseBody?: string
|
|
||||||
readonly metadata?: { readonly [x: string]: string }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -1590,7 +1619,7 @@ export type SessionLogOutput =
|
||||||
readonly type: "session.revert.committed"
|
readonly type: "session.revert.committed"
|
||||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
readonly data: { readonly sessionID: string; readonly messageID: string }
|
readonly data: { readonly sessionID: string; readonly to: string }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
| { readonly type: "log.synced"; readonly aggregateID: string; readonly seq?: number }
|
| { readonly type: "log.synced"; readonly aggregateID: string; readonly seq?: number }
|
||||||
|
|
@ -1703,23 +1732,20 @@ export type SessionMessageOutput = {
|
||||||
readonly agent: string
|
readonly agent: string
|
||||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||||
readonly content: ReadonlyArray<
|
readonly content: ReadonlyArray<
|
||||||
| { readonly type: "text"; readonly id: string; readonly text: string }
|
| { readonly type: "text"; readonly text: string }
|
||||||
| {
|
| {
|
||||||
readonly type: "reasoning"
|
readonly type: "reasoning"
|
||||||
readonly id: string
|
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
readonly state?: { readonly [x: string]: JsonValue }
|
||||||
readonly time?: { readonly created: number; readonly completed?: number }
|
readonly time?: { readonly created: number; readonly completed?: number }
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: "tool"
|
readonly type: "tool"
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly provider?: {
|
readonly executed?: boolean
|
||||||
readonly executed: boolean
|
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
readonly providerResultState?: { readonly [x: string]: JsonValue }
|
||||||
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
|
||||||
}
|
|
||||||
readonly state:
|
readonly state:
|
||||||
| { readonly status: "pending"; readonly input: string }
|
| { readonly status: "pending"; readonly input: string }
|
||||||
| {
|
| {
|
||||||
|
|
@ -1758,7 +1784,7 @@ export type SessionMessageOutput = {
|
||||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||||
>
|
>
|
||||||
readonly structured: { readonly [x: string]: JsonValue }
|
readonly structured: { readonly [x: string]: JsonValue }
|
||||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
readonly result?: JsonValue
|
readonly result?: JsonValue
|
||||||
}
|
}
|
||||||
readonly time: {
|
readonly time: {
|
||||||
|
|
@ -1770,7 +1796,7 @@ export type SessionMessageOutput = {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||||
readonly finish?: string
|
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||||
readonly cost?: number
|
readonly cost?: number
|
||||||
readonly tokens?: {
|
readonly tokens?: {
|
||||||
readonly input: number
|
readonly input: number
|
||||||
|
|
@ -1778,7 +1804,12 @@ export type SessionMessageOutput = {
|
||||||
readonly reasoning: number
|
readonly reasoning: number
|
||||||
readonly cache: { readonly read: number; readonly write: number }
|
readonly cache: { readonly read: number; readonly write: number }
|
||||||
}
|
}
|
||||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
readonly error?: { readonly type: string; readonly message: string }
|
||||||
|
readonly retry?: {
|
||||||
|
readonly attempt: number
|
||||||
|
readonly at: number
|
||||||
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: "compaction"
|
readonly type: "compaction"
|
||||||
|
|
@ -1905,23 +1936,20 @@ export type MessageListOutput = {
|
||||||
readonly agent: string
|
readonly agent: string
|
||||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||||
readonly content: ReadonlyArray<
|
readonly content: ReadonlyArray<
|
||||||
| { readonly type: "text"; readonly id: string; readonly text: string }
|
| { readonly type: "text"; readonly text: string }
|
||||||
| {
|
| {
|
||||||
readonly type: "reasoning"
|
readonly type: "reasoning"
|
||||||
readonly id: string
|
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
readonly state?: { readonly [x: string]: JsonValue }
|
||||||
readonly time?: { readonly created: number; readonly completed?: number }
|
readonly time?: { readonly created: number; readonly completed?: number }
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: "tool"
|
readonly type: "tool"
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly provider?: {
|
readonly executed?: boolean
|
||||||
readonly executed: boolean
|
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
readonly providerResultState?: { readonly [x: string]: JsonValue }
|
||||||
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
|
||||||
}
|
|
||||||
readonly state:
|
readonly state:
|
||||||
| { readonly status: "pending"; readonly input: string }
|
| { readonly status: "pending"; readonly input: string }
|
||||||
| {
|
| {
|
||||||
|
|
@ -1960,7 +1988,7 @@ export type MessageListOutput = {
|
||||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||||
>
|
>
|
||||||
readonly structured: { readonly [x: string]: JsonValue }
|
readonly structured: { readonly [x: string]: JsonValue }
|
||||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
readonly result?: JsonValue
|
readonly result?: JsonValue
|
||||||
}
|
}
|
||||||
readonly time: {
|
readonly time: {
|
||||||
|
|
@ -1972,7 +2000,7 @@ export type MessageListOutput = {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||||
readonly finish?: string
|
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||||
readonly cost?: number
|
readonly cost?: number
|
||||||
readonly tokens?: {
|
readonly tokens?: {
|
||||||
readonly input: number
|
readonly input: number
|
||||||
|
|
@ -1980,7 +2008,12 @@ export type MessageListOutput = {
|
||||||
readonly reasoning: number
|
readonly reasoning: number
|
||||||
readonly cache: { readonly read: number; readonly write: number }
|
readonly cache: { readonly read: number; readonly write: number }
|
||||||
}
|
}
|
||||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
readonly error?: { readonly type: string; readonly message: string }
|
||||||
|
readonly retry?: {
|
||||||
|
readonly attempt: number
|
||||||
|
readonly at: number
|
||||||
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: "compaction"
|
readonly type: "compaction"
|
||||||
|
|
@ -4456,13 +4489,37 @@ export type EventSubscribeOutput =
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly created: number
|
readonly created: number
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
readonly type: "session.execution.settled"
|
readonly type: "session.execution.started"
|
||||||
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
readonly data: {
|
readonly data: { readonly sessionID: string }
|
||||||
readonly sessionID: string
|
}
|
||||||
readonly outcome: "success" | "failure" | "interrupted"
|
| {
|
||||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
readonly id: string
|
||||||
}
|
readonly created: number
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
|
readonly type: "session.execution.succeeded"
|
||||||
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: { readonly sessionID: string }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly created: number
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
|
readonly type: "session.execution.failed"
|
||||||
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: { readonly sessionID: string; readonly error: { readonly type: string; readonly message: string } }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly created: number
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
|
readonly type: "session.execution.interrupted"
|
||||||
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" }
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
|
|
@ -4573,7 +4630,7 @@ export type EventSubscribeOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly finish: string
|
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||||
readonly cost: number
|
readonly cost: number
|
||||||
readonly tokens: {
|
readonly tokens: {
|
||||||
readonly input: number
|
readonly input: number
|
||||||
|
|
@ -4595,7 +4652,7 @@ export type EventSubscribeOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -4605,7 +4662,7 @@ export type EventSubscribeOutput =
|
||||||
readonly type: "session.text.started"
|
readonly type: "session.text.started"
|
||||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
|
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number }
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
|
|
@ -4616,7 +4673,7 @@ export type EventSubscribeOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly textID: string
|
readonly ordinal: number
|
||||||
readonly delta: string
|
readonly delta: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4630,7 +4687,7 @@ export type EventSubscribeOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly textID: string
|
readonly ordinal: number
|
||||||
readonly text: string
|
readonly text: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4644,8 +4701,8 @@ export type EventSubscribeOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly reasoningID: string
|
readonly ordinal: number
|
||||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
readonly state?: { readonly [x: string]: unknown }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -4657,7 +4714,7 @@ export type EventSubscribeOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly reasoningID: string
|
readonly ordinal: number
|
||||||
readonly delta: string
|
readonly delta: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4671,9 +4728,9 @@ export type EventSubscribeOutput =
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly reasoningID: string
|
readonly ordinal: number
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
readonly state?: { readonly [x: string]: unknown }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -4728,12 +4785,9 @@ export type EventSubscribeOutput =
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly callID: string
|
readonly callID: string
|
||||||
readonly tool: string
|
|
||||||
readonly input: { readonly [x: string]: unknown }
|
readonly input: { readonly [x: string]: unknown }
|
||||||
readonly provider: {
|
readonly executed: boolean
|
||||||
readonly executed: boolean
|
readonly state?: { readonly [x: string]: unknown }
|
||||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -4772,10 +4826,8 @@ export type EventSubscribeOutput =
|
||||||
>
|
>
|
||||||
readonly outputPaths?: ReadonlyArray<string>
|
readonly outputPaths?: ReadonlyArray<string>
|
||||||
readonly result?: unknown
|
readonly result?: unknown
|
||||||
readonly provider: {
|
readonly executed: boolean
|
||||||
readonly executed: boolean
|
readonly resultState?: { readonly [x: string]: unknown }
|
||||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -4789,32 +4841,25 @@ export type EventSubscribeOutput =
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly assistantMessageID: string
|
readonly assistantMessageID: string
|
||||||
readonly callID: string
|
readonly callID: string
|
||||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
readonly result?: unknown
|
readonly result?: unknown
|
||||||
readonly provider: {
|
readonly executed: boolean
|
||||||
readonly executed: boolean
|
readonly resultState?: { readonly [x: string]: unknown }
|
||||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly created: number
|
readonly created: number
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
readonly type: "session.retried"
|
readonly type: "session.retry.scheduled"
|
||||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
readonly attempt: number
|
readonly attempt: number
|
||||||
readonly error: {
|
readonly at: number
|
||||||
readonly message: string
|
readonly error: { readonly type: string; readonly message: string }
|
||||||
readonly statusCode?: number
|
|
||||||
readonly isRetryable: boolean
|
|
||||||
readonly responseHeaders?: { readonly [x: string]: string }
|
|
||||||
readonly responseBody?: string
|
|
||||||
readonly metadata?: { readonly [x: string]: string }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -4888,7 +4933,7 @@ export type EventSubscribeOutput =
|
||||||
readonly type: "session.revert.committed"
|
readonly type: "session.revert.committed"
|
||||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
readonly data: { readonly sessionID: string; readonly messageID: string }
|
readonly data: { readonly sessionID: string; readonly to: string }
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
|
|
|
||||||
|
|
@ -155,12 +155,10 @@ current omissions to implement, not intentional product boundaries.
|
||||||
collection values, then extend it to bounded host streams when a stream boundary exists.
|
collection values, then extend it to bounded host streams when a stream boundary exists.
|
||||||
- [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to
|
- [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to
|
||||||
`Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed.
|
`Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed.
|
||||||
- [ ] Close basic `Object` parity gaps: let `Object.values`/`Object.entries` accept arrays, make `Object.assign` validate
|
- [ ] Add `Object.is` after runtime method and tool references have stable identity semantics.
|
||||||
and mutate its target, add `Object.is`, and let `Object.fromEntries` consume every supported iterable.
|
|
||||||
- [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set
|
- [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set
|
||||||
composition methods, and `Array.prototype.toSpliced`.
|
composition methods, and `Array.prototype.toSpliced`.
|
||||||
- [ ] Complete the deterministic `Math` surface beyond the current arithmetic, rounding, root, power, and logarithm
|
- [ ] Decide whether nondeterministic `Math.random` and iterable `Math.sumPrecise` belong in the runtime.
|
||||||
helpers. Decide separately whether nondeterministic `Math.random` belongs in the runtime.
|
|
||||||
- [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter
|
- [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter
|
||||||
defects are distinguishable without leaking private causes.
|
defects are distinguishable without leaking private causes.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export class CodeModeFunction {
|
||||||
readonly parameters: ReadonlyArray<AstNode>,
|
readonly parameters: ReadonlyArray<AstNode>,
|
||||||
readonly body: AstNode,
|
readonly body: AstNode,
|
||||||
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
|
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
|
||||||
|
readonly async: boolean,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,7 +154,8 @@ export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRunti
|
||||||
[supportedSyntaxMessage],
|
[supportedSyntaxMessage],
|
||||||
)
|
)
|
||||||
|
|
||||||
export const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null
|
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null
|
||||||
|
|
||||||
export const asNode = (value: unknown, context: string): AstNode => {
|
export const asNode = (value: unknown, context: string): AstNode => {
|
||||||
if (!isRecord(value) || typeof value.type !== "string") {
|
if (!isRecord(value) || typeof value.type !== "string") {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ import {
|
||||||
numberMethods,
|
numberMethods,
|
||||||
numberStatics,
|
numberStatics,
|
||||||
} from "../stdlib/number.js"
|
} from "../stdlib/number.js"
|
||||||
import { invokeObjectMethod } from "../stdlib/object.js"
|
import { invokeObjectMethod, objectMethodsPreservingIdentity } from "../stdlib/object.js"
|
||||||
import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js"
|
import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js"
|
||||||
import {
|
import {
|
||||||
escapeRegexHint,
|
escapeRegexHint,
|
||||||
|
|
@ -612,12 +612,13 @@ class Interpreter<R> {
|
||||||
// Fiber-backed promises whose settlement no program construct has observed yet. Successful
|
// Fiber-backed promises whose settlement no program construct has observed yet. Successful
|
||||||
// program completion drains these (like a runtime waiting on in-flight work at exit) and
|
// program completion drains these (like a runtime waiting on in-flight work at exit) and
|
||||||
// surfaces a never-awaited failure as an unhandled-rejection diagnostic.
|
// surfaces a never-awaited failure as an unhandled-rejection diagnostic.
|
||||||
private readonly pendingSettlements = new Set<SandboxPromise>()
|
private readonly pendingSettlements: Set<SandboxPromise>
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||||
logs: Array<string> = [],
|
logs: Array<string> = [],
|
||||||
|
shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set<SandboxPromise> },
|
||||||
) {
|
) {
|
||||||
const globalScope = new Map<string, Binding>()
|
const globalScope = new Map<string, Binding>()
|
||||||
this.scopes = [globalScope]
|
this.scopes = [globalScope]
|
||||||
|
|
@ -625,7 +626,8 @@ class Interpreter<R> {
|
||||||
this.toolKeys = toolKeys
|
this.toolKeys = toolKeys
|
||||||
this.logs = logs
|
this.logs = logs
|
||||||
this.lastValue = undefined
|
this.lastValue = undefined
|
||||||
this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
|
this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
|
||||||
|
this.pendingSettlements = shared?.pendingSettlements ?? new Set<SandboxPromise>()
|
||||||
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
||||||
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
||||||
globalScope.set("undefined", { mutable: false, value: undefined })
|
globalScope.set("undefined", { mutable: false, value: undefined })
|
||||||
|
|
@ -705,15 +707,17 @@ class Interpreter<R> {
|
||||||
private drainPendingSettlements(): Effect.Effect<void, unknown, never> {
|
private drainPendingSettlements(): Effect.Effect<void, unknown, never> {
|
||||||
const self = this
|
const self = this
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
for (const promise of [...self.pendingSettlements]) {
|
while (self.pendingSettlements.size > 0) {
|
||||||
|
const promise = self.pendingSettlements.values().next().value
|
||||||
|
if (promise === undefined) break
|
||||||
const exit = yield* self.observePromise(promise)
|
const exit = yield* self.observePromise(promise)
|
||||||
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue
|
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue
|
||||||
const failure = normalizeError(Cause.squash(exit.cause))
|
const failure = normalizeError(Cause.squash(exit.cause))
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError(
|
||||||
`Unhandled rejection from an un-awaited tool call: ${failure.message}`,
|
`Unhandled rejection from an un-awaited promise: ${failure.message}`,
|
||||||
undefined,
|
undefined,
|
||||||
failure.kind,
|
failure.kind,
|
||||||
["Await tool calls - `const result = await tools.ns.tool(...)` - so failures can be caught and handled."],
|
["Await promises so failures can be caught and handled."],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -727,17 +731,15 @@ class Interpreter<R> {
|
||||||
path: ReadonlyArray<string>,
|
path: ReadonlyArray<string>,
|
||||||
args: Array<unknown>,
|
args: Array<unknown>,
|
||||||
): Effect.Effect<SandboxPromise, never, R> {
|
): Effect.Effect<SandboxPromise, never, R> {
|
||||||
const self = this
|
return this.createPromise(this.callPermits.withPermit(Effect.suspend(() => this.invokeTool(path, args))))
|
||||||
return Effect.map(
|
}
|
||||||
Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), {
|
|
||||||
startImmediately: true,
|
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||||
}),
|
return Effect.map(Effect.forkChild(effect, { startImmediately: true }), (fiber) => {
|
||||||
(fiber) => {
|
const promise = new SandboxPromise(fiber)
|
||||||
const promise = new SandboxPromise(fiber)
|
this.pendingSettlements.add(promise)
|
||||||
self.pendingSettlements.add(promise)
|
return promise
|
||||||
return promise
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking.
|
// The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking.
|
||||||
|
|
@ -858,6 +860,7 @@ class Interpreter<R> {
|
||||||
getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)),
|
getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)),
|
||||||
getNode(node, "body"),
|
getNode(node, "body"),
|
||||||
this.scopes.slice(),
|
this.scopes.slice(),
|
||||||
|
node.async === true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1085,7 +1088,7 @@ class Interpreter<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
|
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
|
||||||
let assignmentName: string | undefined
|
let assignment: AstNode | undefined
|
||||||
|
|
||||||
if (left.type === "VariableDeclaration") {
|
if (left.type === "VariableDeclaration") {
|
||||||
const declarations = getArray(left, "declarations")
|
const declarations = getArray(left, "declarations")
|
||||||
|
|
@ -1095,8 +1098,13 @@ class Interpreter<R> {
|
||||||
|
|
||||||
const declarator = asNode(declarations[0], "declarations[0]")
|
const declarator = asNode(declarations[0], "declarations[0]")
|
||||||
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
|
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
|
||||||
} else if (left.type === "Identifier") {
|
} else if (
|
||||||
assignmentName = getString(left, "name")
|
left.type === "Identifier" ||
|
||||||
|
left.type === "MemberExpression" ||
|
||||||
|
left.type === "ArrayPattern" ||
|
||||||
|
left.type === "ObjectPattern"
|
||||||
|
) {
|
||||||
|
assignment = left
|
||||||
} else {
|
} else {
|
||||||
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
|
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
|
||||||
}
|
}
|
||||||
|
|
@ -1105,8 +1113,8 @@ class Interpreter<R> {
|
||||||
if (declaration) {
|
if (declaration) {
|
||||||
self.pushScope()
|
self.pushScope()
|
||||||
yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left)
|
yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left)
|
||||||
} else if (assignmentName) {
|
} else if (assignment) {
|
||||||
self.setIdentifierValue(assignmentName, value, left)
|
yield* self.assignPattern(assignment, value, left)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = yield* self.evaluateStatement(body).pipe(
|
const result = yield* self.evaluateStatement(body).pipe(
|
||||||
|
|
@ -1504,6 +1512,16 @@ class Interpreter<R> {
|
||||||
return this.evaluateUnaryExpression(node)
|
return this.evaluateUnaryExpression(node)
|
||||||
case "AssignmentExpression":
|
case "AssignmentExpression":
|
||||||
return this.evaluateAssignmentExpression(node)
|
return this.evaluateAssignmentExpression(node)
|
||||||
|
case "SequenceExpression": {
|
||||||
|
const self = this
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
let result: unknown
|
||||||
|
for (const expression of getArray(node, "expressions")) {
|
||||||
|
result = yield* self.evaluateExpression(asNode(expression, "expressions"))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
case "CallExpression":
|
case "CallExpression":
|
||||||
return this.evaluateCallExpression(node)
|
return this.evaluateCallExpression(node)
|
||||||
case "ArrowFunctionExpression":
|
case "ArrowFunctionExpression":
|
||||||
|
|
@ -2010,9 +2028,9 @@ class Interpreter<R> {
|
||||||
if (callable instanceof GlobalMethodReference) {
|
if (callable instanceof GlobalMethodReference) {
|
||||||
if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node)
|
if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node)
|
||||||
if (callable.namespace === "Object" && args[0] instanceof ToolReference) {
|
if (callable.namespace === "Object" && args[0] instanceof ToolReference) {
|
||||||
return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node)
|
return self.invokeObjectMethodOnTools(callable.name, args[0], node)
|
||||||
}
|
}
|
||||||
if (callable.namespace === "Object" && callable.name === "assign") {
|
if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) {
|
||||||
return invokeGlobalMethod(callable, args, node)
|
return invokeGlobalMethod(callable, args, node)
|
||||||
}
|
}
|
||||||
return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
|
return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
|
||||||
|
|
@ -2033,8 +2051,8 @@ class Interpreter<R> {
|
||||||
|
|
||||||
// Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate
|
// Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate
|
||||||
// namespace/tool names from the host tool tree - the discovery idiom a model reaches for
|
// namespace/tool names from the host tool tree - the discovery idiom a model reaches for
|
||||||
// first. Every other Object helper cannot produce data from a tool reference, so it fails
|
// first. Other Object helpers fail with a pointer at the working idioms instead of a generic
|
||||||
// with a pointer at the working idioms instead of the generic plain-objects-only message.
|
// plain-data message.
|
||||||
private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown {
|
private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown {
|
||||||
if (name === "keys") {
|
if (name === "keys") {
|
||||||
return boundedData(this.enumerableKeys(ref)!, "Object.keys result")
|
return boundedData(this.enumerableKeys(ref)!, "Object.keys result")
|
||||||
|
|
@ -2226,14 +2244,36 @@ class Interpreter<R> {
|
||||||
switch (ref.name) {
|
switch (ref.name) {
|
||||||
case "all": {
|
case "all": {
|
||||||
// Mark every promise element observed up-front (Promise.all handles all of its
|
// Mark every promise element observed up-front (Promise.all handles all of its
|
||||||
// members' failures, as in JS), then join in index order; the first failure rejects
|
// members' failures, as in JS), race their settlements for fail-fast rejection, and
|
||||||
// the whole call while unrelated in-flight members keep running.
|
// preserve input order when they all fulfill. Rejected calls keep draining siblings.
|
||||||
const settles = items.map((item) =>
|
const observations = items.map((item, index) =>
|
||||||
item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item),
|
item instanceof SandboxPromise
|
||||||
|
? Effect.map(this.observePromise(item), (exit) => ({ index, item, exit }))
|
||||||
|
: Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }),
|
||||||
)
|
)
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
const remaining = [...observations]
|
||||||
const values: Array<unknown> = []
|
const values: Array<unknown> = []
|
||||||
for (const settle of settles) values.push(yield* settle)
|
values.length = items.length
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
const winner = yield* Effect.raceAll(remaining)
|
||||||
|
const position = remaining.indexOf(observations[winner.index])
|
||||||
|
if (position >= 0) remaining.splice(position, 1)
|
||||||
|
if (Exit.isSuccess(winner.exit)) {
|
||||||
|
values[winner.index] = winner.exit.value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
yield* self.createPromise(
|
||||||
|
Effect.asVoid(
|
||||||
|
Effect.forEach(
|
||||||
|
items,
|
||||||
|
(item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return yield* self.unwrapPromiseExit(winner.item, winner.exit, node)
|
||||||
|
}
|
||||||
return values
|
return values
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -2307,43 +2347,42 @@ class Interpreter<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||||
const self = this
|
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.logs, {
|
||||||
return Effect.suspend(() => {
|
callPermits: this.callPermits,
|
||||||
const savedScopes = self.scopes
|
pendingSettlements: this.pendingSettlements,
|
||||||
self.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
|
|
||||||
const run = Effect.gen(function* () {
|
|
||||||
// Seed every parameter name into the scope as a TDZ slot first, so a default that
|
|
||||||
// references another parameter resolves to that (uninitialized) param rather than
|
|
||||||
// silently falling through to an outer binding of the same name - matching JS.
|
|
||||||
const paramScope = self.currentScope()
|
|
||||||
for (const parameter of fn.parameters) {
|
|
||||||
for (const name of collectPatternNames(parameter)) {
|
|
||||||
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [index, parameter] of fn.parameters.entries()) {
|
|
||||||
if (parameter.type === "RestElement") {
|
|
||||||
yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
yield* self.declarePattern(parameter, args[index], true, parameter)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fn.body.type === "BlockStatement") {
|
|
||||||
const result = yield* self.evaluateStatement(fn.body)
|
|
||||||
return result.kind === "return" || result.kind === "value" ? result.value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return yield* self.evaluateExpression(fn.body)
|
|
||||||
})
|
|
||||||
return run.pipe(
|
|
||||||
Effect.ensuring(
|
|
||||||
Effect.sync(() => {
|
|
||||||
self.scopes = savedScopes
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
invocation.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
|
||||||
|
const run = Effect.gen(function* () {
|
||||||
|
// Seed every parameter name into the scope as a TDZ slot first, so a default that
|
||||||
|
// references another parameter resolves to that (uninitialized) param rather than
|
||||||
|
// silently falling through to an outer binding of the same name - matching JS.
|
||||||
|
const paramScope = invocation.currentScope()
|
||||||
|
for (const parameter of fn.parameters) {
|
||||||
|
for (const name of collectPatternNames(parameter)) {
|
||||||
|
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [index, parameter] of fn.parameters.entries()) {
|
||||||
|
if (parameter.type === "RestElement") {
|
||||||
|
yield* invocation.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
yield* invocation.declarePattern(parameter, args[index], true, parameter)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fn.body.type === "BlockStatement") {
|
||||||
|
const result = yield* invocation.evaluateStatement(fn.body)
|
||||||
|
return result.kind === "return" || result.kind === "value" ? result.value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return yield* invocation.evaluateExpression(fn.body)
|
||||||
|
})
|
||||||
|
if (!fn.async) return run
|
||||||
|
return this.createPromise(
|
||||||
|
Effect.flatMap(run, (value) =>
|
||||||
|
value instanceof SandboxPromise ? invocation.settlePromise(value) : Effect.succeed(value),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private invokeIntrinsic(
|
private invokeIntrinsic(
|
||||||
|
|
@ -2432,13 +2471,19 @@ class Interpreter<R> {
|
||||||
else value.replaceAll(pattern, collect)
|
else value.replaceAll(pattern, collect)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const self = this
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const output: Array<string> = []
|
const output: Array<string> = []
|
||||||
let end = 0
|
let end = 0
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
|
const replacement = yield* apply(match.args)
|
||||||
|
const resolved =
|
||||||
|
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise
|
||||||
|
? yield* self.settlePromise(replacement)
|
||||||
|
: replacement
|
||||||
output.push(
|
output.push(
|
||||||
value.slice(end, match.offset),
|
value.slice(end, match.offset),
|
||||||
coerceToString(boundedData(yield* apply(match.args), `String.${name} replacer result`)),
|
coerceToString(boundedData(resolved, `String.${name} replacer result`)),
|
||||||
)
|
)
|
||||||
end = match.offset + match.match.length
|
end = match.offset + match.match.length
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@ export const mathMethods = new Set([
|
||||||
"max",
|
"max",
|
||||||
"min",
|
"min",
|
||||||
"abs",
|
"abs",
|
||||||
|
"acos",
|
||||||
|
"acosh",
|
||||||
|
"asin",
|
||||||
|
"asinh",
|
||||||
|
"atan",
|
||||||
|
"atan2",
|
||||||
|
"atanh",
|
||||||
"floor",
|
"floor",
|
||||||
"ceil",
|
"ceil",
|
||||||
"round",
|
"round",
|
||||||
|
|
@ -13,10 +20,22 @@ export const mathMethods = new Set([
|
||||||
"cbrt",
|
"cbrt",
|
||||||
"pow",
|
"pow",
|
||||||
"hypot",
|
"hypot",
|
||||||
|
"cos",
|
||||||
|
"cosh",
|
||||||
|
"sin",
|
||||||
|
"sinh",
|
||||||
|
"tan",
|
||||||
|
"tanh",
|
||||||
"log",
|
"log",
|
||||||
"log2",
|
"log2",
|
||||||
"log10",
|
"log10",
|
||||||
|
"log1p",
|
||||||
"exp",
|
"exp",
|
||||||
|
"expm1",
|
||||||
|
"f16round",
|
||||||
|
"fround",
|
||||||
|
"clz32",
|
||||||
|
"imul",
|
||||||
])
|
])
|
||||||
|
|
||||||
export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
|
export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||||
|
|
@ -33,6 +52,20 @@ export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNo
|
||||||
return Math.min(...nums)
|
return Math.min(...nums)
|
||||||
case "abs":
|
case "abs":
|
||||||
return Math.abs(a)
|
return Math.abs(a)
|
||||||
|
case "acos":
|
||||||
|
return Math.acos(a)
|
||||||
|
case "acosh":
|
||||||
|
return Math.acosh(a)
|
||||||
|
case "asin":
|
||||||
|
return Math.asin(a)
|
||||||
|
case "asinh":
|
||||||
|
return Math.asinh(a)
|
||||||
|
case "atan":
|
||||||
|
return Math.atan(a)
|
||||||
|
case "atan2":
|
||||||
|
return Math.atan2(a, b)
|
||||||
|
case "atanh":
|
||||||
|
return Math.atanh(a)
|
||||||
case "floor":
|
case "floor":
|
||||||
return Math.floor(a)
|
return Math.floor(a)
|
||||||
case "ceil":
|
case "ceil":
|
||||||
|
|
@ -51,14 +84,38 @@ export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNo
|
||||||
return Math.pow(a, b)
|
return Math.pow(a, b)
|
||||||
case "hypot":
|
case "hypot":
|
||||||
return Math.hypot(...nums)
|
return Math.hypot(...nums)
|
||||||
|
case "cos":
|
||||||
|
return Math.cos(a)
|
||||||
|
case "cosh":
|
||||||
|
return Math.cosh(a)
|
||||||
|
case "sin":
|
||||||
|
return Math.sin(a)
|
||||||
|
case "sinh":
|
||||||
|
return Math.sinh(a)
|
||||||
|
case "tan":
|
||||||
|
return Math.tan(a)
|
||||||
|
case "tanh":
|
||||||
|
return Math.tanh(a)
|
||||||
case "log":
|
case "log":
|
||||||
return Math.log(a)
|
return Math.log(a)
|
||||||
case "log2":
|
case "log2":
|
||||||
return Math.log2(a)
|
return Math.log2(a)
|
||||||
case "log10":
|
case "log10":
|
||||||
return Math.log10(a)
|
return Math.log10(a)
|
||||||
|
case "log1p":
|
||||||
|
return Math.log1p(a)
|
||||||
case "exp":
|
case "exp":
|
||||||
return Math.exp(a)
|
return Math.exp(a)
|
||||||
|
case "expm1":
|
||||||
|
return Math.expm1(a)
|
||||||
|
case "f16round":
|
||||||
|
return Math.f16round(a)
|
||||||
|
case "fround":
|
||||||
|
return Math.fround(a)
|
||||||
|
case "clz32":
|
||||||
|
return Math.clz32(a)
|
||||||
|
case "imul":
|
||||||
|
return Math.imul(a, b)
|
||||||
}
|
}
|
||||||
throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
|
throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||||
import { isBlockedMember } from "../tool-runtime.js"
|
import { isBlockedMember } from "../tool-runtime.js"
|
||||||
import { isSandboxValue, SandboxMap, SandboxURLSearchParams } from "../values.js"
|
import { isSandboxValue, SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js"
|
||||||
import { boundedData, coerceToString } from "./value.js"
|
import { boundedData, coerceToString } from "./value.js"
|
||||||
|
|
||||||
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"])
|
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"])
|
||||||
|
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
||||||
|
|
||||||
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||||
if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
|
if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
|
||||||
const requireObject = (): Record<string, unknown> => {
|
const requireObject = (): Record<string, unknown> => {
|
||||||
|
const input = args[0]
|
||||||
const value = boundedData(args[0], `Object.${name} input`)
|
const value = boundedData(args[0], `Object.${name} input`)
|
||||||
|
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
|
||||||
if (isSandboxValue(value)) return {}
|
if (isSandboxValue(value)) return {}
|
||||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
if (value === null || typeof value !== "object") {
|
||||||
throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node)
|
throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node)
|
||||||
}
|
}
|
||||||
return value as Record<string, unknown>
|
return value as Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +22,11 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
|
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
|
||||||
out[key] = item
|
out[key] = item
|
||||||
}
|
}
|
||||||
|
const addEntry = (out: Record<string, unknown>, key: unknown, item: unknown): void => {
|
||||||
|
boundedData(key, "Object.fromEntries key")
|
||||||
|
boundedData(item, "Object.fromEntries value")
|
||||||
|
guardedSet(out, coerceToString(key), item)
|
||||||
|
}
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "keys": {
|
case "keys": {
|
||||||
const value = boundedData(args[0], "Object.keys input")
|
const value = boundedData(args[0], "Object.keys input")
|
||||||
|
|
@ -55,7 +63,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||||
case "fromEntries": {
|
case "fromEntries": {
|
||||||
if (args[0] instanceof SandboxMap) {
|
if (args[0] instanceof SandboxMap) {
|
||||||
const out: Record<string, unknown> = Object.create(null)
|
const out: Record<string, unknown> = Object.create(null)
|
||||||
for (const [key, item] of args[0].map.entries()) guardedSet(out, coerceToString(key), item)
|
for (const [key, item] of args[0].map.entries()) addEntry(out, key, item)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
if (args[0] instanceof SandboxURLSearchParams) {
|
if (args[0] instanceof SandboxURLSearchParams) {
|
||||||
|
|
@ -63,16 +71,18 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||||
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
|
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
const pairs = boundedData(args[0], "Object.fromEntries input")
|
const pairs = args[0] instanceof SandboxSet ? Array.from(args[0].set.values()) : args[0]
|
||||||
if (!Array.isArray(pairs)) {
|
if (!Array.isArray(pairs)) {
|
||||||
|
boundedData(args[0], "Object.fromEntries input")
|
||||||
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
|
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
|
||||||
}
|
}
|
||||||
const out: Record<string, unknown> = Object.create(null)
|
const out: Record<string, unknown> = Object.create(null)
|
||||||
for (const pair of pairs) {
|
for (const pair of pairs) {
|
||||||
if (!Array.isArray(pair)) {
|
const validated = boundedData(pair, "Object.fromEntries entry")
|
||||||
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node)
|
if (validated === null || typeof validated !== "object" || isSandboxValue(validated))
|
||||||
}
|
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
|
||||||
guardedSet(out, String(pair[0]), pair[1])
|
const entry = pair as Record<string, unknown>
|
||||||
|
addEntry(out, entry[0], entry[1])
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,16 @@ const copyBounded = (
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
|
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
|
||||||
|
if (preserveSandboxValues) {
|
||||||
|
// Array metadata is not serialized, but intra-sandbox copies must retain it.
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
if (Object.hasOwn(copied, key)) continue
|
||||||
|
if (isBlockedMember(key)) {
|
||||||
|
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
|
||||||
|
}
|
||||||
|
Reflect.set(copied, key, copyBounded(item, label, depth + 1, seen, true))
|
||||||
|
}
|
||||||
|
}
|
||||||
seen.delete(value)
|
seen.delete(value)
|
||||||
return copied
|
return copied
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,54 @@ describe("H5: builtin coercion functions work as array callbacks", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("for...of assignment destructuring", () => {
|
||||||
|
test("assigns entry pairs into predeclared variables", async () => {
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
let key
|
||||||
|
let item
|
||||||
|
const out = []
|
||||||
|
for ([key, item] of Object.entries({ a: 1, b: 2 })) out.push(key + item)
|
||||||
|
return { key, item, out }
|
||||||
|
`),
|
||||||
|
).toEqual({ key: "b", item: 2, out: ["a1", "b2"] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("assigns object patterns and defaults", async () => {
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
let id
|
||||||
|
let label
|
||||||
|
const labels = []
|
||||||
|
for ({ id, label = "unknown" } of [{ id: 1 }, { id: 2, label: "two" }]) labels.push(label)
|
||||||
|
return { id, label, labels }
|
||||||
|
`),
|
||||||
|
).toEqual({ id: 2, label: "two", labels: ["unknown", "two"] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("sequence expressions", () => {
|
||||||
|
test("evaluate left to right and return the final value", async () => {
|
||||||
|
expect(await value(`let x = 0; const result = (x += 1, x *= 3, x + 2); return { x, result }`)).toEqual({
|
||||||
|
x: 3,
|
||||||
|
result: 5,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("support comma-separated for-loop updates", async () => {
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const pairs = []
|
||||||
|
for (let left = 0, right = 3; left < right; left++, right--) pairs.push([left, right])
|
||||||
|
return pairs
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
[0, 3],
|
||||||
|
[1, 2],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("destructuring assignment", () => {
|
describe("destructuring assignment", () => {
|
||||||
test("assigns object and array patterns to existing bindings", async () => {
|
test("assigns object and array patterns to existing bindings", async () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,14 @@ const failingTool = Tool.make({
|
||||||
run: () => Effect.fail(toolError("Lookup refused")),
|
run: () => Effect.fail(toolError("Lookup refused")),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const completedTool = (trace: Trace) =>
|
||||||
|
Tool.make({
|
||||||
|
description: "Return the number of completed sleepy calls",
|
||||||
|
input: Schema.Struct({}),
|
||||||
|
output: Schema.Number,
|
||||||
|
run: () => Effect.succeed(trace.completed),
|
||||||
|
})
|
||||||
|
|
||||||
const run = (
|
const run = (
|
||||||
code: string,
|
code: string,
|
||||||
options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
|
options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
|
||||||
|
|
@ -55,7 +63,7 @@ const run = (
|
||||||
const trace = options.trace ?? makeTrace()
|
const trace = options.trace ?? makeTrace()
|
||||||
return Effect.runPromise(
|
return Effect.runPromise(
|
||||||
CodeMode.execute({
|
CodeMode.execute({
|
||||||
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } },
|
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool, completed: completedTool(trace) } },
|
||||||
code,
|
code,
|
||||||
...(options.limits ? { limits: options.limits } : {}),
|
...(options.limits ? { limits: options.limits } : {}),
|
||||||
}),
|
}),
|
||||||
|
|
@ -75,6 +83,42 @@ const error = async (code: string, options: { trace?: Trace; limits?: CodeMode.E
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("first-class promise values", () => {
|
describe("first-class promise values", () => {
|
||||||
|
test("async functions return promises with isolated concurrent invocations", async () => {
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const load = async (id) => {
|
||||||
|
const result = await tools.host.sleepy({ id, ms: 20 })
|
||||||
|
return [id, result]
|
||||||
|
}
|
||||||
|
const first = load(1)
|
||||||
|
const second = load(2)
|
||||||
|
return [first instanceof Promise, second instanceof Promise, await Promise.all([first, second])]
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
[1, 1],
|
||||||
|
[2, 2],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("async function errors reject instead of throwing at the call site", async () => {
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const fail = async () => { throw new Error("boom") }
|
||||||
|
const promise = fail()
|
||||||
|
try {
|
||||||
|
await promise
|
||||||
|
return "no"
|
||||||
|
} catch (error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toBe("boom")
|
||||||
|
})
|
||||||
|
|
||||||
test("an un-awaited tool call starts eagerly, in call order, before any await", async () => {
|
test("an un-awaited tool call starts eagerly, in call order, before any await", async () => {
|
||||||
const trace = makeTrace()
|
const trace = makeTrace()
|
||||||
const result = await value(
|
const result = await value(
|
||||||
|
|
@ -163,9 +207,33 @@ describe("first-class promise values", () => {
|
||||||
return "done"
|
return "done"
|
||||||
`)
|
`)
|
||||||
expect(diagnostic.kind).toBe("ToolFailure")
|
expect(diagnostic.kind).toBe("ToolFailure")
|
||||||
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited tool call")
|
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
|
||||||
|
expect(diagnostic.message).toContain("Lookup refused")
|
||||||
|
expect(diagnostic.suggestions?.join(" ")).toContain("Await promises")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a never-awaited failing async function surfaces as an unhandled promise rejection", async () => {
|
||||||
|
const diagnostic = await error(`
|
||||||
|
const fail = async () => { throw new Error("boom") }
|
||||||
|
fail()
|
||||||
|
return "done"
|
||||||
|
`)
|
||||||
|
expect(diagnostic.kind).toBe("ExecutionFailure")
|
||||||
|
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
|
||||||
|
expect(diagnostic.message).toContain("boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("drains promises started by an async function after an await", async () => {
|
||||||
|
const diagnostic = await error(`
|
||||||
|
const run = async () => {
|
||||||
|
await tools.host.sleepy({ id: 1 })
|
||||||
|
tools.host.fail({})
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
return "done"
|
||||||
|
`)
|
||||||
|
expect(diagnostic.kind).toBe("ToolFailure")
|
||||||
expect(diagnostic.message).toContain("Lookup refused")
|
expect(diagnostic.message).toContain("Lookup refused")
|
||||||
expect(diagnostic.suggestions?.join(" ")).toContain("await tools.ns.tool(...)")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -232,6 +300,19 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||||
expect(trace.maxActive).toBeGreaterThan(1)
|
expect(trace.maxActive).toBeGreaterThan(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("runs async map callbacks concurrently", async () => {
|
||||||
|
const trace = makeTrace()
|
||||||
|
const result = await value(
|
||||||
|
`
|
||||||
|
const ids = [1, 2, 3, 4]
|
||||||
|
return await Promise.all(ids.map(async (id) => await tools.host.sleepy({ id, ms: 40 })))
|
||||||
|
`,
|
||||||
|
{ trace },
|
||||||
|
)
|
||||||
|
expect(result).toEqual([1, 2, 3, 4])
|
||||||
|
expect(trace.maxActive).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
|
||||||
test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
|
test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
|
||||||
const trace = makeTrace()
|
const trace = makeTrace()
|
||||||
const result = await value(
|
const result = await value(
|
||||||
|
|
@ -265,6 +346,28 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||||
).toBe("Lookup refused")
|
).toBe("Lookup refused")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("rejects before an earlier slow promise fulfills", async () => {
|
||||||
|
const trace = makeTrace()
|
||||||
|
expect(
|
||||||
|
await value(
|
||||||
|
`
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
tools.host.sleepy({ id: 1, ms: 100 }),
|
||||||
|
tools.host.fail({}),
|
||||||
|
])
|
||||||
|
return -1
|
||||||
|
} catch {
|
||||||
|
return await tools.host.completed({})
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{ trace },
|
||||||
|
),
|
||||||
|
).toBe(0)
|
||||||
|
expect(trace.completed).toBe(1)
|
||||||
|
expect(trace.interrupted).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
test("a non-collection argument is a clear error", async () => {
|
test("a non-collection argument is a clear error", async () => {
|
||||||
const diagnostic = await error(`return await Promise.all(42)`)
|
const diagnostic = await error(`return await Promise.all(42)`)
|
||||||
expect(diagnostic.message).toContain("Promise.all expects an array")
|
expect(diagnostic.message).toContain("Promise.all expects an array")
|
||||||
|
|
|
||||||
|
|
@ -586,6 +586,85 @@ describe("Set", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("stdlib integration", () => {
|
describe("stdlib integration", () => {
|
||||||
|
test("Object values and entries accept arrays", async () => {
|
||||||
|
expect(await value(`return [Object.values(["a", "b"]), Object.entries(["a", "b"])]`)).toEqual([
|
||||||
|
["a", "b"],
|
||||||
|
[
|
||||||
|
["0", "a"],
|
||||||
|
["1", "b"],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
expect(await value(`const match = /a/.exec("ba"); return [Object.values(match), Object.entries(match)]`)).toEqual([
|
||||||
|
["a", 1],
|
||||||
|
[
|
||||||
|
["0", "a"],
|
||||||
|
["index", 1],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
expect(await value(`return Object.keys(Object.values({ match: /a/.exec("ba") })[0])`)).toEqual(["0", "index"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Object.fromEntries accepts every supported entry collection", async () => {
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
return [
|
||||||
|
Object.fromEntries([["a", 1]]),
|
||||||
|
Object.fromEntries(new Map([["b", 2]])),
|
||||||
|
Object.fromEntries(new Set([["c", 3]])),
|
||||||
|
Object.fromEntries(new URLSearchParams("d=4")),
|
||||||
|
Object.fromEntries([{ 0: "e", 1: 5 }]),
|
||||||
|
Object.fromEntries(new Set([[{}, 6], [new Date(0), 7], [null, 8], [undefined, 9]])),
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{ a: 1 },
|
||||||
|
{ b: 2 },
|
||||||
|
{ c: 3 },
|
||||||
|
{ d: "4" },
|
||||||
|
{ e: 5 },
|
||||||
|
{ "[object Object]": 6, "1970-01-01T00:00:00.000Z": 7, null: 8, undefined: 9 },
|
||||||
|
])
|
||||||
|
expect(await value(`try { Object.fromEntries(new Set([Math.max])); return false } catch { return true }`)).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
await value(
|
||||||
|
`try { Object.fromEntries(new Map([["fn", Math.max]])); return false } catch { return true }`,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deterministic Math methods match the host runtime", async () => {
|
||||||
|
const result = await value(`
|
||||||
|
return [
|
||||||
|
Math.acos(0.5), Math.acosh(2), Math.asin(0.5), Math.asinh(2), Math.atan(1), Math.atan2(1, 2), Math.atanh(0.5),
|
||||||
|
Math.cos(0.5), Math.cosh(0.5), Math.sin(0.5), Math.sinh(0.5), Math.tan(0.5), Math.tanh(0.5),
|
||||||
|
Math.log1p(0.5), Math.expm1(0.5), Math.f16round(1.337), Math.fround(1.337), Math.clz32(1), Math.imul(2, 3),
|
||||||
|
]
|
||||||
|
`)
|
||||||
|
expect(result).toEqual([
|
||||||
|
Math.acos(0.5),
|
||||||
|
Math.acosh(2),
|
||||||
|
Math.asin(0.5),
|
||||||
|
Math.asinh(2),
|
||||||
|
Math.atan(1),
|
||||||
|
Math.atan2(1, 2),
|
||||||
|
Math.atanh(0.5),
|
||||||
|
Math.cos(0.5),
|
||||||
|
Math.cosh(0.5),
|
||||||
|
Math.sin(0.5),
|
||||||
|
Math.sinh(0.5),
|
||||||
|
Math.tan(0.5),
|
||||||
|
Math.tanh(0.5),
|
||||||
|
Math.log1p(0.5),
|
||||||
|
Math.expm1(0.5),
|
||||||
|
Math.f16round(1.337),
|
||||||
|
Math.fround(1.337),
|
||||||
|
Math.clz32(1),
|
||||||
|
Math.imul(2, 3),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("Object.assign mutates and returns its target", async () => {
|
test("Object.assign mutates and returns its target", async () => {
|
||||||
expect(
|
expect(
|
||||||
await value(`
|
await value(`
|
||||||
|
|
|
||||||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -44,6 +44,7 @@ export const migrations = (
|
||||||
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
|
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
|
||||||
import("./migration/20260703181610_event_created_column"),
|
import("./migration/20260703181610_event_created_column"),
|
||||||
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
|
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
|
||||||
|
import("./migration/20260703200000_reset_v2_session_events"),
|
||||||
import("./migration/20260705180000_rename_instructions"),
|
import("./migration/20260705180000_rename_instructions"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260703200000_reset_v2_session_events",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||||
|
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||||
|
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||||
|
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
|
|
@ -67,7 +67,13 @@ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("P
|
||||||
|
|
||||||
export class BlockedError extends Schema.TaggedErrorClass<BlockedError>()("PermissionV2.BlockedError", {
|
export class BlockedError extends Schema.TaggedErrorClass<BlockedError>()("PermissionV2.BlockedError", {
|
||||||
rules: Permission.Ruleset,
|
rules: Permission.Ruleset,
|
||||||
}) {}
|
permission: Schema.String,
|
||||||
|
resources: Schema.Array(Schema.String),
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `Permission denied: ${this.permission}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
|
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
|
||||||
requestID: ID,
|
requestID: ID,
|
||||||
|
|
@ -201,6 +207,8 @@ const layer = Layer.effect(
|
||||||
if (result.effect === "deny") {
|
if (result.effect === "deny") {
|
||||||
return yield* new BlockedError({
|
return yield* new BlockedError({
|
||||||
rules: relevant(input, result.rules),
|
rules: relevant(input, result.rules),
|
||||||
|
permission: input.action,
|
||||||
|
resources: input.resources,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (result.effect === "allow") return
|
if (result.effect === "allow") return
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,13 @@ const make = (dependencies: Dependencies) => {
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.runForEach((event) => {
|
Stream.runForEach((event) => {
|
||||||
if (LLMEvent.is.providerError(event)) failed = true
|
if (LLMEvent.is.providerError(event)) failed = true
|
||||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
if (LLMEvent.is.textDelta(event)) {
|
||||||
|
chunks.push(event.text)
|
||||||
|
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
text: event.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
Effect.as(true),
|
Effect.as(true),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
|
|
||||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
|
|
@ -10,3 +11,20 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
|
||||||
return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
|
return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class StepFailedError extends Schema.TaggedErrorClass<StepFailedError>()("Session.StepFailedError", {
|
||||||
|
error: SessionError.Error,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return this.error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserInterruptedError extends Schema.TaggedErrorClass<UserInterruptedError>()(
|
||||||
|
"Session.UserInterruptedError",
|
||||||
|
{},
|
||||||
|
) {
|
||||||
|
override get message() {
|
||||||
|
return "Session interrupted by user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Cause, DateTime, Effect, Exit, Layer } from "effect"
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { LocationServiceMap } from "../../location-service-map"
|
import { LocationServiceMap } from "../../location-service-map"
|
||||||
import { makeGlobalNode } from "../../effect/app-node"
|
import { makeGlobalNode } from "../../effect/app-node"
|
||||||
|
|
@ -8,6 +8,16 @@ import { SessionRunner } from "../runner"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
import { SessionStore } from "../store"
|
import { SessionStore } from "../store"
|
||||||
import { SessionExecution } from "../execution"
|
import { SessionExecution } from "../execution"
|
||||||
|
import { toSessionError } from "../to-session-error"
|
||||||
|
import { UserInterruptedError } from "../error"
|
||||||
|
|
||||||
|
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: "user" | "shutdown" | "superseded") {
|
||||||
|
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
|
||||||
|
if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" }
|
||||||
|
const failure = Cause.squash(exit.cause)
|
||||||
|
if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const }
|
||||||
|
return { type: "failed" as const, error: toSessionError(failure) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
|
|
@ -16,7 +26,23 @@ const layer = Layer.effect(
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const locations = yield* LocationServiceMap.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
||||||
|
effect.pipe(
|
||||||
|
Effect.tapCause((cause) =>
|
||||||
|
Cause.hasInterruptsOnly(cause)
|
||||||
|
? Effect.void
|
||||||
|
: Effect.logError("Failed to publish Session execution lifecycle", cause).pipe(
|
||||||
|
Effect.annotateLogs({ sessionID }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.asVoid,
|
||||||
|
)
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make<
|
||||||
|
SessionSchema.ID,
|
||||||
|
SessionRunner.RunError,
|
||||||
|
"user" | "shutdown" | "superseded"
|
||||||
|
>({
|
||||||
|
started: (sessionID) => reportLifecycle(sessionID, events.publish(SessionEvent.Execution.Started, { sessionID })),
|
||||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||||
const session = yield* store.get(sessionID)
|
const session = yield* store.get(sessionID)
|
||||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||||
|
|
@ -29,28 +55,31 @@ const layer = Layer.effect(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
// One ExecutionSettled per execution (busy period), covering every coalesced drain.
|
// One terminal observation per busy period, covering every coalesced drain.
|
||||||
settled: (sessionID, exit) =>
|
settled: (sessionID, exit, reason) =>
|
||||||
Effect.gen(function* () {
|
reportLifecycle(
|
||||||
const failure =
|
sessionID,
|
||||||
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined
|
Effect.gen(function* () {
|
||||||
yield* events.publish(SessionEvent.ExecutionSettled, {
|
const outcome = terminal(exit, reason)
|
||||||
sessionID,
|
if (outcome.type === "succeeded") {
|
||||||
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
|
yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID })
|
||||||
error:
|
return
|
||||||
failure !== undefined
|
}
|
||||||
? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) }
|
if (outcome.type === "interrupted") {
|
||||||
: undefined,
|
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
|
||||||
})
|
return
|
||||||
}).pipe(
|
}
|
||||||
Effect.catchCause(() => Effect.void),
|
yield* events.publish(SessionEvent.Execution.Failed, {
|
||||||
Effect.asVoid,
|
sessionID,
|
||||||
|
error: outcome.error,
|
||||||
|
})
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
return SessionExecution.Service.of({
|
return SessionExecution.Service.of({
|
||||||
active: coordinator.active,
|
active: coordinator.active,
|
||||||
interrupt: coordinator.interrupt,
|
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
awaitIdle: coordinator.awaitIdle,
|
awaitIdle: coordinator.awaitIdle,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { castDraft, produce, type WritableDraft } from "immer"
|
import { castDraft, produce, type WritableDraft } from "immer"
|
||||||
import { Effect } from "effect"
|
import { DateTime, Effect } from "effect"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
|
|
||||||
|
|
@ -99,11 +99,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
|
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
|
||||||
)
|
)
|
||||||
|
|
||||||
const latestText = (assistant: DraftAssistant | undefined, textID: string) =>
|
const latestText = (assistant: DraftAssistant | undefined) =>
|
||||||
assistant?.content.findLast((item): item is DraftText => item.type === "text" && item.id === textID)
|
assistant?.content.findLast((item): item is DraftText => item.type === "text")
|
||||||
|
|
||||||
const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) =>
|
const latestReasoning = (assistant: DraftAssistant | undefined) =>
|
||||||
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID)
|
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
|
||||||
|
|
||||||
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
|
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -111,6 +111,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
|
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const clearCurrentRetry = Effect.gen(function* () {
|
||||||
|
const assistant = yield* adapter.getCurrentAssistant()
|
||||||
|
if (assistant?.retry) {
|
||||||
|
yield* adapter.updateAssistant(
|
||||||
|
produce(assistant, (draft) => {
|
||||||
|
draft.retry = undefined
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
yield* SessionEvent.All.match(event, {
|
yield* SessionEvent.All.match(event, {
|
||||||
"session.agent.selected": (event) => {
|
"session.agent.selected": (event) => {
|
||||||
|
|
@ -144,7 +155,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
"session.forked": () => Effect.void,
|
"session.forked": () => Effect.void,
|
||||||
"session.prompt.promoted": () => Effect.void,
|
"session.prompt.promoted": () => Effect.void,
|
||||||
"session.prompt.admitted": () => Effect.void,
|
"session.prompt.admitted": () => Effect.void,
|
||||||
"session.execution.settled": () => Effect.void,
|
"session.execution.started": () => Effect.void,
|
||||||
|
"session.execution.succeeded": () => clearCurrentRetry,
|
||||||
|
"session.execution.failed": () => clearCurrentRetry,
|
||||||
|
"session.execution.interrupted": () => clearCurrentRetry,
|
||||||
"session.instructions.updated": (event) =>
|
"session.instructions.updated": (event) =>
|
||||||
adapter.appendMessage(
|
adapter.appendMessage(
|
||||||
SessionMessage.System.make({
|
SessionMessage.System.make({
|
||||||
|
|
@ -206,10 +220,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
},
|
},
|
||||||
"session.step.started": (event) => {
|
"session.step.started": (event) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
const existing = yield* adapter.getAssistant(event.data.assistantMessageID)
|
||||||
|
if (existing) {
|
||||||
|
yield* adapter.updateAssistant(
|
||||||
|
produce(existing, (draft) => {
|
||||||
|
draft.agent = event.data.agent
|
||||||
|
draft.model = castDraft(event.data.model)
|
||||||
|
draft.retry = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
draft.finish = undefined
|
||||||
|
draft.time.completed = undefined
|
||||||
|
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||||
if (currentAssistant) {
|
if (currentAssistant) {
|
||||||
yield* adapter.updateAssistant(
|
yield* adapter.updateAssistant(
|
||||||
produce(currentAssistant, (draft) => {
|
produce(currentAssistant, (draft) => {
|
||||||
|
draft.retry = undefined
|
||||||
draft.time.completed = event.created
|
draft.time.completed = event.created
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -245,25 +275,24 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
draft.time.completed = event.created
|
draft.time.completed = event.created
|
||||||
draft.finish = "error"
|
draft.finish = "error"
|
||||||
draft.error = event.data.error
|
draft.error = castDraft(event.data.error)
|
||||||
|
draft.retry = undefined
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.text.started": (event) => {
|
"session.text.started": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
draft.content.push(
|
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
|
||||||
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.text.delta": (event) => {
|
"session.text.delta": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestText(draft, event.data.textID)
|
const match = latestText(draft)
|
||||||
if (match) match.text += event.data.delta
|
if (match) match.text += event.data.delta
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.text.ended": (event) => {
|
"session.text.ended": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestText(draft, event.data.textID)
|
const match = latestText(draft)
|
||||||
if (match) match.text = event.data.text
|
if (match) match.text = event.data.text
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -293,7 +322,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestTool(draft, event.data.callID)
|
const match = latestTool(draft, event.data.callID)
|
||||||
if (match) {
|
if (match) {
|
||||||
match.provider = event.data.provider
|
match.executed = event.data.executed
|
||||||
|
match.providerState = event.data.state
|
||||||
match.time.ran = event.created
|
match.time.ran = event.created
|
||||||
match.state = castDraft(
|
match.state = castDraft(
|
||||||
SessionMessage.ToolStateRunning.make({
|
SessionMessage.ToolStateRunning.make({
|
||||||
|
|
@ -319,11 +349,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestTool(draft, event.data.callID)
|
const match = latestTool(draft, event.data.callID)
|
||||||
if (match && match.state.status === "running") {
|
if (match && match.state.status === "running") {
|
||||||
match.provider = {
|
match.executed = event.data.executed || match.executed === true
|
||||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
match.providerResultState = event.data.resultState
|
||||||
metadata: match.provider?.metadata,
|
|
||||||
resultMetadata: event.data.provider.metadata,
|
|
||||||
}
|
|
||||||
match.time.completed = event.created
|
match.time.completed = event.created
|
||||||
match.state = castDraft(
|
match.state = castDraft(
|
||||||
SessionMessage.ToolStateCompleted.make({
|
SessionMessage.ToolStateCompleted.make({
|
||||||
|
|
@ -342,11 +369,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestTool(draft, event.data.callID)
|
const match = latestTool(draft, event.data.callID)
|
||||||
if (match && (match.state.status === "pending" || match.state.status === "running")) {
|
if (match && (match.state.status === "pending" || match.state.status === "running")) {
|
||||||
match.provider = {
|
match.executed = event.data.executed || match.executed === true
|
||||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
match.providerResultState = event.data.resultState
|
||||||
metadata: match.provider?.metadata,
|
|
||||||
resultMetadata: event.data.provider.metadata,
|
|
||||||
}
|
|
||||||
match.time.completed = event.created
|
match.time.completed = event.created
|
||||||
match.state = castDraft(
|
match.state = castDraft(
|
||||||
SessionMessage.ToolStateError.make({
|
SessionMessage.ToolStateError.make({
|
||||||
|
|
@ -367,9 +391,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
castDraft(
|
castDraft(
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: event.data.reasoningID,
|
|
||||||
text: "",
|
text: "",
|
||||||
providerMetadata: event.data.providerMetadata,
|
state: event.data.state,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -378,21 +401,29 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
},
|
},
|
||||||
"session.reasoning.delta": (event) => {
|
"session.reasoning.delta": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestReasoning(draft, event.data.reasoningID)
|
const match = latestReasoning(draft)
|
||||||
if (match) match.text += event.data.delta
|
if (match) match.text += event.data.delta
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.reasoning.ended": (event) => {
|
"session.reasoning.ended": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestReasoning(draft, event.data.reasoningID)
|
const match = latestReasoning(draft)
|
||||||
if (match) {
|
if (match) {
|
||||||
match.text = event.data.text
|
match.text = event.data.text
|
||||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
if (event.data.state !== undefined) match.state = event.data.state
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"session.retry.scheduled": (event) => {
|
||||||
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
|
draft.retry = {
|
||||||
|
attempt: event.data.attempt,
|
||||||
|
at: DateTime.makeUnsafe(event.data.at),
|
||||||
|
error: castDraft(event.data.error),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.retried": () => Effect.void,
|
|
||||||
"session.compaction.started": () => Effect.void,
|
"session.compaction.started": () => Effect.void,
|
||||||
"session.compaction.delta": () => Effect.void,
|
"session.compaction.delta": () => Effect.void,
|
||||||
"session.compaction.ended": (event) => {
|
"session.compaction.ended": (event) => {
|
||||||
|
|
|
||||||
|
|
@ -634,6 +634,9 @@ const layer = Layer.effectDiscard(
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||||
|
yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||||
|
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event))
|
yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Skill.Activated, (event) =>
|
yield* events.project(SessionEvent.Skill.Activated, (event) =>
|
||||||
|
|
@ -660,7 +663,7 @@ const layer = Layer.effectDiscard(
|
||||||
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
|
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
|
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
||||||
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
yield* events.project(SessionEvent.RetryScheduled, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
|
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
||||||
db
|
db
|
||||||
|
|
@ -687,14 +690,11 @@ const layer = Layer.effectDiscard(
|
||||||
.select({ seq: SessionMessageTable.seq })
|
.select({ seq: SessionMessageTable.seq })
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.to)),
|
||||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
|
||||||
eq(SessionMessageTable.id, event.data.messageID),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.messageID}`))
|
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.to}`))
|
||||||
yield* db
|
yield* db
|
||||||
.delete(SessionMessageTable)
|
.delete(SessionMessageTable)
|
||||||
.where(
|
.where(
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,6 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (session: Sess
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
yield* events.publish(SessionEvent.RevertEvent.Committed, {
|
yield* events.publish(SessionEvent.RevertEvent.Committed, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
messageID: session.revert.messageID,
|
to: session.revert.messageID,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ export * as SessionRunCoordinator from "./run-coordinator"
|
||||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||||
|
|
||||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||||
export interface Coordinator<Key, E> {
|
export interface Coordinator<Key, E, Reason = never> {
|
||||||
/** Snapshots keys with an execution owned by this coordinator. */
|
/** Snapshots keys with an execution owned by this coordinator. */
|
||||||
readonly active: Effect.Effect<ReadonlySet<Key>>
|
readonly active: Effect.Effect<ReadonlySet<Key>>
|
||||||
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||||
|
|
@ -11,7 +11,7 @@ export interface Coordinator<Key, E> {
|
||||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||||
readonly wake: (key: Key) => Effect.Effect<void>
|
readonly wake: (key: Key) => Effect.Effect<void>
|
||||||
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
||||||
readonly interrupt: (key: Key) => Effect.Effect<void>
|
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
@ -23,11 +23,13 @@ export interface Coordinator<Key, E> {
|
||||||
* closes the gap between a drain's last eligibility check and the idle transition, since
|
* closes the gap between a drain's last eligibility check and the idle transition, since
|
||||||
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
|
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
|
||||||
*/
|
*/
|
||||||
type Execution<E> = {
|
type Execution<E, Reason> = {
|
||||||
readonly done: Deferred.Deferred<void, E>
|
readonly done: Deferred.Deferred<void, E>
|
||||||
owner?: Fiber.Fiber<void>
|
owner?: Fiber.Fiber<void>
|
||||||
pendingWake: boolean
|
pendingWake: boolean
|
||||||
stopping: boolean
|
stopping: boolean
|
||||||
|
settling: boolean
|
||||||
|
interruptionReason?: Reason
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -41,19 +43,21 @@ type Execution<E> = {
|
||||||
* waiters get this exit
|
* waiters get this exit
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const make = <Key, E>(options: {
|
export const make = <Key, E, Reason = never>(options: {
|
||||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||||
|
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||||
|
readonly started?: (key: Key) => Effect.Effect<void>
|
||||||
/**
|
/**
|
||||||
* Runs in the execution fiber for every exit, including interruption, after the final
|
* Runs in the execution fiber for every exit, including interruption, after the final
|
||||||
* drain and before the execution settles (waiters resolve after it completes).
|
* drain and before the execution settles (waiters resolve after it completes).
|
||||||
*/
|
*/
|
||||||
readonly settled?: (key: Key, exit: Exit.Exit<void, E>) => Effect.Effect<void>
|
readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<void>
|
||||||
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
|
}): Effect.Effect<Coordinator<Key, E, Reason>, never, Scope.Scope> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const executions = new Map<Key, Execution<E>>()
|
const executions = new Map<Key, Execution<E, Reason>>()
|
||||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||||
|
|
||||||
const loop = (key: Key, execution: Execution<E>, force: boolean): Effect.Effect<void, E> =>
|
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
|
||||||
Effect.suspend(() => options.drain(key, force)).pipe(
|
Effect.suspend(() => options.drain(key, force)).pipe(
|
||||||
Effect.flatMap(() =>
|
Effect.flatMap(() =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
|
|
@ -66,15 +70,25 @@ export const make = <Key, E>(options: {
|
||||||
)
|
)
|
||||||
|
|
||||||
const start = (key: Key, force: boolean) => {
|
const start = (key: Key, force: boolean) => {
|
||||||
const execution: Execution<E> = { done: Deferred.makeUnsafe<void, E>(), pendingWake: false, stopping: false }
|
const execution: Execution<E, Reason> = {
|
||||||
|
done: Deferred.makeUnsafe<void, E>(),
|
||||||
|
pendingWake: false,
|
||||||
|
stopping: false,
|
||||||
|
settling: false,
|
||||||
|
}
|
||||||
executions.set(key, execution)
|
executions.set(key, execution)
|
||||||
// The leading yield lets `owner` be assigned before the drain can settle, and keeps
|
// The leading yield lets `owner` be assigned before the drain can settle, and keeps
|
||||||
// failing self-waking executions from growing the stack across successor starts.
|
// failing self-waking executions from growing the stack across successor starts.
|
||||||
// Drains start one tick after wake; callers observe progress through events or run.
|
// Drains start one tick after wake; callers observe progress through events or run.
|
||||||
execution.owner = fork(
|
execution.owner = fork(
|
||||||
Effect.yieldNow.pipe(
|
Effect.yieldNow.pipe(
|
||||||
|
Effect.andThen(Effect.uninterruptible(options.started?.(key) ?? Effect.void)),
|
||||||
Effect.andThen(loop(key, execution, force)),
|
Effect.andThen(loop(key, execution, force)),
|
||||||
Effect.onExit((exit) => options.settled?.(key, exit) ?? Effect.void),
|
Effect.onExit((exit) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
execution.settling = true
|
||||||
|
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
|
||||||
|
),
|
||||||
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
||||||
Effect.exit,
|
Effect.exit,
|
||||||
Effect.asVoid,
|
Effect.asVoid,
|
||||||
|
|
@ -85,7 +99,7 @@ export const make = <Key, E>(options: {
|
||||||
|
|
||||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||||
const settle = (key: Key, execution: Execution<E>, exit: Exit.Exit<void, E>) => {
|
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||||
if (execution.pendingWake) start(key, false)
|
if (execution.pendingWake) start(key, false)
|
||||||
else executions.delete(key)
|
else executions.delete(key)
|
||||||
Deferred.doneUnsafe(execution.done, exit)
|
Deferred.doneUnsafe(execution.done, exit)
|
||||||
|
|
@ -112,12 +126,13 @@ export const make = <Key, E>(options: {
|
||||||
start(key, false)
|
start(key, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
const interrupt = (key: Key): Effect.Effect<void> =>
|
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
const execution = executions.get(key)
|
const execution = executions.get(key)
|
||||||
if (execution?.owner === undefined) return Effect.void
|
if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
|
||||||
execution.stopping = true
|
execution.stopping = true
|
||||||
execution.pendingWake = false
|
execution.pendingWake = false
|
||||||
|
execution.interruptionReason = reason
|
||||||
return Fiber.interrupt(execution.owner)
|
return Fiber.interrupt(execution.owner)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,19 @@ export * as SessionRunner from "./index"
|
||||||
import type { LLMError } from "@opencode-ai/llm"
|
import type { LLMError } from "@opencode-ai/llm"
|
||||||
import { Context, Effect } from "effect"
|
import { Context, Effect } from "effect"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
import type { MessageDecodeError } from "../error"
|
import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
|
||||||
import { SessionRunnerModel } from "./model"
|
import { SessionRunnerModel } from "./model"
|
||||||
import type { Instructions } from "../../instructions/index"
|
import type { Instructions } from "../../instructions/index"
|
||||||
import type { ToolOutputStore } from "../../tool-output-store"
|
import type { ToolOutputStore } from "../../tool-output-store"
|
||||||
|
|
||||||
export type RunError =
|
export type RunError =
|
||||||
LLMError | SessionRunnerModel.Error | MessageDecodeError | Instructions.InitializationBlocked | ToolOutputStore.Error
|
| LLMError
|
||||||
|
| SessionRunnerModel.Error
|
||||||
|
| MessageDecodeError
|
||||||
|
| StepFailedError
|
||||||
|
| UserInterruptedError
|
||||||
|
| Instructions.InitializationBlocked
|
||||||
|
| ToolOutputStore.Error
|
||||||
|
|
||||||
/** Runs one local continuation from already-recorded Session history. */
|
/** Runs one local continuation from already-recorded Session history. */
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ import {
|
||||||
isContextOverflowFailure,
|
isContextOverflowFailure,
|
||||||
type ProviderErrorEvent,
|
type ProviderErrorEvent,
|
||||||
} from "@opencode-ai/llm"
|
} from "@opencode-ai/llm"
|
||||||
import { Cause, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
|
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||||
import { AgentV2 } from "../../agent"
|
import { AgentV2 } from "../../agent"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { Database } from "../../database/database"
|
import { Database } from "../../database/database"
|
||||||
|
|
@ -32,6 +33,7 @@ import { SessionCompaction } from "../compaction"
|
||||||
import { SessionEvent } from "../event"
|
import { SessionEvent } from "../event"
|
||||||
import { SessionHistory } from "../history"
|
import { SessionHistory } from "../history"
|
||||||
import { SessionInput } from "../input"
|
import { SessionInput } from "../input"
|
||||||
|
import { SessionMessage } from "../message"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
import { SessionStore } from "../store"
|
import { SessionStore } from "../store"
|
||||||
import { SessionTitle } from "../title"
|
import { SessionTitle } from "../title"
|
||||||
|
|
@ -44,6 +46,9 @@ import { SessionRunnerSystemPrompt } from "./system-prompt"
|
||||||
import { Snapshot } from "../../snapshot"
|
import { Snapshot } from "../../snapshot"
|
||||||
import { makeLocationNode } from "../../effect/app-node"
|
import { makeLocationNode } from "../../effect/app-node"
|
||||||
import { llmClient } from "../../effect/app-node-platform"
|
import { llmClient } from "../../effect/app-node-platform"
|
||||||
|
import { StepFailedError, UserInterruptedError } from "../error"
|
||||||
|
import { toSessionError } from "../to-session-error"
|
||||||
|
import { SessionRunnerRetry } from "./retry"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs one durable coding-agent Session until it settles.
|
* Runs one durable coding-agent Session until it settles.
|
||||||
|
|
@ -54,10 +59,10 @@ import { llmClient } from "../../effect/app-node-platform"
|
||||||
* - Session ownership and controls
|
* - Session ownership and controls
|
||||||
* - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce.
|
* - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce.
|
||||||
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
|
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
|
||||||
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
|
* - [x] Publish durable historical execution lifecycle and bounded retry observations.
|
||||||
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
|
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
|
||||||
* - [x] Honor optional agent step limits.
|
* - [x] Honor optional agent step limits.
|
||||||
* - [ ] Bound provider retries and repeated identical tool calls.
|
* - [ ] Bound repeated identical tool calls (provider retries are bounded).
|
||||||
*
|
*
|
||||||
* - Runtime context assembly
|
* - Runtime context assembly
|
||||||
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`.
|
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`.
|
||||||
|
|
@ -66,7 +71,7 @@ import { llmClient } from "../../effect/app-node-platform"
|
||||||
* - [x] Translate every projected V2 Session message variant into canonical
|
* - [x] Translate every projected V2 Session message variant into canonical
|
||||||
* `@opencode-ai/llm` messages.
|
* `@opencode-ai/llm` messages.
|
||||||
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
|
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
|
||||||
* - [x] Stream exactly one `llm.stream(request)` physical attempt.
|
* - [x] Stream exactly one `llm.stream(request)` call per attempt.
|
||||||
* - [x] Persist assistant text and usage events incrementally as they arrive.
|
* - [x] Persist assistant text and usage events incrementally as they arrive.
|
||||||
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
|
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
|
||||||
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
|
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
|
||||||
|
|
@ -87,7 +92,7 @@ import { llmClient } from "../../effect/app-node-platform"
|
||||||
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
|
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
|
||||||
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
|
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
|
||||||
*
|
*
|
||||||
* Use `llm.stream(request)` for each physical attempt. Keep tool execution and continuation here.
|
* Use `llm.stream(request)` for each attempt. Keep tool execution and continuation here.
|
||||||
* Durable continuation recovery remains a separate future slice with an explicit retry policy.
|
* Durable continuation recovery remains a separate future slice with an explicit retry policy.
|
||||||
*
|
*
|
||||||
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
|
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
|
||||||
|
|
@ -137,19 +142,13 @@ const layer = Layer.effect(
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: message.id,
|
assistantMessageID: message.id,
|
||||||
callID: tool.id,
|
callID: tool.id,
|
||||||
error: { type: "unknown", message: "Tool execution interrupted" },
|
error: { type: "tool.stale", message: `Tool execution interrupted: ${tool.name}` },
|
||||||
provider: {
|
executed: tool.executed === true,
|
||||||
executed: tool.provider?.executed === true,
|
|
||||||
...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
|
|
||||||
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
|
|
||||||
|
|
||||||
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
|
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
|
||||||
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
|
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
|
||||||
cause.reasons.some(
|
cause.reasons.some(
|
||||||
|
|
@ -176,6 +175,7 @@ const layer = Layer.effect(
|
||||||
promotion: SessionInput.Delivery | undefined,
|
promotion: SessionInput.Delivery | undefined,
|
||||||
step: number,
|
step: number,
|
||||||
recoverOverflow?: typeof compaction.compactAfterOverflow,
|
recoverOverflow?: typeof compaction.compactAfterOverflow,
|
||||||
|
assistantMessageID?: SessionMessage.ID,
|
||||||
) {
|
) {
|
||||||
const session = yield* getSession(sessionID)
|
const session = yield* getSession(sessionID)
|
||||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||||
|
|
@ -189,7 +189,8 @@ const layer = Layer.effect(
|
||||||
loadInstructions(agent, session.id),
|
loadInstructions(agent, session.id),
|
||||||
session.id,
|
session.id,
|
||||||
)
|
)
|
||||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
|
||||||
|
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
|
||||||
let needsContinuation = false
|
let needsContinuation = false
|
||||||
let currentStep = step
|
let currentStep = step
|
||||||
if (promotion) {
|
if (promotion) {
|
||||||
|
|
@ -236,21 +237,23 @@ const layer = Layer.effect(
|
||||||
// The selected catalog identity, not model.id: route-level ids are provider API
|
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||||
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
||||||
model: resolved.ref,
|
model: resolved.ref,
|
||||||
|
provider: model.provider,
|
||||||
snapshot: startSnapshot,
|
snapshot: startSnapshot,
|
||||||
|
assistantMessageID,
|
||||||
})
|
})
|
||||||
const publication = Semaphore.makeUnsafe(1)
|
const publication = Semaphore.makeUnsafe(1)
|
||||||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||||
// mid-event.
|
// mid-event.
|
||||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
|
||||||
serialized(publisher.publish(event, outputPaths))
|
serialized(publisher.publish(event, outputPaths, error))
|
||||||
let overflowFailure: ProviderErrorEvent | undefined
|
let overflowFailure: ProviderErrorEvent | undefined
|
||||||
const providerStream = llm.stream(request).pipe(
|
const providerStream = llm.stream(request).pipe(
|
||||||
Stream.runForEach((event) =>
|
Stream.runForEach((event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (overflowFailure || publisher.hasProviderError()) return
|
if (overflowFailure || publisher.hasProviderError()) return
|
||||||
if (LLMEvent.is.providerError(event)) {
|
if (LLMEvent.is.providerError(event)) {
|
||||||
if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
|
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||||
overflowFailure = event
|
overflowFailure = event
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -258,33 +261,49 @@ const layer = Layer.effect(
|
||||||
yield* publish(event)
|
yield* publish(event)
|
||||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||||
if (!toolMaterialization) {
|
if (!toolMaterialization) {
|
||||||
yield* serialized(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
|
yield* serialized(
|
||||||
|
publisher.failUnsettledTools({
|
||||||
|
type: "tool.execution",
|
||||||
|
message: "Tools are disabled after the maximum agent steps",
|
||||||
|
}),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
needsContinuation = true
|
needsContinuation = true
|
||||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||||
yield* Effect.uninterruptibleMask((restore) =>
|
ownedToolFibers.push(
|
||||||
restore(
|
yield* Effect.uninterruptibleMask((restore) =>
|
||||||
toolMaterialization.settle({
|
restore(
|
||||||
sessionID: session.id,
|
toolMaterialization.settle({
|
||||||
agent: agent.id,
|
sessionID: session.id,
|
||||||
assistantMessageID,
|
agent: agent.id,
|
||||||
call: event,
|
assistantMessageID,
|
||||||
}),
|
call: event,
|
||||||
).pipe(
|
}),
|
||||||
Effect.flatMap((settlement) =>
|
).pipe(
|
||||||
publish(
|
Effect.flatMap((settlement) =>
|
||||||
LLMEvent.toolResult({
|
publish(
|
||||||
id: event.id,
|
LLMEvent.toolResult({
|
||||||
name: event.name,
|
id: event.id,
|
||||||
result: settlement.result,
|
name: event.name,
|
||||||
output: settlement.output,
|
result: settlement.result,
|
||||||
}),
|
output: settlement.output,
|
||||||
settlement.outputPaths ?? [],
|
}),
|
||||||
|
settlement.outputPaths ?? [],
|
||||||
|
settlement.error,
|
||||||
|
).pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
settlement.error?.type === "permission.rejected"
|
||||||
|
? serialized(publisher.failAssistant(settlement.error)).pipe(
|
||||||
|
Effect.andThen(Effect.fail(new UserInterruptedError())),
|
||||||
|
)
|
||||||
|
: Effect.void,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
).pipe(FiberSet.run(toolFibers)),
|
||||||
).pipe(FiberSet.run(toolFibers))
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.ensuring(serialized(publisher.flush())),
|
Effect.ensuring(serialized(publisher.flush())),
|
||||||
|
|
@ -327,64 +346,118 @@ const layer = Layer.effect(
|
||||||
// restart the step instead of surfacing the provider error.
|
// restart the step instead of surfacing the provider error.
|
||||||
if (
|
if (
|
||||||
recoverOverflow &&
|
recoverOverflow &&
|
||||||
!publisher.hasAssistantStarted() &&
|
!publisher.hasRetryEvidence() &&
|
||||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
|
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
|
||||||
)
|
)
|
||||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||||
|
|
||||||
// An unrecovered held-back overflow becomes the step's durable provider error. A
|
// An unrecovered held-back overflow becomes the step's durable provider error. A
|
||||||
// thrown LLM failure fails hosted tool calls and the assistant unless a provider
|
// thrown LLM failure records the assistant failure unless a provider error was
|
||||||
// error was already recorded from the stream.
|
// already recorded from the stream. Terminal publication waits for owned tools.
|
||||||
if (overflowFailure) yield* publish(overflowFailure)
|
if (overflowFailure) yield* publish(overflowFailure)
|
||||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||||
if (llmFailure && !publisher.hasProviderError()) {
|
if (llmFailure && !publisher.hasProviderError()) {
|
||||||
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
const error = toSessionError(llmFailure)
|
||||||
yield* serialized(publisher.failAssistant(llmFailure.reason.message))
|
if (
|
||||||
|
SessionRunnerRetry.isRetryable(llmFailure) &&
|
||||||
|
!publisher.hasRetryEvidence() &&
|
||||||
|
(agent.info?.steps === undefined || currentStep < agent.info.steps)
|
||||||
|
) {
|
||||||
|
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||||
|
cause: llmFailure,
|
||||||
|
assistantMessageID: yield* publisher.startAssistant(),
|
||||||
|
error,
|
||||||
|
step: currentStep,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
yield* serialized(publisher.failAssistant(error))
|
||||||
}
|
}
|
||||||
// Provider error events only arrive from the stream, so the flag is final here.
|
// Provider error events only arrive from the stream, so the flag is final here.
|
||||||
const providerFailed = publisher.hasProviderError()
|
const providerFailed = publisher.hasProviderError()
|
||||||
|
|
||||||
// Settle tool fibers: an interrupted stream abandons unstarted tool work first.
|
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
|
||||||
|
// the individual fibers and await all exits before publishing the terminal step event.
|
||||||
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
|
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
|
||||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
const settled = yield* restore(
|
||||||
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
|
Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
|
||||||
const userDeclined = settled._tag === "Failure" && isUserDeclined(settled.cause)
|
).pipe(Effect.exit)
|
||||||
|
const settledCauses =
|
||||||
|
settled._tag === "Failure"
|
||||||
|
? [settled.cause]
|
||||||
|
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
||||||
|
const toolsInterrupted = settledCauses.some(Cause.hasInterrupts)
|
||||||
|
const userDeclined = settledCauses.some(isUserDeclined)
|
||||||
|
const permissionRejected = settledCauses.some(
|
||||||
|
(cause) => Option.getOrUndefined(Cause.findErrorOption(cause)) instanceof UserInterruptedError,
|
||||||
|
)
|
||||||
|
|
||||||
if (userDeclined || streamInterrupted || toolsInterrupted) {
|
if (userDeclined || permissionRejected || streamInterrupted || toolsInterrupted) {
|
||||||
yield* FiberSet.clear(toolFibers)
|
yield* FiberSet.clear(toolFibers)
|
||||||
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
|
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||||
yield* serialized(publisher.failAssistant("Step interrupted"))
|
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
||||||
if (userDeclined) return yield* Effect.interrupt
|
|
||||||
}
|
}
|
||||||
// A settled tool fiber failure is one of two things. A defect from a tool
|
// A settled tool fiber failure is one of two things. A defect from a tool
|
||||||
// implementation becomes a failed tool call the model can read, and the step still
|
// implementation becomes a failed tool call the model can read, and the step still
|
||||||
// settles so the model may recover. A typed infrastructure failure (tool output
|
// settles so the model may recover. A typed infrastructure failure (tool output
|
||||||
// could not be persisted) also fails the assistant and then fails the drain.
|
// could not be persisted) also fails the assistant and then fails the drain.
|
||||||
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
|
const settledFailure = settledCauses.find(
|
||||||
|
(cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause) && !permissionRejected,
|
||||||
|
)
|
||||||
const infraError =
|
const infraError =
|
||||||
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
||||||
if (settledFailure !== undefined) {
|
if (settledFailure !== undefined) {
|
||||||
const failure = infraError ?? Cause.squash(settledFailure)
|
const failure = infraError ?? Cause.squash(settledFailure)
|
||||||
const message = failure instanceof Error ? failure.message : String(failure)
|
const error = toSessionError(failure)
|
||||||
yield* serialized(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
yield* serialized(publisher.failUnsettledTools(error))
|
||||||
if (infraError !== undefined)
|
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
||||||
yield* serialized(publisher.failAssistant(`Tool execution failed: ${message}`))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fail unresolved calls before the terminal step event. Local calls have joined, so
|
||||||
|
// these sweeps only close calls that could not produce a truthful settlement.
|
||||||
|
if (providerFailed)
|
||||||
|
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||||
|
if (llmFailure && !providerFailed)
|
||||||
|
yield* serialized(
|
||||||
|
publisher.failUnsettledTools(
|
||||||
|
{
|
||||||
|
type: "tool.result-missing",
|
||||||
|
message: "Provider did not return a tool result",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const hostedResultMissing =
|
||||||
|
stream._tag === "Success" && !providerFailed
|
||||||
|
? yield* serialized(
|
||||||
|
publisher.failUnsettledTools(
|
||||||
|
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: false
|
||||||
|
if (hostedResultMissing && !publisher.stepSettlement())
|
||||||
|
yield* serialized(
|
||||||
|
publisher.failAssistant({
|
||||||
|
type: "tool.result-missing",
|
||||||
|
message: "Provider did not return a tool result",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const stepFailure = publisher.stepFailure()
|
||||||
const stepSettlement = publisher.stepSettlement()
|
const stepSettlement = publisher.stepSettlement()
|
||||||
const stepEndedCleanly =
|
const stepEndedCleanly =
|
||||||
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed
|
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure
|
||||||
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
|
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
|
||||||
// A provider error orphans recorded local calls; a clean stream can still leave
|
if (stepFailure) yield* serialized(publisher.publishStepFailure())
|
||||||
// hosted calls without results.
|
|
||||||
if (providerFailed) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
|
|
||||||
if (stream._tag === "Success" && !providerFailed)
|
|
||||||
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
|
||||||
|
|
||||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||||
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined))
|
if (userDeclined) return yield* Effect.interrupt
|
||||||
return yield* Effect.failCause(settled.cause)
|
if (permissionRejected) return yield* new UserInterruptedError()
|
||||||
|
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
|
||||||
|
return yield* Effect.failCause(settledFailure)
|
||||||
|
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||||
|
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||||
return {
|
return {
|
||||||
_tag: "Completed",
|
_tag: "Completed",
|
||||||
needsContinuation: !providerFailed && needsContinuation,
|
needsContinuation: !providerFailed && needsContinuation,
|
||||||
|
|
@ -405,8 +478,31 @@ const layer = Layer.effect(
|
||||||
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
|
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
|
||||||
let currentPromotion = promotion
|
let currentPromotion = promotion
|
||||||
let currentStep = step
|
let currentStep = step
|
||||||
|
let assistantMessageID: SessionMessage.ID | undefined
|
||||||
while (true) {
|
while (true) {
|
||||||
const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow)
|
const attempt = yield* Effect.suspend(() =>
|
||||||
|
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
|
||||||
|
).pipe(
|
||||||
|
Effect.tapError((error) =>
|
||||||
|
error instanceof SessionRunnerRetry.RetryableFailure
|
||||||
|
? Effect.sync(() => {
|
||||||
|
currentStep = error.step + 1
|
||||||
|
assistantMessageID = error.assistantMessageID
|
||||||
|
currentPromotion = undefined
|
||||||
|
})
|
||||||
|
: Effect.void,
|
||||||
|
),
|
||||||
|
Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => {
|
||||||
|
if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error)
|
||||||
|
return events
|
||||||
|
.publish(SessionEvent.Step.Failed, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: error.assistantMessageID,
|
||||||
|
error: error.error,
|
||||||
|
})
|
||||||
|
.pipe(Effect.andThen(Effect.fail(error.cause)))
|
||||||
|
}),
|
||||||
|
)
|
||||||
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
|
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
|
||||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
@ -415,8 +511,7 @@ const layer = Layer.effect(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ExecutionSettled is published per execution (busy period) by SessionExecution, not per
|
// Execution lifecycle is published per busy period by SessionExecution, not per drain here.
|
||||||
// drain here.
|
|
||||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
|
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
|
||||||
import { DateTime, Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { SessionEvent } from "../event"
|
import { SessionEvent } from "../event"
|
||||||
import { SessionMessage } from "../message"
|
import { SessionMessage } from "../message"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
|
|
||||||
type Input = {
|
type Input = {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly agent: string
|
readonly agent: string
|
||||||
readonly model: ModelV2.Ref
|
readonly model: ModelV2.Ref
|
||||||
|
readonly provider: string
|
||||||
readonly snapshot?: string
|
readonly snapshot?: string
|
||||||
|
readonly assistantMessageID?: SessionMessage.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
||||||
|
|
@ -41,10 +44,10 @@ const message = (value: unknown) => {
|
||||||
|
|
||||||
type SettledOutput =
|
type SettledOutput =
|
||||||
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
|
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
|
||||||
| { readonly error: { readonly type: "unknown"; readonly message: string } }
|
| { readonly error: SessionError.Error }
|
||||||
|
|
||||||
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
|
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
|
||||||
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
|
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
|
||||||
const settled = value ?? ToolOutput.fromResultValue(result)
|
const settled = value ?? ToolOutput.fromResultValue(result)
|
||||||
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
|
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
|
||||||
return { structured: record(settled.structured), content: settled.content }
|
return { structured: record(settled.structured), content: settled.content }
|
||||||
|
|
@ -61,20 +64,25 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
called: boolean
|
called: boolean
|
||||||
settled: boolean
|
settled: boolean
|
||||||
providerExecuted: boolean
|
providerExecuted: boolean
|
||||||
providerMetadata?: ProviderMetadata
|
|
||||||
}
|
}
|
||||||
>()
|
>()
|
||||||
const timestamp = DateTime.now
|
let assistantMessageID = input.assistantMessageID
|
||||||
let assistantMessageID: SessionMessage.ID | undefined
|
let stepStarted = false
|
||||||
let assistantActive = false
|
let stepFailed = false
|
||||||
let assistantFailed = false
|
|
||||||
let providerFailed = false
|
let providerFailed = false
|
||||||
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
|
let retryEvidence = false
|
||||||
|
let stepFailure: SessionError.Error | undefined
|
||||||
|
let stepSettlement:
|
||||||
|
| {
|
||||||
|
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
|
||||||
|
readonly tokens: ReturnType<typeof tokens>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
|
||||||
const startAssistant = Effect.fnUntraced(function* () {
|
const startAssistant = Effect.fnUntraced(function* () {
|
||||||
if (assistantMessageID !== undefined) return assistantMessageID
|
if (stepStarted && assistantMessageID !== undefined) return assistantMessageID
|
||||||
assistantMessageID = SessionMessage.ID.create()
|
assistantMessageID ??= SessionMessage.ID.create()
|
||||||
assistantActive = true
|
stepStarted = true
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
...input,
|
...input,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
|
|
@ -86,29 +94,34 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
assistantMessageID === undefined
|
assistantMessageID === undefined
|
||||||
? Effect.die(new Error("Tool event before assistant step start"))
|
? Effect.die(new Error("Tool event before assistant step start"))
|
||||||
: Effect.succeed(assistantMessageID)
|
: Effect.succeed(assistantMessageID)
|
||||||
|
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider]
|
||||||
|
|
||||||
const fragments = (
|
const fragments = (
|
||||||
name: string,
|
name: string,
|
||||||
ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>,
|
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
|
||||||
|
single = false,
|
||||||
) => {
|
) => {
|
||||||
const chunks = new Map<string, string[]>()
|
const chunks = new Map<string, { readonly ordinal: number; readonly values: string[] }>()
|
||||||
|
let nextOrdinal = 0
|
||||||
const start = (id: string) =>
|
const start = (id: string) =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
|
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
|
||||||
chunks.set(id, [])
|
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
|
||||||
return Effect.void
|
const ordinal = nextOrdinal++
|
||||||
|
chunks.set(id, { ordinal, values: [] })
|
||||||
|
return Effect.succeed(ordinal)
|
||||||
})
|
})
|
||||||
const append = (id: string, value: string) =>
|
const append = (id: string, value: string) =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
const current = chunks.get(id)
|
const current = chunks.get(id)
|
||||||
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
|
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
|
||||||
current.push(value)
|
current.values.push(value)
|
||||||
return Effect.void
|
return Effect.succeed(current.ordinal)
|
||||||
})
|
})
|
||||||
const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) {
|
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
|
||||||
const current = chunks.get(id)
|
const current = chunks.get(id)
|
||||||
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
||||||
yield* ended(id, current.join(""), providerMetadata)
|
yield* ended(id, current.values.join(""), current.ordinal, state)
|
||||||
chunks.delete(id)
|
chunks.delete(id)
|
||||||
})
|
})
|
||||||
const flush = Effect.fnUntraced(function* () {
|
const flush = Effect.fnUntraced(function* () {
|
||||||
|
|
@ -117,26 +130,32 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
return { start, append, end, flush }
|
return { start, append, end, flush }
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = fragments("text", (textID, value) =>
|
const text = fragments(
|
||||||
Effect.gen(function* () {
|
"text",
|
||||||
yield* events.publish(SessionEvent.Text.Ended, {
|
(_textID, value, ordinal) =>
|
||||||
sessionID: input.sessionID,
|
Effect.gen(function* () {
|
||||||
assistantMessageID: yield* currentAssistantMessageID(),
|
yield* events.publish(SessionEvent.Text.Ended, {
|
||||||
textID,
|
sessionID: input.sessionID,
|
||||||
text: value,
|
assistantMessageID: yield* currentAssistantMessageID(),
|
||||||
})
|
ordinal,
|
||||||
}),
|
text: value,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
const reasoning = fragments("reasoning", (reasoningID, value, providerMetadata) =>
|
const reasoning = fragments(
|
||||||
Effect.gen(function* () {
|
"reasoning",
|
||||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
(_reasoningID, value, ordinal, state) =>
|
||||||
sessionID: input.sessionID,
|
Effect.gen(function* () {
|
||||||
assistantMessageID: yield* currentAssistantMessageID(),
|
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||||
reasoningID,
|
sessionID: input.sessionID,
|
||||||
text: value,
|
assistantMessageID: yield* currentAssistantMessageID(),
|
||||||
providerMetadata,
|
ordinal,
|
||||||
})
|
text: value,
|
||||||
}),
|
state,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
const toolInput = fragments("tool input", (callID, value) =>
|
const toolInput = fragments("tool input", (callID, value) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -191,37 +210,41 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
yield* flushFragments()
|
yield* flushFragments()
|
||||||
})
|
})
|
||||||
|
|
||||||
const failAssistant = Effect.fnUntraced(function* (message: string) {
|
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
|
||||||
if (assistantFailed) return
|
|
||||||
yield* flush()
|
yield* flush()
|
||||||
|
yield* startAssistant()
|
||||||
|
if (replace || stepFailure === undefined) stepFailure = error
|
||||||
|
})
|
||||||
|
|
||||||
|
const publishStepFailure = Effect.fnUntraced(function* () {
|
||||||
|
if (stepFailed || stepFailure === undefined) return
|
||||||
const assistantMessageID = yield* startAssistant()
|
const assistantMessageID = yield* startAssistant()
|
||||||
assistantActive = false
|
stepFailed = true
|
||||||
assistantFailed = true
|
|
||||||
yield* events.publish(SessionEvent.Step.Failed, {
|
yield* events.publish(SessionEvent.Step.Failed, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
error: { type: "unknown", message },
|
error: stepFailure,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
||||||
message: string,
|
error: SessionError.Error,
|
||||||
hostedOnly = false,
|
hostedOnly = false,
|
||||||
) {
|
) {
|
||||||
|
let failed = false
|
||||||
for (const [callID, tool] of tools) {
|
for (const [callID, tool] of tools) {
|
||||||
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
|
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
|
||||||
tool.settled = true
|
tool.settled = true
|
||||||
|
failed = true
|
||||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: tool.assistantMessageID,
|
assistantMessageID: tool.assistantMessageID,
|
||||||
callID,
|
callID,
|
||||||
error: { type: "unknown", message },
|
error,
|
||||||
provider: {
|
executed: tool.providerExecuted,
|
||||||
executed: tool.providerExecuted,
|
|
||||||
...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return failed
|
||||||
})
|
})
|
||||||
|
|
||||||
const assistantMessageIDForTool = (callID: string) => {
|
const assistantMessageIDForTool = (callID: string) => {
|
||||||
|
|
@ -232,24 +255,27 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
|
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
|
||||||
event: LLMEvent,
|
event: LLMEvent,
|
||||||
outputPaths: ReadonlyArray<string> = [],
|
outputPaths: ReadonlyArray<string> = [],
|
||||||
|
error?: SessionError.Error,
|
||||||
) {
|
) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "step-start":
|
case "step-start":
|
||||||
|
yield* startAssistant()
|
||||||
return
|
return
|
||||||
case "text-start":
|
case "text-start":
|
||||||
yield* text.start(event.id)
|
retryEvidence = true
|
||||||
|
const startedTextOrdinal = yield* text.start(event.id)
|
||||||
yield* events.publish(SessionEvent.Text.Started, {
|
yield* events.publish(SessionEvent.Text.Started, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: yield* startAssistant(),
|
assistantMessageID: yield* startAssistant(),
|
||||||
textID: event.id,
|
ordinal: startedTextOrdinal,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
yield* text.append(event.id, event.text)
|
const deltaTextOrdinal = yield* text.append(event.id, event.text)
|
||||||
yield* events.publish(SessionEvent.Text.Delta, {
|
yield* events.publish(SessionEvent.Text.Delta, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: yield* currentAssistantMessageID(),
|
assistantMessageID: yield* currentAssistantMessageID(),
|
||||||
textID: event.id,
|
ordinal: deltaTextOrdinal,
|
||||||
delta: event.text,
|
delta: event.text,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -257,27 +283,29 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
yield* text.end(event.id)
|
yield* text.end(event.id)
|
||||||
return
|
return
|
||||||
case "reasoning-start":
|
case "reasoning-start":
|
||||||
yield* reasoning.start(event.id)
|
retryEvidence = true
|
||||||
|
const startedReasoningOrdinal = yield* reasoning.start(event.id)
|
||||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: yield* startAssistant(),
|
assistantMessageID: yield* startAssistant(),
|
||||||
reasoningID: event.id,
|
ordinal: startedReasoningOrdinal,
|
||||||
providerMetadata: event.providerMetadata,
|
state: providerState(event.providerMetadata),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
case "reasoning-delta":
|
case "reasoning-delta":
|
||||||
yield* reasoning.append(event.id, event.text)
|
const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text)
|
||||||
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: yield* currentAssistantMessageID(),
|
assistantMessageID: yield* currentAssistantMessageID(),
|
||||||
reasoningID: event.id,
|
ordinal: deltaReasoningOrdinal,
|
||||||
delta: event.text,
|
delta: event.text,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
case "reasoning-end":
|
case "reasoning-end":
|
||||||
yield* reasoning.end(event.id, event.providerMetadata)
|
yield* reasoning.end(event.id, providerState(event.providerMetadata))
|
||||||
return
|
return
|
||||||
case "tool-input-start":
|
case "tool-input-start":
|
||||||
|
retryEvidence = true
|
||||||
yield* startToolInput(event)
|
yield* startToolInput(event)
|
||||||
return
|
return
|
||||||
case "tool-input-delta": {
|
case "tool-input-delta": {
|
||||||
|
|
@ -299,6 +327,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
yield* endToolInput(event)
|
yield* endToolInput(event)
|
||||||
return
|
return
|
||||||
case "tool-call": {
|
case "tool-call": {
|
||||||
|
retryEvidence = true
|
||||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||||
const tool = tools.get(event.id)!
|
const tool = tools.get(event.id)!
|
||||||
if (!tool.inputEnded) yield* endToolInput(event)
|
if (!tool.inputEnded) yield* endToolInput(event)
|
||||||
|
|
@ -307,21 +336,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
||||||
tool.called = true
|
tool.called = true
|
||||||
tool.providerExecuted = event.providerExecuted === true
|
tool.providerExecuted = event.providerExecuted === true
|
||||||
tool.providerMetadata = event.providerMetadata
|
const state = providerState(event.providerMetadata)
|
||||||
yield* events.publish(SessionEvent.Tool.Called, {
|
yield* events.publish(SessionEvent.Tool.Called, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: tool.assistantMessageID,
|
assistantMessageID: tool.assistantMessageID,
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
tool: event.name,
|
|
||||||
input: record(event.input),
|
input: record(event.input),
|
||||||
provider: {
|
executed: tool.providerExecuted,
|
||||||
executed: tool.providerExecuted,
|
state,
|
||||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "tool-result": {
|
case "tool-result": {
|
||||||
|
retryEvidence = true
|
||||||
const tool = tools.get(event.id)
|
const tool = tools.get(event.id)
|
||||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
|
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
|
||||||
if (tool.name !== event.name)
|
if (tool.name !== event.name)
|
||||||
|
|
@ -331,11 +358,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
|
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
|
||||||
}
|
}
|
||||||
tool.settled = true
|
tool.settled = true
|
||||||
const result = settledOutput(event.output, event.result)
|
const result = error ? { error } : settledOutput(event.output, event.result)
|
||||||
const provider = {
|
const executed = event.providerExecuted === true || tool.providerExecuted
|
||||||
executed: event.providerExecuted === true || tool.providerExecuted,
|
const resultState = providerState(event.providerMetadata)
|
||||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
|
||||||
}
|
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|
@ -343,7 +368,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
result: event.result,
|
result: event.result,
|
||||||
provider,
|
executed,
|
||||||
|
resultState,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -353,12 +379,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
...result,
|
...result,
|
||||||
outputPaths,
|
outputPaths,
|
||||||
...(provider.executed ? { result: event.result } : {}),
|
...(executed ? { result: event.result } : {}),
|
||||||
provider,
|
executed,
|
||||||
|
resultState,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "tool-error": {
|
case "tool-error": {
|
||||||
|
retryEvidence = true
|
||||||
const tool = tools.get(event.id)
|
const tool = tools.get(event.id)
|
||||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`))
|
if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`))
|
||||||
if (tool.name !== event.name)
|
if (tool.name !== event.name)
|
||||||
|
|
@ -369,25 +397,30 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: tool.assistantMessageID,
|
assistantMessageID: tool.assistantMessageID,
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
error: { type: "unknown", message: event.message },
|
error:
|
||||||
provider: {
|
event.message === `Unknown tool: ${event.name}`
|
||||||
executed: tool.providerExecuted,
|
? { type: "tool.unknown", message: event.message }
|
||||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
: { type: "tool.execution", message: event.message },
|
||||||
},
|
executed: tool.providerExecuted,
|
||||||
|
resultState: providerState(event.providerMetadata),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "step-finish":
|
case "step-finish":
|
||||||
yield* flush()
|
yield* flush()
|
||||||
assistantActive = false
|
|
||||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||||
|
if (event.reason === "content-filter") {
|
||||||
|
providerFailed = true
|
||||||
|
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
||||||
return
|
return
|
||||||
case "finish":
|
case "finish":
|
||||||
return
|
return
|
||||||
case "provider-error":
|
case "provider-error":
|
||||||
providerFailed = true
|
providerFailed = true
|
||||||
yield* failAssistant(event.message)
|
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -396,10 +429,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
publish,
|
publish,
|
||||||
flush,
|
flush,
|
||||||
failAssistant,
|
failAssistant,
|
||||||
|
publishStepFailure,
|
||||||
failUnsettledTools,
|
failUnsettledTools,
|
||||||
hasActiveAssistant: () => assistantActive,
|
|
||||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
|
||||||
hasProviderError: () => providerFailed,
|
hasProviderError: () => providerFailed,
|
||||||
|
hasRetryEvidence: () => retryEvidence,
|
||||||
|
stepFailure: () => stepFailure,
|
||||||
stepSettlement: () => stepSettlement,
|
stepSettlement: () => stepSettlement,
|
||||||
startAssistant,
|
startAssistant,
|
||||||
assistantMessageID: assistantMessageIDForTool,
|
assistantMessageID: assistantMessageIDForTool,
|
||||||
|
|
|
||||||
67
packages/core/src/session/runner/retry.ts
Normal file
67
packages/core/src/session/runner/retry.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
export * as SessionRunnerRetry from "./retry"
|
||||||
|
|
||||||
|
import { LLMError } from "@opencode-ai/llm"
|
||||||
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
|
import { Data, Duration, Effect, Schedule } from "effect"
|
||||||
|
import { EventV2 } from "../../event"
|
||||||
|
import { SessionEvent } from "../event"
|
||||||
|
import { SessionMessage } from "../message"
|
||||||
|
import { SessionSchema } from "../schema"
|
||||||
|
import type { SessionRunner } from "./index"
|
||||||
|
|
||||||
|
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
|
||||||
|
readonly cause: LLMError
|
||||||
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
|
readonly error: SessionError.Error
|
||||||
|
readonly step: number
|
||||||
|
}> {}
|
||||||
|
|
||||||
|
export function isRetryable(error: LLMError) {
|
||||||
|
switch (error.reason._tag) {
|
||||||
|
case "RateLimit":
|
||||||
|
case "ProviderInternal":
|
||||||
|
case "Transport":
|
||||||
|
return true
|
||||||
|
case "Authentication":
|
||||||
|
case "QuotaExceeded":
|
||||||
|
case "ContentPolicy":
|
||||||
|
case "InvalidProviderOutput":
|
||||||
|
case "InvalidRequest":
|
||||||
|
case "NoRoute":
|
||||||
|
case "UnknownProvider":
|
||||||
|
return false
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = error.reason
|
||||||
|
return exhaustive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryAfter = (failure: RetryableFailure) => {
|
||||||
|
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
|
||||||
|
return failure.cause.reason.retryAfterMs
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
|
||||||
|
Schedule.exponential("2 seconds").pipe(
|
||||||
|
Schedule.take(4),
|
||||||
|
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
|
||||||
|
Schedule.passthrough,
|
||||||
|
Schedule.while(({ input }) => input instanceof RetryableFailure),
|
||||||
|
Schedule.modifyDelay((failure, delay) => {
|
||||||
|
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
|
||||||
|
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
|
||||||
|
}),
|
||||||
|
Schedule.tap((metadata) =>
|
||||||
|
metadata.input instanceof RetryableFailure
|
||||||
|
? events.publish(SessionEvent.RetryScheduled, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: metadata.input.assistantMessageID,
|
||||||
|
attempt: metadata.attempt + 1,
|
||||||
|
at: metadata.now + Duration.toMillis(metadata.duration),
|
||||||
|
error: metadata.input.error,
|
||||||
|
})
|
||||||
|
: Effect.void,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -43,6 +43,11 @@ const textAttachment = (file: FileAttachment) =>
|
||||||
|
|
||||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||||
|
|
||||||
|
const providerMetadata = (
|
||||||
|
provider: string,
|
||||||
|
state: Record<string, unknown> | undefined,
|
||||||
|
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
|
||||||
|
|
||||||
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
||||||
tool.state.status === "pending"
|
tool.state.status === "pending"
|
||||||
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
|
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
|
||||||
|
|
@ -53,7 +58,7 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
input: toolInput(tool),
|
input: toolInput(tool),
|
||||||
providerExecuted: tool.provider?.executed,
|
providerExecuted: tool.executed,
|
||||||
providerMetadata,
|
providerMetadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -62,14 +67,14 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
||||||
// TODO: Materialize remote and managed URIs before provider-history lowering.
|
// TODO: Materialize remote and managed URIs before provider-history lowering.
|
||||||
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
|
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
|
||||||
const result =
|
const result =
|
||||||
tool.provider?.executed === true && tool.state.result !== undefined
|
tool.executed === true && tool.state.result !== undefined
|
||||||
? tool.state.result
|
? tool.state.result
|
||||||
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
|
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
|
||||||
return ToolResultPart.make({
|
return ToolResultPart.make({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
result,
|
result,
|
||||||
providerExecuted: tool.provider?.executed,
|
providerExecuted: tool.executed,
|
||||||
providerMetadata,
|
providerMetadata,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -78,11 +83,11 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
result:
|
result:
|
||||||
tool.provider?.executed === true && tool.state.result !== undefined
|
tool.executed === true && tool.state.result !== undefined
|
||||||
? tool.state.result
|
? tool.state.result
|
||||||
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
|
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
|
||||||
resultType: "error",
|
resultType: "error",
|
||||||
providerExecuted: tool.provider?.executed,
|
providerExecuted: tool.executed,
|
||||||
providerMetadata,
|
providerMetadata,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -100,17 +105,22 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: item.text,
|
text: item.text,
|
||||||
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
|
providerMetadata: reuseProviderMetadata ? providerMetadata(model.providerID, item.state) : undefined,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: item.text.length > 0
|
: item.text.length > 0
|
||||||
? [{ type: "text", text: item.text }]
|
? [{ type: "text", text: item.text }]
|
||||||
: []
|
: []
|
||||||
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
|
const call = toolCall(
|
||||||
if (item.provider?.executed !== true) return [call]
|
item,
|
||||||
|
reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined,
|
||||||
|
)
|
||||||
|
if (item.executed !== true) return [call]
|
||||||
const result = toolResult(
|
const result = toolResult(
|
||||||
item,
|
item,
|
||||||
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
|
reuseProviderMetadata
|
||||||
|
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
|
||||||
|
: undefined,
|
||||||
)
|
)
|
||||||
return result ? [call, result] : [call]
|
return result ? [call, result] : [call]
|
||||||
})
|
})
|
||||||
|
|
@ -120,9 +130,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
||||||
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
|
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
|
||||||
})
|
})
|
||||||
const results = message.content
|
const results = message.content
|
||||||
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.executed !== true)
|
||||||
.map((item) =>
|
.map((item) =>
|
||||||
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
|
toolResult(
|
||||||
|
item,
|
||||||
|
reuseProviderMetadata
|
||||||
|
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.filter((message) => message !== undefined)
|
.filter((message) => message !== undefined)
|
||||||
.map(Message.tool)
|
.map(Message.tool)
|
||||||
|
|
@ -141,9 +156,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
|
||||||
case "user":
|
case "user":
|
||||||
const files = message.files ?? []
|
const files = message.files ?? []
|
||||||
return [
|
return [
|
||||||
...files
|
...files.filter((file) => file.mime === "text/plain").map(textAttachment),
|
||||||
.filter((file) => file.mime === "text/plain")
|
|
||||||
.map(textAttachment),
|
|
||||||
Message.make({
|
Message.make({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
|
||||||
55
packages/core/src/session/to-session-error.ts
Normal file
55
packages/core/src/session/to-session-error.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { LLMError, ToolFailure } from "@opencode-ai/llm"
|
||||||
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
|
import { PermissionV2 } from "../permission"
|
||||||
|
import { QuestionV2 } from "../question"
|
||||||
|
import { Integration } from "../integration"
|
||||||
|
import { ToolOutputStore } from "../tool-output-store"
|
||||||
|
import { StepFailedError, UserInterruptedError } from "./error"
|
||||||
|
import { SessionRunnerModel } from "./runner/model"
|
||||||
|
|
||||||
|
export function toSessionError(cause: unknown): SessionError.Error {
|
||||||
|
if (cause instanceof LLMError) {
|
||||||
|
switch (cause.reason._tag) {
|
||||||
|
case "RateLimit":
|
||||||
|
return { type: "provider.rate-limit", message: cause.reason.message }
|
||||||
|
case "Authentication":
|
||||||
|
return { type: "provider.auth", message: cause.reason.message }
|
||||||
|
case "QuotaExceeded":
|
||||||
|
return { type: "provider.quota", message: cause.reason.message }
|
||||||
|
case "ContentPolicy":
|
||||||
|
return { type: "provider.content-filter", message: cause.reason.message }
|
||||||
|
case "Transport":
|
||||||
|
return { type: "provider.transport", message: cause.reason.message }
|
||||||
|
case "ProviderInternal":
|
||||||
|
return { type: "provider.internal", message: cause.reason.message }
|
||||||
|
case "InvalidProviderOutput":
|
||||||
|
return { type: "provider.invalid-output", message: cause.reason.message }
|
||||||
|
case "InvalidRequest":
|
||||||
|
return { type: "provider.invalid-request", message: cause.reason.message }
|
||||||
|
case "NoRoute":
|
||||||
|
return { type: "provider.no-route", message: cause.reason.message }
|
||||||
|
case "UnknownProvider":
|
||||||
|
return { type: "provider.unknown", message: cause.reason.message }
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = cause.reason
|
||||||
|
return exhaustive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message }
|
||||||
|
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
|
||||||
|
if (cause instanceof ToolFailure)
|
||||||
|
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
|
||||||
|
if (cause instanceof StepFailedError) return cause.error
|
||||||
|
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
|
||||||
|
if (
|
||||||
|
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
|
||||||
|
cause instanceof SessionRunnerModel.ModelUnavailableError ||
|
||||||
|
cause instanceof SessionRunnerModel.VariantUnavailableError ||
|
||||||
|
cause instanceof SessionRunnerModel.UnsupportedPackageError
|
||||||
|
)
|
||||||
|
return { type: "provider.no-route", message: cause.message }
|
||||||
|
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||||
|
if (cause instanceof ToolOutputStore.StorageError) return { type: "unknown", message: cause.message }
|
||||||
|
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
||||||
|
}
|
||||||
|
|
@ -75,12 +75,12 @@ export const Plugin = {
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const applied: Array<typeof Applied.Type> = []
|
const applied: Array<typeof Applied.Type> = []
|
||||||
const fail = (path: string) => {
|
const fail = (path: string, error?: unknown) => {
|
||||||
const prefix =
|
const prefix =
|
||||||
applied.length === 0
|
applied.length === 0
|
||||||
? `Unable to apply patch at ${path}`
|
? `Unable to apply patch at ${path}`
|
||||||
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
|
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
|
||||||
return new ToolFailure({ message: prefix })
|
return new ToolFailure({ message: prefix, error })
|
||||||
}
|
}
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const source = {
|
const source = {
|
||||||
|
|
@ -152,7 +152,7 @@ export const Plugin = {
|
||||||
before,
|
before,
|
||||||
after: update.content,
|
after: update.content,
|
||||||
})
|
})
|
||||||
}).pipe(Effect.mapError(() => fail(hunk.path)))
|
}).pipe(Effect.mapError((error) => fail(hunk.path, error)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchFiles = prepared.map(patchFile)
|
const patchFiles = prepared.map(patchFile)
|
||||||
|
|
@ -182,11 +182,11 @@ export const Plugin = {
|
||||||
content: change.content,
|
content: change.content,
|
||||||
})
|
})
|
||||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||||
}).pipe(Effect.mapError(() => fail(change.path))),
|
}).pipe(Effect.mapError((error) => fail(change.path, error))),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
return { applied, files: patchFiles }
|
return { applied, files: patchFiles }
|
||||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
|
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))))
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
"edit",
|
"edit",
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,9 @@ export const Plugin = {
|
||||||
error instanceof FileMutation.StaleContentError
|
error instanceof FileMutation.StaleContentError
|
||||||
? new ToolFailure({
|
? new ToolFailure({
|
||||||
message: "File changed after permission approval. Read it again before editing.",
|
message: "File changed after permission approval. Read it again before editing.",
|
||||||
|
error,
|
||||||
})
|
})
|
||||||
: new ToolFailure({ message: `Unable to edit ${input.path}` }),
|
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ export const Plugin = {
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
error instanceof ToolFailure
|
error instanceof ToolFailure
|
||||||
? error
|
? error
|
||||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }),
|
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ export const Plugin = {
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
error instanceof ToolFailure
|
error instanceof ToolFailure
|
||||||
? error
|
? error
|
||||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }),
|
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export const Plugin = {
|
||||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
|
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
forms
|
forms
|
||||||
.ask({
|
.ask({
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export const Plugin = {
|
||||||
error instanceof Image.SizeError
|
error instanceof Image.SizeError
|
||||||
? error.message
|
? error.message
|
||||||
: `Unable to read ${input.path}`
|
: `Unable to read ${input.path}`
|
||||||
return new ToolFailure({ message })
|
return new ToolFailure({ message, error })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import { definition, permission, registrationEntries, RegistrationError, settle,
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
import { ToolHooks } from "./hooks"
|
import { ToolHooks } from "./hooks"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
|
import { toSessionError } from "../session/to-session-error"
|
||||||
|
|
||||||
export type ExecuteInput = {
|
export type ExecuteInput = {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
|
|
@ -45,6 +47,7 @@ export interface Settlement {
|
||||||
readonly result: ToolResultValue
|
readonly result: ToolResultValue
|
||||||
readonly output?: ToolOutput
|
readonly output?: ToolOutput
|
||||||
readonly outputPaths?: ReadonlyArray<string>
|
readonly outputPaths?: ReadonlyArray<string>
|
||||||
|
readonly error?: SessionError.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
||||||
|
|
@ -86,7 +89,10 @@ const registryLayer = Layer.effect(
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.map((output) => ({ output })),
|
Effect.map((output) => ({ output })),
|
||||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
Effect.succeed({
|
||||||
|
result: { type: "error" as const, value: failure.message },
|
||||||
|
error: toSessionError(failure),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
let settlement: Settlement
|
let settlement: Settlement
|
||||||
|
|
@ -124,20 +130,19 @@ const registryLayer = Layer.effect(
|
||||||
result: afterEvent.result,
|
result: afterEvent.result,
|
||||||
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
||||||
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
|
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
|
||||||
|
...(settlement.error !== undefined ? { error: settlement.error } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) {
|
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) {
|
||||||
const registration = local.get(input.call.name)?.at(-1)?.registration
|
const registration = local.get(input.call.name)?.at(-1)?.registration
|
||||||
if (!registration)
|
if (!registration || registration.identity !== advertised) {
|
||||||
|
const message = `Stale tool call: ${input.call.name}`
|
||||||
return {
|
return {
|
||||||
result: {
|
result: { type: "error" as const, value: message },
|
||||||
type: "error" as const,
|
error: { type: "tool.stale" as const, message },
|
||||||
value: `Stale tool call: ${input.call.name}`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
if (registration.identity !== advertised)
|
}
|
||||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
|
||||||
return yield* settleTool(input, registration.tool)
|
return yield* settleTool(input, registration.tool)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -215,7 +220,10 @@ const registryLayer = Layer.effect(
|
||||||
if (input.call.name === "execute" && execute) return settleTool(input, execute)
|
if (input.call.name === "execute" && execute) return settleTool(input, execute)
|
||||||
const registration = direct.get(input.call.name)
|
const registration = direct.get(input.call.name)
|
||||||
if (registration) return settleWith(input, registration.identity)
|
if (registration) return settleWith(input, registration.identity)
|
||||||
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
|
return Effect.succeed({
|
||||||
|
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
|
||||||
|
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,9 @@ export const Plugin = {
|
||||||
...(warnings.length ? { warnings } : {}),
|
...(warnings.length ? { warnings } : {}),
|
||||||
}
|
}
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })),
|
Effect.mapError(
|
||||||
|
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ export const Plugin = {
|
||||||
.get(context.sessionID)
|
.get(context.sessionID)
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }),
|
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const agent = yield* agents.resolve(input.agent)
|
const agent = yield* agents.resolve(input.agent)
|
||||||
|
|
@ -128,7 +128,7 @@ export const Plugin = {
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }),
|
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export const Plugin = {
|
||||||
})
|
})
|
||||||
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
|
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
|
||||||
return { todos: input.todos }
|
return { todos: input.todos }
|
||||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
|
}).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to update todos", error }))),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ export const Plugin = {
|
||||||
format: input.format,
|
format: input.format,
|
||||||
output,
|
output,
|
||||||
}
|
}
|
||||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
|
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,9 @@ export const Plugin = {
|
||||||
text: text ?? NO_RESULTS,
|
text: text ?? NO_RESULTS,
|
||||||
}
|
}
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })),
|
Effect.mapError(
|
||||||
|
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,9 @@ export const Plugin = {
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
|
}).pipe(
|
||||||
|
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
"edit",
|
"edit",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
|
||||||
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
||||||
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
||||||
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||||
|
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
|
||||||
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
|
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
|
@ -39,6 +40,29 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
||||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||||
|
|
||||||
describe("DatabaseMigration", () => {
|
describe("DatabaseMigration", () => {
|
||||||
|
test("resets incompatible V2 Session event history", async () => {
|
||||||
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* makeDb
|
||||||
|
yield* db.run(sql`CREATE TABLE session_input (id text PRIMARY KEY)`)
|
||||||
|
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY)`)
|
||||||
|
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY)`)
|
||||||
|
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||||
|
yield* db.run(sql`INSERT INTO session_input (id) VALUES ('input')`)
|
||||||
|
yield* db.run(sql`INSERT INTO session_message (id) VALUES ('message')`)
|
||||||
|
yield* db.run(sql`INSERT INTO event (id) VALUES ('event')`)
|
||||||
|
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 1)`)
|
||||||
|
|
||||||
|
yield* DatabaseMigration.applyOnly(db, [resetSessionEventsMigration])
|
||||||
|
|
||||||
|
expect(yield* db.get(sql`SELECT id FROM session_input`)).toBeUndefined()
|
||||||
|
expect(yield* db.get(sql`SELECT id FROM session_message`)).toBeUndefined()
|
||||||
|
expect(yield* db.get(sql`SELECT id FROM event`)).toBeUndefined()
|
||||||
|
expect(yield* db.get(sql`SELECT aggregate_id FROM event_sequence`)).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("serializes concurrent embedded initialization for one database path", async () => {
|
test("serializes concurrent embedded initialization for one database path", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const filename = path.join(tmp.path, "embedded.sqlite")
|
const filename = path.join(tmp.path, "embedded.sqlite")
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,9 @@ const it = testEffect(
|
||||||
describe("MCP errors", () => {
|
describe("MCP errors", () => {
|
||||||
test("expose useful messages", () => {
|
test("expose useful messages", () => {
|
||||||
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
|
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
|
||||||
expect(new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message).toBe(
|
expect(
|
||||||
"failed",
|
new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
|
||||||
)
|
).toBe("failed")
|
||||||
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
|
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
|
||||||
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
||||||
})
|
})
|
||||||
|
|
@ -231,7 +231,7 @@ it.effect("does not call MCP when permission is blocked", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
calls = 0
|
calls = 0
|
||||||
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
||||||
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [] }))
|
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
yield* waitForTool(registry, "execute")
|
yield* waitForTool(registry, "execute")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { DateTime, Effect, Layer, Stream } from "effect"
|
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
||||||
import { asc, eq } from "drizzle-orm"
|
import { asc, eq } from "drizzle-orm"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
|
@ -141,11 +141,30 @@ it.effect("does not count file attachments as text context", () =>
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test("compaction prompt requires the checkpoint headings in order", () => {
|
||||||
|
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
|
||||||
|
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
|
||||||
|
"## Objective",
|
||||||
|
"## Important Details",
|
||||||
|
"## Work State",
|
||||||
|
"## Next Move",
|
||||||
|
])
|
||||||
|
expect(prompt).toContain("one or two brief sentences")
|
||||||
|
expect(prompt).toContain("constraints/preferences, decisions and why")
|
||||||
|
expect(prompt).toContain("Completed:")
|
||||||
|
expect(prompt).toContain("Active:")
|
||||||
|
expect(prompt).toContain("Blocked:")
|
||||||
|
expect(prompt).toContain("immediate concrete action")
|
||||||
|
expect(prompt).toContain("next action if known")
|
||||||
|
expect(prompt).toContain("Keep every section, even when empty.")
|
||||||
|
})
|
||||||
|
|
||||||
it.effect("manual compaction summarizes short context instead of no-op", () =>
|
it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
requests = []
|
requests = []
|
||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const sessionID = SessionV2.ID.make("ses_manual_compaction")
|
const sessionID = SessionV2.ID.make("ses_manual_compaction")
|
||||||
const userMessage = {
|
const userMessage = {
|
||||||
|
|
@ -181,7 +200,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const delta = yield* events
|
||||||
|
.subscribe(SessionEvent.Compaction.Delta)
|
||||||
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
|
yield* Effect.yieldNow
|
||||||
expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true)
|
expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true)
|
||||||
|
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||||
|
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
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 short conversation.")
|
||||||
|
|
|
||||||
88
packages/core/test/session-error.test.ts
Normal file
88
packages/core/test/session-error.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
AuthenticationReason,
|
||||||
|
ContentPolicyReason,
|
||||||
|
InvalidProviderOutputReason,
|
||||||
|
InvalidRequestReason,
|
||||||
|
LLMError,
|
||||||
|
NoRouteReason,
|
||||||
|
ModelID,
|
||||||
|
ProviderID,
|
||||||
|
ProviderInternalReason,
|
||||||
|
QuotaExceededReason,
|
||||||
|
RateLimitReason,
|
||||||
|
TransportReason,
|
||||||
|
UnknownProviderReason,
|
||||||
|
ToolFailure,
|
||||||
|
} from "@opencode-ai/llm"
|
||||||
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
|
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||||
|
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||||
|
|
||||||
|
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
|
||||||
|
|
||||||
|
describe("toSessionError", () => {
|
||||||
|
test("maps every LLM reason to the open wire type", () => {
|
||||||
|
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
|
||||||
|
type: "provider.rate-limit",
|
||||||
|
message: "rate",
|
||||||
|
})
|
||||||
|
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
|
||||||
|
"provider.auth",
|
||||||
|
)
|
||||||
|
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
|
||||||
|
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
|
||||||
|
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
|
||||||
|
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
|
||||||
|
"provider.internal",
|
||||||
|
)
|
||||||
|
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
|
||||||
|
"provider.invalid-output",
|
||||||
|
)
|
||||||
|
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
|
||||||
|
expect(
|
||||||
|
toSessionError(
|
||||||
|
llm(
|
||||||
|
new NoRouteReason({
|
||||||
|
route: "route",
|
||||||
|
provider: ProviderID.make("provider"),
|
||||||
|
model: ModelID.make("model"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).type,
|
||||||
|
).toBe("provider.no-route")
|
||||||
|
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves the permission rejection type without exposing internal fields", () => {
|
||||||
|
const blocked = new PermissionV2.BlockedError({ rules: [], permission: "external_directory", resources: [] })
|
||||||
|
expect(toSessionError(blocked)).toEqual({
|
||||||
|
type: "permission.rejected",
|
||||||
|
message: "Permission denied: external_directory",
|
||||||
|
})
|
||||||
|
expect(toSessionError(new ToolFailure({ message: blocked.message, error: blocked }))).toEqual({
|
||||||
|
type: "permission.rejected",
|
||||||
|
message: "Permission denied: external_directory",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||||
|
const eligible = [
|
||||||
|
llm(new RateLimitReason({ message: "rate" })),
|
||||||
|
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
||||||
|
llm(new TransportReason({ message: "transport" })),
|
||||||
|
]
|
||||||
|
const ineligible = [
|
||||||
|
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
||||||
|
llm(new QuotaExceededReason({ message: "quota" })),
|
||||||
|
llm(new ContentPolicyReason({ message: "blocked" })),
|
||||||
|
llm(new InvalidProviderOutputReason({ message: "output" })),
|
||||||
|
llm(new InvalidRequestReason({ message: "request" })),
|
||||||
|
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
|
||||||
|
llm(new UnknownProviderReason({ message: "unknown" })),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||||
|
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||||
|
})
|
||||||
|
})
|
||||||
36
packages/core/test/session-execution-local.test.ts
Normal file
36
packages/core/test/session-execution-local.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { LLMError, TransportReason } from "@opencode-ai/llm"
|
||||||
|
import { terminal } from "@opencode-ai/core/session/execution/local"
|
||||||
|
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||||
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
|
import { Effect, Exit } from "effect"
|
||||||
|
|
||||||
|
describe("SessionExecutionLocal lifecycle", () => {
|
||||||
|
test("classifies success and typed failure terminals", () => {
|
||||||
|
expect(terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
|
||||||
|
expect(
|
||||||
|
terminal(
|
||||||
|
Exit.fail(
|
||||||
|
new LLMError({
|
||||||
|
module: "test",
|
||||||
|
method: "stream",
|
||||||
|
reason: new TransportReason({ message: "Disconnected" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
|
||||||
|
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
|
||||||
|
expect(terminal(Exit.fail(storage))).toEqual({
|
||||||
|
type: "failed",
|
||||||
|
error: { type: "unknown", message: storage.message },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("defaults owner-scope interruption to shutdown and preserves explicit reasons", () => {
|
||||||
|
const interrupted = Effect.runSyncExit(Effect.interrupt)
|
||||||
|
expect(terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" })
|
||||||
|
expect(terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" })
|
||||||
|
expect(terminal(interrupted, "superseded")).toEqual({ type: "interrupted", reason: "superseded" })
|
||||||
|
expect(terminal(Exit.fail(new UserInterruptedError()))).toEqual({ type: "interrupted", reason: "user" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -101,7 +101,7 @@ describe("SessionProjector", () => {
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.RevertEvent.Committed, {
|
yield* events.publish(SessionEvent.RevertEvent.Committed, {
|
||||||
sessionID,
|
sessionID,
|
||||||
messageID: boundary,
|
to: boundary,
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||||
|
|
@ -437,6 +437,73 @@ describe("SessionProjector", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
yield* db
|
||||||
|
.insert(ProjectTable)
|
||||||
|
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
yield* db
|
||||||
|
.insert(SessionTable)
|
||||||
|
.values({
|
||||||
|
id: sessionID,
|
||||||
|
project_id: Project.ID.global,
|
||||||
|
slug: "test",
|
||||||
|
directory: "/project",
|
||||||
|
title: "test",
|
||||||
|
version: "test",
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const first = SessionMessage.ID.make("msg_retry_first")
|
||||||
|
const second = SessionMessage.ID.make("msg_retry_second")
|
||||||
|
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model })
|
||||||
|
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: first,
|
||||||
|
attempt: 2,
|
||||||
|
at: 2_000,
|
||||||
|
error: { type: "provider.transport", message: "Disconnected" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
|
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type })
|
||||||
|
const firstRow = yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionMessageTable)
|
||||||
|
.where(eq(SessionMessageTable.id, first))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
const projected = firstRow ?? (yield* Effect.die(new Error("Missing retry projection")))
|
||||||
|
expect(decode(projected)).toMatchObject({
|
||||||
|
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model })
|
||||||
|
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: second,
|
||||||
|
attempt: 3,
|
||||||
|
at: 6_000,
|
||||||
|
error: { type: "provider.internal", message: "Unavailable" },
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
|
||||||
|
|
||||||
|
const rows = yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionMessageTable)
|
||||||
|
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||||
|
.orderBy(asc(SessionMessageTable.seq))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
expect(decode(rows[0])).not.toHaveProperty("retry")
|
||||||
|
expect(decode(rows[1])).not.toHaveProperty("retry")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("updates only the newest incomplete assistant projection", () =>
|
it.effect("updates only the newest incomplete assistant projection", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|
@ -530,7 +597,7 @@ describe("SessionProjector", () => {
|
||||||
yield* service.publish(SessionEvent.Text.Started, {
|
yield* service.publish(SessionEvent.Text.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
|
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
|
||||||
textID: "text-stale",
|
ordinal: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows = yield* db
|
const rows = yield* db
|
||||||
|
|
@ -549,7 +616,7 @@ describe("SessionProjector", () => {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model,
|
model,
|
||||||
content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })],
|
content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
|
||||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||||
}),
|
}),
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
|
|
|
||||||
|
|
@ -275,9 +275,11 @@ describe("SessionV2.prompt", () => {
|
||||||
source: { type: "uri", uri: sourceUri.href },
|
source: { type: "uri", uri: sourceUri.href },
|
||||||
name: "main.ts",
|
name: "main.ts",
|
||||||
})
|
})
|
||||||
expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8").replace(/\r$/, "")).toBe(
|
expect(
|
||||||
'import { describe, expect } from "bun:test"',
|
Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64")
|
||||||
)
|
.toString("utf8")
|
||||||
|
.replace(/\r$/, ""),
|
||||||
|
).toBe('import { describe, expect } from "bun:test"')
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -565,7 +567,12 @@ describe("SessionV2.prompt", () => {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Promote once" }), resume: false })
|
yield* session.prompt({
|
||||||
|
id: messageID,
|
||||||
|
sessionID,
|
||||||
|
prompt: PromptInput.Prompt.make({ text: "Promote once" }),
|
||||||
|
resume: false,
|
||||||
|
})
|
||||||
|
|
||||||
yield* Effect.all(
|
yield* Effect.all(
|
||||||
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
|
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
|
||||||
|
|
@ -677,7 +684,12 @@ describe("SessionV2.prompt", () => {
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
const failure = yield* session
|
const failure = yield* session
|
||||||
.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), resume: false })
|
.prompt({
|
||||||
|
id: messageID,
|
||||||
|
sessionID,
|
||||||
|
prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }),
|
||||||
|
resume: false,
|
||||||
|
})
|
||||||
.pipe(Effect.flip)
|
.pipe(Effect.flip)
|
||||||
|
|
||||||
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
|
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
|
||||||
|
|
|
||||||
|
|
@ -104,8 +104,10 @@ describe("SessionRunCoordinator", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const failure = new Error("failed")
|
const failure = new Error("failed")
|
||||||
const defect = new Error("defect")
|
const defect = new Error("defect")
|
||||||
|
const settled: Exit.Exit<void, Error>[] = []
|
||||||
const coordinator = yield* SessionRunCoordinator.make({
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)),
|
drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)),
|
||||||
|
settled: (_key, exit) => Effect.sync(() => void settled.push(exit)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const failed = yield* coordinator.run("failure").pipe(Effect.exit)
|
const failed = yield* coordinator.run("failure").pipe(Effect.exit)
|
||||||
|
|
@ -115,6 +117,25 @@ describe("SessionRunCoordinator", () => {
|
||||||
const died = yield* coordinator.run("defect").pipe(Effect.exit)
|
const died = yield* coordinator.run("defect").pipe(Effect.exit)
|
||||||
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
|
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
|
||||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||||
|
expect(settled).toHaveLength(2)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves settlement hook defects while releasing ownership", () =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const defect = new Error("terminal publication failed")
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
|
drain: () => Effect.void,
|
||||||
|
settled: () => Effect.die(defect),
|
||||||
|
})
|
||||||
|
|
||||||
|
const exit = yield* coordinator.run("session").pipe(Effect.exit)
|
||||||
|
|
||||||
|
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
|
||||||
|
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(defect)
|
||||||
|
expect(yield* coordinator.active).toEqual(new Set())
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -209,8 +230,41 @@ describe("SessionRunCoordinator", () => {
|
||||||
it.effect("does nothing when interrupted while idle", () =>
|
it.effect("does nothing when interrupted while idle", () =>
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void })
|
const reasons: Array<string | undefined> = []
|
||||||
yield* coordinator.interrupt("session")
|
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||||
|
drain: () => Effect.void,
|
||||||
|
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
|
||||||
|
})
|
||||||
|
yield* coordinator.interrupt("session", "user")
|
||||||
|
yield* coordinator.run("session")
|
||||||
|
expect(reasons).toEqual([undefined])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("does not attach a late interrupt reason after terminal settlement starts", () =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const settling = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const reasons: Array<string | undefined> = []
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||||
|
drain: () => Effect.void,
|
||||||
|
settled: (_key, _exit, reason) =>
|
||||||
|
Deferred.succeed(settling, undefined).pipe(
|
||||||
|
Effect.andThen(Deferred.await(release)),
|
||||||
|
Effect.andThen(Effect.sync(() => void reasons.push(reason))),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(settling)
|
||||||
|
yield* coordinator.interrupt("session", "user")
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
yield* Fiber.join(run)
|
||||||
|
yield* coordinator.run("session")
|
||||||
|
|
||||||
|
expect(reasons).toEqual([undefined, undefined])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -221,25 +275,28 @@ describe("SessionRunCoordinator", () => {
|
||||||
const started = yield* Deferred.make<void>()
|
const started = yield* Deferred.make<void>()
|
||||||
const interrupted = yield* Deferred.make<void>()
|
const interrupted = yield* Deferred.make<void>()
|
||||||
let runs = 0
|
let runs = 0
|
||||||
const coordinator = yield* SessionRunCoordinator.make({
|
const reasons: Array<string | undefined> = []
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||||
drain: () =>
|
drain: () =>
|
||||||
Effect.sync(() => ++runs).pipe(
|
Effect.sync(() => ++runs).pipe(
|
||||||
Effect.andThen(Deferred.succeed(started, undefined)),
|
Effect.andThen(Deferred.succeed(started, undefined)),
|
||||||
Effect.andThen(Effect.never),
|
Effect.andThen(Effect.never),
|
||||||
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||||
),
|
),
|
||||||
|
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
|
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||||
yield* Deferred.await(started)
|
yield* Deferred.await(started)
|
||||||
yield* coordinator.wake("session")
|
yield* coordinator.wake("session")
|
||||||
yield* coordinator.interrupt("session")
|
yield* coordinator.interrupt("session", "user")
|
||||||
yield* Deferred.await(interrupted)
|
yield* Deferred.await(interrupted)
|
||||||
|
|
||||||
const exit = yield* Fiber.await(resumed)
|
const exit = yield* Fiber.await(resumed)
|
||||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||||
expect(runs).toBe(1)
|
expect(runs).toBe(1)
|
||||||
|
expect(reasons).toEqual(["user"])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -252,6 +309,7 @@ describe("SessionRunCoordinator", () => {
|
||||||
const cleanupGate = yield* Deferred.make<void>()
|
const cleanupGate = yield* Deferred.make<void>()
|
||||||
const secondStarted = yield* Deferred.make<void>()
|
const secondStarted = yield* Deferred.make<void>()
|
||||||
let runs = 0
|
let runs = 0
|
||||||
|
let starts = 0
|
||||||
const coordinator = yield* SessionRunCoordinator.make({
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
drain: () =>
|
drain: () =>
|
||||||
Effect.sync(() => ++runs).pipe(
|
Effect.sync(() => ++runs).pipe(
|
||||||
|
|
@ -266,6 +324,7 @@ describe("SessionRunCoordinator", () => {
|
||||||
: Deferred.succeed(secondStarted, undefined),
|
: Deferred.succeed(secondStarted, undefined),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* coordinator.wake("session")
|
yield* coordinator.wake("session")
|
||||||
|
|
@ -278,6 +337,7 @@ describe("SessionRunCoordinator", () => {
|
||||||
yield* Deferred.await(secondStarted)
|
yield* Deferred.await(secondStarted)
|
||||||
|
|
||||||
expect(runs).toBe(2)
|
expect(runs).toBe(2)
|
||||||
|
expect(starts).toBe(2)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -399,6 +459,7 @@ describe("SessionRunCoordinator", () => {
|
||||||
const gate = yield* Deferred.make<void>()
|
const gate = yield* Deferred.make<void>()
|
||||||
const idle = yield* Deferred.make<void>()
|
const idle = yield* Deferred.make<void>()
|
||||||
let drains = 0
|
let drains = 0
|
||||||
|
let starts = 0
|
||||||
const settled: Exit.Exit<void, never>[] = []
|
const settled: Exit.Exit<void, never>[] = []
|
||||||
const coordinator = yield* SessionRunCoordinator.make<string, never>({
|
const coordinator = yield* SessionRunCoordinator.make<string, never>({
|
||||||
drain: () =>
|
drain: () =>
|
||||||
|
|
@ -410,6 +471,7 @@ describe("SessionRunCoordinator", () => {
|
||||||
),
|
),
|
||||||
Effect.asVoid,
|
Effect.asVoid,
|
||||||
),
|
),
|
||||||
|
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
|
||||||
settled: (_key, exit) =>
|
settled: (_key, exit) =>
|
||||||
Effect.sync(() => void settled.push(exit)).pipe(
|
Effect.sync(() => void settled.push(exit)).pipe(
|
||||||
Effect.andThen(Deferred.succeed(idle, undefined)),
|
Effect.andThen(Deferred.succeed(idle, undefined)),
|
||||||
|
|
@ -424,6 +486,7 @@ describe("SessionRunCoordinator", () => {
|
||||||
yield* Deferred.await(idle)
|
yield* Deferred.await(idle)
|
||||||
|
|
||||||
expect(drains).toBe(2)
|
expect(drains).toBe(2)
|
||||||
|
expect(starts).toBe(1)
|
||||||
expect(settled).toHaveLength(1)
|
expect(settled).toHaveLength(1)
|
||||||
expect(Exit.isSuccess(settled[0]!)).toBe(true)
|
expect(Exit.isSuccess(settled[0]!)).toBe(true)
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,14 @@ describe("toLLMMessages", () => {
|
||||||
const messages = toLLMMessages(
|
const messages = toLLMMessages(
|
||||||
[
|
[
|
||||||
assistant("empty", []),
|
assistant("empty", []),
|
||||||
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]),
|
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", text: "" })]),
|
||||||
assistant("empty-reasoning", [
|
assistant("empty-reasoning", [SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "" })]),
|
||||||
SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }),
|
assistant("text", [SessionMessage.AssistantText.make({ type: "text", text: "Partial" })]),
|
||||||
]),
|
|
||||||
assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]),
|
|
||||||
assistant("reasoning", [
|
assistant("reasoning", [
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "reasoning",
|
|
||||||
text: "",
|
text: "",
|
||||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
state: { signature: "sig_1" },
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
|
@ -258,12 +255,11 @@ Recent work
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
content: [
|
content: [
|
||||||
SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }),
|
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "reasoning-1",
|
|
||||||
text: "Think",
|
text: "Think",
|
||||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
state: { signature: "sig_1" },
|
||||||
}),
|
}),
|
||||||
SessionMessage.AssistantTool.make({
|
SessionMessage.AssistantTool.make({
|
||||||
type: "tool",
|
type: "tool",
|
||||||
|
|
@ -308,11 +304,9 @@ Recent work
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: "hosted",
|
id: "hosted",
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
provider: {
|
executed: true,
|
||||||
executed: true,
|
providerState: { continuation: "hosted-call" },
|
||||||
metadata: { fake: { continuation: "hosted-call" } },
|
providerResultState: { continuation: "hosted-result" },
|
||||||
resultMetadata: { fake: { continuation: "hosted-result" } },
|
|
||||||
},
|
|
||||||
state: SessionMessage.ToolStateCompleted.make({
|
state: SessionMessage.ToolStateCompleted.make({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: { query: "Effect" },
|
input: { query: "Effect" },
|
||||||
|
|
@ -325,7 +319,8 @@ Recent work
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: "hosted-failed",
|
id: "hosted-failed",
|
||||||
name: "write",
|
name: "write",
|
||||||
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
|
executed: true,
|
||||||
|
providerState: { continuation: "failed" },
|
||||||
state: SessionMessage.ToolStateError.make({
|
state: SessionMessage.ToolStateError.make({
|
||||||
status: "error",
|
status: "error",
|
||||||
input: { path: "README.md" },
|
input: { path: "README.md" },
|
||||||
|
|
@ -345,7 +340,7 @@ Recent work
|
||||||
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
|
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
|
||||||
expect(messages[0]?.content).toEqual([
|
expect(messages[0]?.content).toEqual([
|
||||||
{ type: "text", text: "Checking" },
|
{ type: "text", text: "Checking" },
|
||||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
{ type: "reasoning", text: "Think", providerMetadata: { provider: { signature: "sig_1" } } },
|
||||||
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
|
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
|
||||||
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
|
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
|
||||||
{
|
{
|
||||||
|
|
@ -360,14 +355,14 @@ Recent work
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
input: { query: "Effect" },
|
input: { query: "Effect" },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: { fake: { continuation: "hosted-call" } },
|
providerMetadata: { provider: { continuation: "hosted-call" } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
id: "hosted",
|
id: "hosted",
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: { fake: { continuation: "hosted-result" } },
|
providerMetadata: { provider: { continuation: "hosted-result" } },
|
||||||
result: { type: "text", value: "Found it" },
|
result: { type: "text", value: "Found it" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -376,14 +371,14 @@ Recent work
|
||||||
name: "write",
|
name: "write",
|
||||||
input: { path: "README.md" },
|
input: { path: "README.md" },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: { fake: { continuation: "failed" } },
|
providerMetadata: { provider: { continuation: "failed" } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
id: "hosted-failed",
|
id: "hosted-failed",
|
||||||
name: "write",
|
name: "write",
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: { fake: { continuation: "failed" } },
|
providerMetadata: { provider: { continuation: "failed" } },
|
||||||
result: {
|
result: {
|
||||||
type: "error",
|
type: "error",
|
||||||
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
||||||
|
|
@ -417,9 +412,8 @@ Recent work
|
||||||
content: [
|
content: [
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "reasoning-openai",
|
|
||||||
text: "Think",
|
text: "Think",
|
||||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
time: { created, completed: created },
|
time: { created, completed: created },
|
||||||
|
|
@ -432,7 +426,7 @@ Recent work
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: "Think",
|
text: "Think",
|
||||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
@ -448,19 +442,16 @@ Recent work
|
||||||
content: [
|
content: [
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "reasoning-failed",
|
|
||||||
text: "Partial thought",
|
text: "Partial thought",
|
||||||
providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } },
|
state: { itemId: "rs_failed", reasoningEncryptedContent: null },
|
||||||
}),
|
}),
|
||||||
SessionMessage.AssistantTool.make({
|
SessionMessage.AssistantTool.make({
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: "hosted-failed",
|
id: "hosted-failed",
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
provider: {
|
executed: true,
|
||||||
executed: true,
|
providerState: { itemId: "call_failed" },
|
||||||
metadata: { openai: { itemId: "call_failed" } },
|
providerResultState: { itemId: "result_failed" },
|
||||||
resultMetadata: { openai: { itemId: "result_failed" } },
|
|
||||||
},
|
|
||||||
state: SessionMessage.ToolStateError.make({
|
state: SessionMessage.ToolStateError.make({
|
||||||
status: "error",
|
status: "error",
|
||||||
input: { query: "Effect" },
|
input: { query: "Effect" },
|
||||||
|
|
@ -520,19 +511,16 @@ Recent work
|
||||||
content: [
|
content: [
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "reasoning-old-model",
|
|
||||||
text: "Visible thought",
|
text: "Visible thought",
|
||||||
providerMetadata: { anthropic: { signature: "sig_old" } },
|
state: { signature: "sig_old" },
|
||||||
}),
|
}),
|
||||||
SessionMessage.AssistantTool.make({
|
SessionMessage.AssistantTool.make({
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: "hosted-old-model",
|
id: "hosted-old-model",
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
provider: {
|
executed: true,
|
||||||
executed: true,
|
providerState: { itemId: "hosted-old-model" },
|
||||||
metadata: { openai: { itemId: "hosted-old-model" } },
|
providerResultState: { itemId: "hosted-old-model" },
|
||||||
resultMetadata: { openai: { itemId: "hosted-old-model" } },
|
|
||||||
},
|
|
||||||
state: SessionMessage.ToolStateCompleted.make({
|
state: SessionMessage.ToolStateCompleted.make({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: { query: "Effect" },
|
input: { query: "Effect" },
|
||||||
|
|
@ -546,11 +534,9 @@ Recent work
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: "local-old-model",
|
id: "local-old-model",
|
||||||
name: "read",
|
name: "read",
|
||||||
provider: {
|
executed: false,
|
||||||
executed: false,
|
providerState: { call: "old" },
|
||||||
metadata: { fake: { call: "old" } },
|
providerResultState: { result: "old" },
|
||||||
resultMetadata: { fake: { result: "old" } },
|
|
||||||
},
|
|
||||||
state: SessionMessage.ToolStateCompleted.make({
|
state: SessionMessage.ToolStateCompleted.make({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: { path: "README.md" },
|
input: { path: "README.md" },
|
||||||
|
|
@ -620,9 +606,8 @@ Recent work
|
||||||
content: [
|
content: [
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "reasoning-alias",
|
|
||||||
text: "Visible thought",
|
text: "Visible thought",
|
||||||
providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } },
|
state: { reasoningEncryptedContent: "encrypted" },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
time: { created, completed: created },
|
time: { created, completed: created },
|
||||||
|
|
@ -635,7 +620,7 @@ Recent work
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: "Visible thought",
|
text: "Visible thought",
|
||||||
providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } },
|
providerMetadata: { provider: { reasoningEncryptedContent: "encrypted" } },
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ const capture = () => {
|
||||||
id: ModelV2.ID.make("model"),
|
id: ModelV2.ID.make("model"),
|
||||||
providerID: ProviderV2.ID.make("provider"),
|
providerID: ProviderV2.ID.make("provider"),
|
||||||
},
|
},
|
||||||
|
provider: "openai",
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +89,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("provider-executed success retains its compatibility result", async () => {
|
test("provider-executed success retains its raw provider result", async () => {
|
||||||
const { published, publisher } = capture()
|
const { published, publisher } = capture()
|
||||||
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
|
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
|
||||||
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
|
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
|
||||||
|
|
@ -96,6 +97,19 @@ test("provider-executed success retains its compatibility result", async () => {
|
||||||
expect(success?.data).toHaveProperty("result")
|
expect(success?.data).toHaveProperty("result")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("provider state uses the route provider instead of the catalog provider", async () => {
|
||||||
|
const { published, publisher } = capture()
|
||||||
|
await Effect.runPromise(
|
||||||
|
publisher.publish(
|
||||||
|
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: "reasoning" } } }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
|
||||||
|
state: { itemId: "reasoning" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("binary failure emits no success event", async () => {
|
test("binary failure emits no success event", async () => {
|
||||||
const { published, publisher } = capture()
|
const { published, publisher } = capture()
|
||||||
await Effect.runPromise(publisher.publish(call))
|
await Effect.runPromise(publisher.publish(call))
|
||||||
|
|
@ -112,7 +126,7 @@ test("binary failure emits no success event", async () => {
|
||||||
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
|
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("old success event data containing result still decodes", () => {
|
test("success event data can carry a provider-executed result", () => {
|
||||||
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
|
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: SessionMessage.ID.create(),
|
assistantMessageID: SessionMessage.ID.create(),
|
||||||
|
|
@ -120,7 +134,7 @@ test("old success event data containing result still decodes", () => {
|
||||||
structured: { type: "media", mime: "image/png" },
|
structured: { type: "media", mime: "image/png" },
|
||||||
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
|
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
|
||||||
result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
|
result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
|
||||||
provider: { executed: false },
|
executed: true,
|
||||||
})
|
})
|
||||||
expect(decoded.result).toMatchObject({ type: "content" })
|
expect(decoded.result).toMatchObject({ type: "content" })
|
||||||
})
|
})
|
||||||
|
|
@ -133,3 +147,40 @@ test("step finish records settlement without publishing step ended", async () =>
|
||||||
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
||||||
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("content-filter finish retains failure evidence until step closeout", async () => {
|
||||||
|
const { published, publisher } = capture()
|
||||||
|
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||||
|
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "content-filter" })))
|
||||||
|
|
||||||
|
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
|
||||||
|
await Effect.runPromise(publisher.publishStepFailure())
|
||||||
|
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
|
||||||
|
expect(published.at(-1)?.data).toMatchObject({
|
||||||
|
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||||
|
})
|
||||||
|
expect(publisher.stepSettlement()).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => {
|
||||||
|
const { published, publisher } = capture()
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.forEach(
|
||||||
|
[
|
||||||
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
|
LLMEvent.textStart({ id: "text" }),
|
||||||
|
LLMEvent.textDelta({ id: "text", text: "Partial" }),
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
|
||||||
|
],
|
||||||
|
(event) => publisher.publish(event),
|
||||||
|
{ discard: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Effect.runPromise(publisher.publishStepFailure())
|
||||||
|
|
||||||
|
expect(published.some((event) => event.type === "session.step.ended.1")).toBe(false)
|
||||||
|
expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Partial" })
|
||||||
|
expect(published.find((event) => event.type === "session.step.failed.1")?.data).toMatchObject({
|
||||||
|
error: { type: "provider.content-filter" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -76,9 +76,8 @@ describe("Tool.Progress", () => {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
callID,
|
callID,
|
||||||
tool: "bash",
|
|
||||||
input: { command: "pwd" },
|
input: { command: "pwd" },
|
||||||
provider: { executed: false },
|
executed: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -104,7 +103,7 @@ describe("Tool.Progress", () => {
|
||||||
callID: "call-success",
|
callID: "call-success",
|
||||||
structured: { phase: "done" },
|
structured: { phase: "done" },
|
||||||
content: content("complete"),
|
content: content("complete"),
|
||||||
provider: { executed: false },
|
executed: false,
|
||||||
})
|
})
|
||||||
expect((yield* readAssistant).content[0]).toMatchObject({
|
expect((yield* readAssistant).content[0]).toMatchObject({
|
||||||
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
|
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
|
||||||
|
|
@ -123,7 +122,7 @@ describe("Tool.Progress", () => {
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
callID: "call-failed",
|
callID: "call-failed",
|
||||||
error: { type: "unknown", message: "boom" },
|
error: { type: "unknown", message: "boom" },
|
||||||
provider: { executed: false },
|
executed: false,
|
||||||
})
|
})
|
||||||
expect((yield* readAssistant).content[1]).toMatchObject({
|
expect((yield* readAssistant).content[1]).toMatchObject({
|
||||||
state: {
|
state: {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||||
[coreIntegration.Ref, Integration.Ref],
|
[coreIntegration.Ref, Integration.Ref],
|
||||||
[coreLocation.Ref, Location.Ref],
|
[coreLocation.Ref, Location.Ref],
|
||||||
[coreLLM.ProviderMetadata, LLM.ProviderMetadata],
|
[coreLLM.ProviderMetadata, LLM.ProviderMetadata],
|
||||||
|
[coreLLM.FinishReason, LLM.FinishReason],
|
||||||
[coreLLM.ToolTextContent, LLM.ToolTextContent],
|
[coreLLM.ToolTextContent, LLM.ToolTextContent],
|
||||||
[coreLLM.ToolFileContent, LLM.ToolFileContent],
|
[coreLLM.ToolFileContent, LLM.ToolFileContent],
|
||||||
[coreLLM.ToolContent, LLM.ToolContent],
|
[coreLLM.ToolContent, LLM.ToolContent],
|
||||||
|
|
@ -137,7 +138,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||||
[coreSessionInput.Delivery, SessionInput.Delivery],
|
[coreSessionInput.Delivery, SessionInput.Delivery],
|
||||||
[coreSessionInput.Admitted, SessionInput.Admitted],
|
[coreSessionInput.Admitted, SessionInput.Admitted],
|
||||||
[coreSessionMessage.ID, SessionMessage.ID],
|
[coreSessionMessage.ID, SessionMessage.ID],
|
||||||
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
|
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
|
||||||
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
|
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
|
||||||
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
|
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
|
||||||
[coreSessionMessage.User, SessionMessage.User],
|
[coreSessionMessage.User, SessionMessage.User],
|
||||||
|
|
@ -183,7 +184,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||||
test("shared record schemas construct and decode plain objects", () => {
|
test("shared record schemas construct and decode plain objects", () => {
|
||||||
const made = Prompt.make({ text: "hello" })
|
const made = Prompt.make({ text: "hello" })
|
||||||
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
||||||
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" })
|
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", text: "hi" })
|
||||||
|
|
||||||
expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
|
||||||
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,15 @@ const permission = Layer.succeed(
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
|
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
|
input.action === denyAction
|
||||||
|
? Effect.fail(
|
||||||
|
new PermissionV2.BlockedError({
|
||||||
|
rules: [],
|
||||||
|
permission: input.action,
|
||||||
|
resources: input.resources,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Effect.void,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ask: () => Effect.die("unused"),
|
ask: () => Effect.die("unused"),
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,15 @@ const permission = Layer.succeed(
|
||||||
assert: (input) =>
|
assert: (input) =>
|
||||||
Effect.sync(() => assertions.push(input)).pipe(
|
Effect.sync(() => assertions.push(input)).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
|
input.action === denyAction
|
||||||
|
? Effect.fail(
|
||||||
|
new PermissionV2.BlockedError({
|
||||||
|
rules: [],
|
||||||
|
permission: input.action,
|
||||||
|
resources: input.resources,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Effect.void,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ask: () => Effect.die("unused"),
|
ask: () => Effect.die("unused"),
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,17 @@ const permission = Layer.succeed(
|
||||||
PermissionV2.Service.of({
|
PermissionV2.Service.of({
|
||||||
assert: (input) =>
|
assert: (input) =>
|
||||||
Effect.sync(() => assertions.push(input)).pipe(
|
Effect.sync(() => assertions.push(input)).pipe(
|
||||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
|
Effect.andThen(
|
||||||
|
deny
|
||||||
|
? Effect.fail(
|
||||||
|
new PermissionV2.BlockedError({
|
||||||
|
rules: [],
|
||||||
|
permission: input.action,
|
||||||
|
resources: input.resources,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Effect.void,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ask: () => Effect.die("unused"),
|
ask: () => Effect.die("unused"),
|
||||||
reply: () => Effect.die("unused"),
|
reply: () => Effect.die("unused"),
|
||||||
|
|
@ -82,7 +92,13 @@ describe("QuestionTool", () => {
|
||||||
...toolIdentity,
|
...toolIdentity,
|
||||||
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
|
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
|
||||||
}),
|
}),
|
||||||
).toEqual({ result: { type: "error", value: "Permission denied: question" } })
|
).toEqual({
|
||||||
|
result: { type: "error", value: "Permission denied: question" },
|
||||||
|
error: {
|
||||||
|
type: "permission.rejected",
|
||||||
|
message: "Permission denied: question",
|
||||||
|
},
|
||||||
|
})
|
||||||
expect(capturedInput()).toBeUndefined()
|
expect(capturedInput()).toBeUndefined()
|
||||||
deny = false
|
deny = false
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,19 @@ const permission = Layer.succeed(
|
||||||
assert: (input) =>
|
assert: (input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
assertions.push(input)
|
assertions.push(input)
|
||||||
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.BlockedError({ rules: [] })))),
|
}).pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
allow
|
||||||
|
? Effect.void
|
||||||
|
: Effect.fail(
|
||||||
|
new PermissionV2.BlockedError({
|
||||||
|
rules: [],
|
||||||
|
permission: input.action,
|
||||||
|
resources: input.resources,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
ask: () => Effect.die("unused"),
|
ask: () => Effect.die("unused"),
|
||||||
reply: () => Effect.die("unused"),
|
reply: () => Effect.die("unused"),
|
||||||
get: () => Effect.die("unused"),
|
get: () => Effect.die("unused"),
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,15 @@ const permission = Layer.succeed(
|
||||||
Effect.sync(() => assertions.push(input)).pipe(
|
Effect.sync(() => assertions.push(input)).pipe(
|
||||||
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
|
input.action === denyAction
|
||||||
|
? Effect.fail(
|
||||||
|
new PermissionV2.BlockedError({
|
||||||
|
rules: [],
|
||||||
|
permission: input.action,
|
||||||
|
resources: input.resources,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Effect.void,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ask: () => Effect.die("unused"),
|
ask: () => Effect.die("unused"),
|
||||||
|
|
@ -75,7 +83,6 @@ const executionNode = makeGlobalNode({
|
||||||
const session = yield* store.get(id)
|
const session = yield* store.get(id)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
const assistantMessageID = SessionMessage.ID.create()
|
const assistantMessageID = SessionMessage.ID.create()
|
||||||
const textID = "text_shell_test"
|
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
sessionID: id,
|
sessionID: id,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
|
|
@ -85,12 +92,12 @@ const executionNode = makeGlobalNode({
|
||||||
yield* events.publish(SessionEvent.Text.Started, {
|
yield* events.publish(SessionEvent.Text.Started, {
|
||||||
sessionID: id,
|
sessionID: id,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
textID,
|
ordinal: 0,
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Text.Ended, {
|
yield* events.publish(SessionEvent.Text.Ended, {
|
||||||
sessionID: id,
|
sessionID: id,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
textID,
|
ordinal: 0,
|
||||||
text: "ok",
|
text: "ok",
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Step.Ended, {
|
yield* events.publish(SessionEvent.Step.Ended, {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,17 @@ describe("SkillTool", () => {
|
||||||
PermissionV2.Service.of({
|
PermissionV2.Service.of({
|
||||||
assert: (input) =>
|
assert: (input) =>
|
||||||
Effect.sync(() => assertions.push(input)).pipe(
|
Effect.sync(() => assertions.push(input)).pipe(
|
||||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
|
Effect.andThen(
|
||||||
|
deny
|
||||||
|
? Effect.fail(
|
||||||
|
new PermissionV2.BlockedError({
|
||||||
|
rules: [],
|
||||||
|
permission: input.action,
|
||||||
|
resources: input.resources,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Effect.void,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ask: () => Effect.die("unused"),
|
ask: () => Effect.die("unused"),
|
||||||
reply: () => Effect.die("unused"),
|
reply: () => Effect.die("unused"),
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ const executionNode = makeGlobalNode({
|
||||||
}
|
}
|
||||||
completed.add(sessionID)
|
completed.add(sessionID)
|
||||||
const assistantMessageID = SessionMessage.ID.create()
|
const assistantMessageID = SessionMessage.ID.create()
|
||||||
const textID = "text_subagent_test"
|
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
|
|
@ -59,12 +58,12 @@ const executionNode = makeGlobalNode({
|
||||||
yield* events.publish(SessionEvent.Text.Started, {
|
yield* events.publish(SessionEvent.Text.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
textID,
|
ordinal: 0,
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Text.Ended, {
|
yield* events.publish(SessionEvent.Text.Ended, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
textID,
|
ordinal: 0,
|
||||||
text: childText,
|
text: childText,
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Step.Ended, {
|
yield* events.publish(SessionEvent.Step.Ended, {
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,17 @@ const permission = Layer.succeed(
|
||||||
PermissionV2.Service.of({
|
PermissionV2.Service.of({
|
||||||
assert: (input) =>
|
assert: (input) =>
|
||||||
Effect.sync(() => assertions.push(input)).pipe(
|
Effect.sync(() => assertions.push(input)).pipe(
|
||||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
|
Effect.andThen(
|
||||||
|
deny
|
||||||
|
? Effect.fail(
|
||||||
|
new PermissionV2.BlockedError({
|
||||||
|
rules: [],
|
||||||
|
permission: input.action,
|
||||||
|
resources: input.resources,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Effect.void,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ask: () => Effect.die("unused"),
|
ask: () => Effect.die("unused"),
|
||||||
reply: () => Effect.die("unused"),
|
reply: () => Effect.die("unused"),
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,15 @@ const permission = Layer.succeed(
|
||||||
assert: (input) =>
|
assert: (input) =>
|
||||||
Effect.sync(() => assertions.push(input)).pipe(
|
Effect.sync(() => assertions.push(input)).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
|
input.action === denyAction
|
||||||
|
? Effect.fail(
|
||||||
|
new PermissionV2.BlockedError({
|
||||||
|
rules: [],
|
||||||
|
permission: input.action,
|
||||||
|
resources: input.resources,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Effect.void,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ask: () => Effect.die("unused"),
|
ask: () => Effect.die("unused"),
|
||||||
|
|
|
||||||
|
|
@ -619,6 +619,11 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
|
const events: LLMEvent[] = []
|
||||||
|
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
|
||||||
|
}
|
||||||
|
|
||||||
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
if (!event.delta) return [state, NO_EVENTS]
|
if (!event.delta) return [state, NO_EVENTS]
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
|
|
@ -810,6 +815,8 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||||
const item = event.item
|
const item = event.item
|
||||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||||
|
|
||||||
|
if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id })
|
||||||
|
|
||||||
if (item.type === "function_call") {
|
if (item.type === "function_call") {
|
||||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||||
const tools = state.tools[item.id]
|
const tools = state.tools[item.id]
|
||||||
|
|
@ -920,6 +927,7 @@ const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult =>
|
||||||
|
|
||||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
||||||
|
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
|
||||||
if (
|
if (
|
||||||
event.type === "response.reasoning_text.delta" ||
|
event.type === "response.reasoning_text.delta" ||
|
||||||
event.type === "response.reasoning_summary.delta" ||
|
event.type === "response.reasoning_summary.delta" ||
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { ProviderMetadata } from "@opencode-ai/schema/llm"
|
import { LLM, ProviderMetadata } from "@opencode-ai/schema/llm"
|
||||||
|
|
||||||
export { ProviderMetadata }
|
export { ProviderMetadata }
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ export type TextVerbosity = Schema.Schema.Type<typeof TextVerbosity>
|
||||||
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
|
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
|
||||||
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
|
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
|
||||||
|
|
||||||
export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"])
|
export const FinishReason = LLM.FinishReason
|
||||||
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
|
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
|
||||||
|
|
||||||
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
|
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||||
|
|
|
||||||
|
|
@ -764,6 +764,35 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OpenAI's documented stream orders output text within one message item; no
|
||||||
|
// provider-valid same-kind overlap is evidenced, so done boundaries close it.
|
||||||
|
it.effect("closes sequential output messages before starting the next", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
|
||||||
|
{ type: "response.output_text.done", item_id: "msg_1" },
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
|
||||||
|
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||||
|
{ type: "text-start", id: "msg_1" },
|
||||||
|
{ type: "text-delta", id: "msg_1", text: "First" },
|
||||||
|
{ type: "text-end", id: "msg_1" },
|
||||||
|
{ type: "text-start", id: "msg_2" },
|
||||||
|
{ type: "text-delta", id: "msg_2", text: "Second" },
|
||||||
|
{ type: "text-end", id: "msg_2" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("parses reasoning summary stream fixtures", () =>
|
it.effect("parses reasoning summary stream fixtures", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,20 @@ function prompted(inputID: string): V2Event {
|
||||||
}
|
}
|
||||||
|
|
||||||
function settled(outcome: "success" | "interrupted" = "success"): V2Event {
|
function settled(outcome: "success" | "interrupted" = "success"): V2Event {
|
||||||
|
if (outcome === "interrupted")
|
||||||
|
return {
|
||||||
|
id: "evt_interrupted",
|
||||||
|
created: 0,
|
||||||
|
type: "session.execution.interrupted",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||||
|
data: { sessionID: "ses_1", reason: "user" },
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: "evt_settled",
|
id: "evt_succeeded",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: { sessionID: "ses_1", outcome },
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||||
|
data: { sessionID: "ses_1" },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,8 +67,7 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?:
|
||||||
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
|
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
|
||||||
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
|
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
|
||||||
spyOn(sdk.form, "list").mockImplementation(
|
spyOn(sdk.form, "list").mockImplementation(
|
||||||
(request) =>
|
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
|
||||||
ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
|
|
||||||
)
|
)
|
||||||
spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
|
spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
|
||||||
spyOn(sdk.session, "prompt").mockImplementation((request) => {
|
spyOn(sdk.session, "prompt").mockImplementation((request) => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "node:url"
|
import { pathToFileURL } from "node:url"
|
||||||
import { OpenCode, type EventSubscribeOutput, type MessageListOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import {
|
||||||
|
OpenCode,
|
||||||
|
type EventSubscribeOutput,
|
||||||
|
type MessageListOutput,
|
||||||
|
type OpenCodeClient,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
import { createSessionTransport } from "@opencode-ai/cli/mini/stream-v2.transport"
|
import { createSessionTransport } from "@opencode-ai/cli/mini/stream-v2.transport"
|
||||||
import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types"
|
import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
|
|
@ -200,15 +205,16 @@ describe("V2 mini transport", () => {
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
assistantMessageID: "msg_assistant",
|
assistantMessageID: "msg_assistant",
|
||||||
textID: "txt_1",
|
ordinal: 0,
|
||||||
delta: "answer",
|
delta: "answer",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_settled",
|
id: "evt_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: { sessionID: "ses_1", outcome: "success" },
|
durable: durable("ses_1"),
|
||||||
|
data: { sessionID: "ses_1" },
|
||||||
})
|
})
|
||||||
await turn
|
await turn
|
||||||
|
|
||||||
|
|
@ -253,8 +259,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_settled",
|
id: "evt_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: { sessionID: "ses_1", outcome: "success" },
|
durable: durable("ses_1"),
|
||||||
|
data: { sessionID: "ses_1" },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return ok({
|
return ok({
|
||||||
|
|
@ -347,8 +354,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_settled",
|
id: "evt_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: { sessionID: "ses_1", outcome: "success" },
|
durable: durable("ses_1"),
|
||||||
|
data: { sessionID: "ses_1" },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return ok({
|
return ok({
|
||||||
|
|
@ -444,8 +452,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_settled",
|
id: "evt_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: { sessionID: "ses_1", outcome: "success" },
|
durable: durable("ses_1"),
|
||||||
|
data: { sessionID: "ses_1" },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return ok({
|
return ok({
|
||||||
|
|
@ -693,7 +702,7 @@ describe("V2 mini transport", () => {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: { providerID: "test", id: "model" },
|
model: { providerID: "test", id: "model" },
|
||||||
content: [{ type: "text", id: "txt_1", text: "the answer" }],
|
content: [{ type: "text", text: "the answer" }],
|
||||||
time: { created: 2, completed: 3 },
|
time: { created: 2, completed: 3 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -705,6 +714,17 @@ describe("V2 mini transport", () => {
|
||||||
reset = resolve
|
reset = resolve
|
||||||
})
|
})
|
||||||
const replay = transport.replayOnResize({ localRows: () => [], reset: () => resetting })
|
const replay = transport.replayOnResize({ localRows: () => [], reset: () => resetting })
|
||||||
|
events.push({
|
||||||
|
id: "evt_text_started",
|
||||||
|
created: 0,
|
||||||
|
type: "session.text.started",
|
||||||
|
durable: durable("ses_1"),
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_assistant",
|
||||||
|
ordinal: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_text",
|
id: "evt_text",
|
||||||
created: 0,
|
created: 0,
|
||||||
|
|
@ -712,7 +732,7 @@ describe("V2 mini transport", () => {
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
assistantMessageID: "msg_assistant",
|
assistantMessageID: "msg_assistant",
|
||||||
textID: "txt_1",
|
ordinal: 0,
|
||||||
delta: "answer",
|
delta: "answer",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -725,7 +745,7 @@ describe("V2 mini transport", () => {
|
||||||
await transport.close()
|
await transport.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("scopes repeated text and reasoning ids by assistant message", async () => {
|
test("scopes text and reasoning ordinals by assistant message", async () => {
|
||||||
const events = feed()
|
const events = feed()
|
||||||
events.push(connected())
|
events.push(connected())
|
||||||
const client = sdk({ streams: [events] })
|
const client = sdk({ streams: [events] })
|
||||||
|
|
@ -738,8 +758,8 @@ describe("V2 mini transport", () => {
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: { providerID: "test", id: "model" },
|
model: { providerID: "test", id: "model" },
|
||||||
content: [
|
content: [
|
||||||
{ type: "reasoning", id: "reasoning-0", text: "second thought" },
|
{ type: "reasoning", text: "second thought" },
|
||||||
{ type: "text", id: "text-0", text: "second answer" },
|
{ type: "text", text: "second answer" },
|
||||||
],
|
],
|
||||||
time: { created: 4, completed: 5 },
|
time: { created: 4, completed: 5 },
|
||||||
},
|
},
|
||||||
|
|
@ -749,8 +769,8 @@ describe("V2 mini transport", () => {
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: { providerID: "test", id: "model" },
|
model: { providerID: "test", id: "model" },
|
||||||
content: [
|
content: [
|
||||||
{ type: "reasoning", id: "reasoning-0", text: "first thought" },
|
{ type: "reasoning", text: "first thought" },
|
||||||
{ type: "text", id: "text-0", text: "first answer" },
|
{ type: "text", text: "first answer" },
|
||||||
],
|
],
|
||||||
time: { created: 2, completed: 3 },
|
time: { created: 2, completed: 3 },
|
||||||
},
|
},
|
||||||
|
|
@ -798,7 +818,7 @@ describe("V2 mini transport", () => {
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
assistantMessageID: "msg_assistant",
|
assistantMessageID: "msg_assistant",
|
||||||
reasoningID: "reasoning_1",
|
ordinal: 0,
|
||||||
text: "considering",
|
text: "considering",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -808,6 +828,52 @@ describe("V2 mini transport", () => {
|
||||||
await transport.close()
|
await transport.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("renders a live tool start when the call begins", async () => {
|
||||||
|
const events = feed()
|
||||||
|
events.push(connected())
|
||||||
|
const client = sdk({ streams: [events] })
|
||||||
|
const ui = footer()
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: client,
|
||||||
|
sessionID: "ses_1",
|
||||||
|
thinking: false,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
id: "evt_tool_input",
|
||||||
|
created: 1,
|
||||||
|
type: "session.tool.input.started",
|
||||||
|
durable: durable("ses_1"),
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_assistant",
|
||||||
|
callID: "call_read",
|
||||||
|
name: "read",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
events.push({
|
||||||
|
id: "evt_tool_called",
|
||||||
|
created: 2,
|
||||||
|
type: "session.tool.called",
|
||||||
|
durable: durable("ses_1", 1),
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_assistant",
|
||||||
|
callID: "call_read",
|
||||||
|
input: { path: "README.md" },
|
||||||
|
executed: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Bun.sleep(0)
|
||||||
|
|
||||||
|
expect(ui.commits).toContainEqual(
|
||||||
|
expect.objectContaining({ kind: "tool", phase: "start", partID: "prt_call_read", tool: "read" }),
|
||||||
|
)
|
||||||
|
await transport.close()
|
||||||
|
})
|
||||||
|
|
||||||
test("resolves an interrupted turn even when promotion never arrived", async () => {
|
test("resolves an interrupted turn even when promotion never arrived", async () => {
|
||||||
const events = feed()
|
const events = feed()
|
||||||
events.push(connected())
|
events.push(connected())
|
||||||
|
|
@ -849,8 +915,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_settled",
|
id: "evt_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.interrupted",
|
||||||
data: { sessionID: "ses_1", outcome: "success" },
|
durable: durable("ses_1"),
|
||||||
|
data: { sessionID: "ses_1", reason: "user" },
|
||||||
})
|
})
|
||||||
await turn
|
await turn
|
||||||
|
|
||||||
|
|
@ -913,8 +980,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_settled",
|
id: "evt_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: { sessionID: "ses_1", outcome: "success" },
|
durable: durable("ses_1"),
|
||||||
|
data: { sessionID: "ses_1" },
|
||||||
})
|
})
|
||||||
await turn
|
await turn
|
||||||
|
|
||||||
|
|
@ -975,8 +1043,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_settled",
|
id: "evt_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.interrupted",
|
||||||
data: { sessionID: "ses_1", outcome: "success" },
|
durable: durable("ses_1"),
|
||||||
|
data: { sessionID: "ses_1", reason: "user" },
|
||||||
})
|
})
|
||||||
await turn
|
await turn
|
||||||
|
|
||||||
|
|
@ -1413,8 +1482,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_settled",
|
id: "evt_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: { sessionID: "ses_1", outcome: "success" },
|
durable: durable("ses_1"),
|
||||||
|
data: { sessionID: "ses_1" },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return ok({
|
return ok({
|
||||||
|
|
@ -1488,8 +1558,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_settled",
|
id: "evt_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: { sessionID: "ses_1", outcome: "success" },
|
durable: durable("ses_1"),
|
||||||
|
data: { sessionID: "ses_1" },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return ok(undefined) as never
|
return ok(undefined) as never
|
||||||
|
|
@ -1569,8 +1640,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_unrelated_settled",
|
id: "evt_unrelated_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: { sessionID: "ses_1", outcome: "success" },
|
durable: durable("ses_1"),
|
||||||
|
data: { sessionID: "ses_1" },
|
||||||
})
|
})
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
|
|
@ -1590,8 +1662,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_skill_settled",
|
id: "evt_skill_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: { sessionID: "ses_1", outcome: "success" },
|
durable: durable("ses_1"),
|
||||||
|
data: { sessionID: "ses_1" },
|
||||||
})
|
})
|
||||||
await turn
|
await turn
|
||||||
|
|
||||||
|
|
@ -1701,18 +1774,19 @@ describe("V2 mini transport", () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
spyOn(client.session, "get").mockImplementation(() =>
|
spyOn(client.session, "get").mockImplementation(
|
||||||
ok({
|
() =>
|
||||||
id: "ses_child",
|
ok({
|
||||||
parentID: "ses_1",
|
id: "ses_child",
|
||||||
projectID: "proj_1",
|
parentID: "ses_1",
|
||||||
agent: "explore",
|
projectID: "proj_1",
|
||||||
cost: 0,
|
agent: "explore",
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
cost: 0,
|
||||||
time: { created: 1, updated: 1 },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
title: "Find files",
|
time: { created: 1, updated: 1 },
|
||||||
location: { directory: "/tmp" },
|
title: "Find files",
|
||||||
}) as never,
|
location: { directory: "/tmp" },
|
||||||
|
}) as never,
|
||||||
)
|
)
|
||||||
const ui = footer()
|
const ui = footer()
|
||||||
const transport = await createSessionTransport({
|
const transport = await createSessionTransport({
|
||||||
|
|
@ -1750,7 +1824,7 @@ describe("V2 mini transport", () => {
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_child",
|
sessionID: "ses_child",
|
||||||
assistantMessageID: "msg_child_a",
|
assistantMessageID: "msg_child_a",
|
||||||
textID: "txt_child",
|
ordinal: 0,
|
||||||
delta: "child answer",
|
delta: "child answer",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -1760,8 +1834,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_child_settled",
|
id: "evt_child_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: { sessionID: "ses_child", outcome: "success" },
|
durable: durable("ses_child"),
|
||||||
|
data: { sessionID: "ses_child" },
|
||||||
})
|
})
|
||||||
while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0)
|
while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0)
|
||||||
await transport.close()
|
await transport.close()
|
||||||
|
|
@ -1801,7 +1876,9 @@ describe("V2 mini transport", () => {
|
||||||
})
|
})
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
expect(
|
expect(
|
||||||
states().at(-1)?.details.ses_child?.commits.some((item) => item.messageID === "msg_child_prompt"),
|
states()
|
||||||
|
.at(-1)
|
||||||
|
?.details.ses_child?.commits.some((item) => item.messageID === "msg_child_prompt"),
|
||||||
).toBe(false)
|
).toBe(false)
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
|
|
@ -1955,19 +2032,36 @@ describe("V2 mini transport", () => {
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_child",
|
sessionID: "ses_child",
|
||||||
assistantMessageID: "msg_overflow_assistant",
|
assistantMessageID: "msg_overflow_assistant",
|
||||||
textID: `txt_overflow_${index}`,
|
ordinal: index,
|
||||||
delta: `live ${index}`,
|
delta: `live ${index}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
while (!states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")) await Bun.sleep(0)
|
while (
|
||||||
|
!states()
|
||||||
|
.at(-1)
|
||||||
|
?.details.ses_child?.commits.some((item) => item.text === "live 64")
|
||||||
|
)
|
||||||
|
await Bun.sleep(0)
|
||||||
releaseStale()
|
releaseStale()
|
||||||
while (childRequests < 2) await Bun.sleep(0)
|
while (childRequests < 2) await Bun.sleep(0)
|
||||||
expect(states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")).toBe(true)
|
expect(
|
||||||
|
states()
|
||||||
|
.at(-1)
|
||||||
|
?.details.ses_child?.commits.some((item) => item.text === "live 64"),
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
releaseRetry()
|
releaseRetry()
|
||||||
while (!states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "baseline history"))
|
while (
|
||||||
|
!states()
|
||||||
|
.at(-1)
|
||||||
|
?.details.ses_child?.commits.some((item) => item.text === "baseline history")
|
||||||
|
)
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
expect(states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")).toBe(true)
|
expect(
|
||||||
|
states()
|
||||||
|
.at(-1)
|
||||||
|
?.details.ses_child?.commits.some((item) => item.text === "live 64"),
|
||||||
|
).toBe(true)
|
||||||
expect(childRequests).toBe(2)
|
expect(childRequests).toBe(2)
|
||||||
await transport.close()
|
await transport.close()
|
||||||
})
|
})
|
||||||
|
|
@ -2032,7 +2126,7 @@ describe("V2 mini transport", () => {
|
||||||
durable: durable("ses_child", seq),
|
durable: durable("ses_child", seq),
|
||||||
data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID, name },
|
data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID, name },
|
||||||
})
|
})
|
||||||
const called = (callID: string, tool: string, input: Record<string, unknown>, seq: number) =>
|
const called = (callID: string, input: Record<string, unknown>, seq: number) =>
|
||||||
events.push({
|
events.push({
|
||||||
id: `evt_called_${callID}`,
|
id: `evt_called_${callID}`,
|
||||||
created: seq,
|
created: seq,
|
||||||
|
|
@ -2042,14 +2136,13 @@ describe("V2 mini transport", () => {
|
||||||
sessionID: "ses_child",
|
sessionID: "ses_child",
|
||||||
assistantMessageID: "msg_tool_projected",
|
assistantMessageID: "msg_tool_projected",
|
||||||
callID,
|
callID,
|
||||||
tool,
|
|
||||||
input,
|
input,
|
||||||
provider: { executed: true },
|
executed: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
inputStarted("call_terminal", "grep", 0)
|
inputStarted("call_terminal", "grep", 0)
|
||||||
called("call_terminal", "grep", { pattern: "needle" }, 1)
|
called("call_terminal", { pattern: "needle" }, 1)
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
transport.selectSubagent("ses_child")
|
transport.selectSubagent("ses_child")
|
||||||
while (!childHydrating) await Bun.sleep(0)
|
while (!childHydrating) await Bun.sleep(0)
|
||||||
|
|
@ -2064,11 +2157,11 @@ describe("V2 mini transport", () => {
|
||||||
callID: "call_terminal",
|
callID: "call_terminal",
|
||||||
structured: {},
|
structured: {},
|
||||||
content: [{ type: "text", text: "found" }],
|
content: [{ type: "text", text: "found" }],
|
||||||
provider: { executed: true },
|
executed: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
inputStarted("call_overlap", "bash", 3)
|
inputStarted("call_overlap", "bash", 3)
|
||||||
called("call_overlap", "bash", { command: "stale" }, 4)
|
called("call_overlap", { command: "stale" }, 4)
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
const beforeHydration = states().length
|
const beforeHydration = states().length
|
||||||
releaseHydration()
|
releaseHydration()
|
||||||
|
|
@ -2137,8 +2230,9 @@ describe("V2 mini transport", () => {
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_child_settled",
|
id: "evt_child_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.interrupted",
|
||||||
data: { sessionID: "ses_child", outcome: "interrupted" },
|
durable: durable("ses_child"),
|
||||||
|
data: { sessionID: "ses_child", reason: "user" },
|
||||||
})
|
})
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
resolveGet?.()
|
resolveGet?.()
|
||||||
|
|
@ -2192,6 +2286,18 @@ describe("V2 mini transport", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
// Parent's background subagent tool.success adopts the child mid-discovery.
|
// Parent's background subagent tool.success adopts the child mid-discovery.
|
||||||
|
events.push({
|
||||||
|
id: "evt_parent_input",
|
||||||
|
created: 0,
|
||||||
|
type: "session.tool.input.started",
|
||||||
|
durable: durable("ses_1"),
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_parent_a",
|
||||||
|
callID: "call_sub",
|
||||||
|
name: "subagent",
|
||||||
|
},
|
||||||
|
})
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_parent_call",
|
id: "evt_parent_call",
|
||||||
created: 0,
|
created: 0,
|
||||||
|
|
@ -2201,9 +2307,8 @@ describe("V2 mini transport", () => {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
assistantMessageID: "msg_parent_a",
|
assistantMessageID: "msg_parent_a",
|
||||||
callID: "call_sub",
|
callID: "call_sub",
|
||||||
tool: "subagent",
|
|
||||||
input: { agent: "explore", description: "Find things", prompt: "go", background: true },
|
input: { agent: "explore", description: "Find things", prompt: "go", background: true },
|
||||||
provider: { executed: true },
|
executed: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
events.push({
|
events.push({
|
||||||
|
|
@ -2217,15 +2322,16 @@ describe("V2 mini transport", () => {
|
||||||
callID: "call_sub",
|
callID: "call_sub",
|
||||||
structured: { sessionID: "ses_child", status: "running", output: "" },
|
structured: { sessionID: "ses_child", status: "running", output: "" },
|
||||||
content: [],
|
content: [],
|
||||||
provider: { executed: true },
|
executed: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
// The settled event arrives after adoption, so it applies directly.
|
// The settled event arrives after adoption, so it applies directly.
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_child_settled",
|
id: "evt_child_settled",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.interrupted",
|
||||||
data: { sessionID: "ses_child", outcome: "interrupted" },
|
durable: durable("ses_child"),
|
||||||
|
data: { sessionID: "ses_child", reason: "shutdown" },
|
||||||
})
|
})
|
||||||
while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0)
|
while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ test.skip("text ended populates assistant text content", () => {
|
||||||
data: {
|
data: {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
textID: "text-1",
|
ordinal: 0,
|
||||||
},
|
},
|
||||||
} satisfies SessionEvent.Event),
|
} satisfies SessionEvent.Event),
|
||||||
)
|
)
|
||||||
|
|
@ -115,7 +115,7 @@ test.skip("text ended populates assistant text content", () => {
|
||||||
data: {
|
data: {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
textID: "text-1",
|
ordinal: 0,
|
||||||
text: "hello assistant",
|
text: "hello assistant",
|
||||||
},
|
},
|
||||||
} satisfies SessionEvent.Event),
|
} satisfies SessionEvent.Event),
|
||||||
|
|
@ -123,7 +123,7 @@ test.skip("text ended populates assistant text content", () => {
|
||||||
|
|
||||||
expect(state.messages[0]?.type).toBe("assistant")
|
expect(state.messages[0]?.type).toBe("assistant")
|
||||||
if (state.messages[0]?.type !== "assistant") return
|
if (state.messages[0]?.type !== "assistant") return
|
||||||
expect(state.messages[0].content).toEqual([{ type: "text", id: "text-1", text: "hello assistant" }])
|
expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test.skip("tool completion stores completed timestamp", () => {
|
test.skip("tool completion stores completed timestamp", () => {
|
||||||
|
|
@ -176,9 +176,9 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
callID,
|
callID,
|
||||||
tool: "bash",
|
|
||||||
input: { command: "pwd" },
|
input: { command: "pwd" },
|
||||||
provider: { executed: true, metadata: { fake: { source: "provider" } } },
|
executed: true,
|
||||||
|
state: { source: "provider" },
|
||||||
},
|
},
|
||||||
} satisfies SessionEvent.Event),
|
} satisfies SessionEvent.Event),
|
||||||
)
|
)
|
||||||
|
|
@ -195,7 +195,8 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||||
callID,
|
callID,
|
||||||
structured: {},
|
structured: {},
|
||||||
content: [{ type: "text", text: "/tmp" }],
|
content: [{ type: "text", text: "/tmp" }],
|
||||||
provider: { executed: true, metadata: { fake: { status: "done" } } },
|
executed: true,
|
||||||
|
resultState: { status: "done" },
|
||||||
},
|
},
|
||||||
} satisfies SessionEvent.Event),
|
} satisfies SessionEvent.Event),
|
||||||
)
|
)
|
||||||
|
|
@ -205,7 +206,11 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||||
expect(state.messages[0].content[0]?.type).toBe("tool")
|
expect(state.messages[0].content[0]?.type).toBe("tool")
|
||||||
if (state.messages[0].content[0]?.type !== "tool") return
|
if (state.messages[0].content[0]?.type !== "tool") return
|
||||||
expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4))
|
expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4))
|
||||||
expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { fake: { status: "done" } } })
|
expect(state.messages[0].content[0]).toMatchObject({
|
||||||
|
executed: true,
|
||||||
|
providerState: { source: "provider" },
|
||||||
|
providerResultState: { status: "done" },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("compaction events reduce to compaction message only when completed", () => {
|
test("compaction events reduce to compaction message only when completed", () => {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export { Revert } from "./revert.js"
|
||||||
export { Session } from "./session.js"
|
export { Session } from "./session.js"
|
||||||
export { Vcs } from "./vcs.js"
|
export { Vcs } from "./vcs.js"
|
||||||
export { SessionInput } from "./session-input.js"
|
export { SessionInput } from "./session-input.js"
|
||||||
|
export { SessionError } from "./session-error.js"
|
||||||
export { SessionMessage } from "./session-message.js"
|
export { SessionMessage } from "./session-message.js"
|
||||||
export { Shell } from "./shell.js"
|
export { Shell } from "./shell.js"
|
||||||
export { Skill } from "./skill.js"
|
export { Skill } from "./skill.js"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schem
|
||||||
})
|
})
|
||||||
export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
|
export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
|
||||||
|
|
||||||
|
export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"])
|
||||||
|
export type FinishReason = typeof FinishReason.Type
|
||||||
|
|
||||||
export interface ToolTextContent extends Schema.Schema.Type<typeof ToolTextContent> {}
|
export interface ToolTextContent extends Schema.Schema.Type<typeof ToolTextContent> {}
|
||||||
export const ToolTextContent = Schema.Struct({
|
export const ToolTextContent = Schema.Struct({
|
||||||
type: Schema.Literal("text"),
|
type: Schema.Literal("text"),
|
||||||
|
|
|
||||||
9
packages/schema/src/session-error.ts
Normal file
9
packages/schema/src/session-error.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export * as SessionError from "./session-error.js"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
|
export interface Error extends Schema.Schema.Type<typeof Error> {}
|
||||||
|
export const Error = Schema.Struct({
|
||||||
|
type: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
}).annotate({ identifier: "Session.StructuredError" })
|
||||||
|
|
@ -3,16 +3,18 @@ export * as SessionEvent from "./session-event.js"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional } from "./schema.js"
|
import { optional } from "./schema.js"
|
||||||
import { Event } from "./event.js"
|
import { Event } from "./event.js"
|
||||||
import { ProviderMetadata, ToolContent } from "./llm.js"
|
import { ToolContent } from "./llm.js"
|
||||||
|
import { FinishReason } from "./llm.js"
|
||||||
import { Delivery } from "./session-delivery.js"
|
import { Delivery } from "./session-delivery.js"
|
||||||
import { Model } from "./model.js"
|
import { Model } from "./model.js"
|
||||||
import { NonNegativeInt, RelativePath } from "./schema.js"
|
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
|
||||||
import { FileAttachment, Prompt } from "./prompt.js"
|
import { FileAttachment, Prompt } from "./prompt.js"
|
||||||
import { SessionID } from "./session-id.js"
|
import { SessionID } from "./session-id.js"
|
||||||
import { Location } from "./location.js"
|
import { Location } from "./location.js"
|
||||||
import { SessionMessage } from "./session-message.js"
|
import { SessionMessage } from "./session-message.js"
|
||||||
import { Revert } from "./revert.js"
|
import { Revert } from "./revert.js"
|
||||||
import { Shell as ShellSchema } from "./shell.js"
|
import { Shell as ShellSchema } from "./shell.js"
|
||||||
|
import { SessionError } from "./session-error.js"
|
||||||
|
|
||||||
export { FileAttachment }
|
export { FileAttachment }
|
||||||
|
|
||||||
|
|
@ -41,16 +43,6 @@ const options = {
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
const stepSettlementOptions = {
|
|
||||||
durable: {
|
|
||||||
aggregate: "sessionID",
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const UnknownError = SessionMessage.UnknownError
|
|
||||||
export type UnknownError = SessionMessage.UnknownError
|
|
||||||
|
|
||||||
export const AgentSelected = Event.durable({
|
export const AgentSelected = Event.durable({
|
||||||
type: "session.agent.selected",
|
type: "session.agent.selected",
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -120,15 +112,27 @@ export const PromptAdmitted = Event.durable({
|
||||||
})
|
})
|
||||||
export type PromptAdmitted = typeof PromptAdmitted.Type
|
export type PromptAdmitted = typeof PromptAdmitted.Type
|
||||||
|
|
||||||
export const ExecutionSettled = Event.ephemeral({
|
export namespace Execution {
|
||||||
type: "session.execution.settled",
|
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
|
||||||
schema: {
|
export type Started = typeof Started.Type
|
||||||
...Base,
|
|
||||||
outcome: Schema.Literals(["success", "failure", "interrupted"]),
|
export const Succeeded = Event.durable({ type: "session.execution.succeeded", ...options, schema: Base })
|
||||||
error: UnknownError.pipe(optional),
|
export type Succeeded = typeof Succeeded.Type
|
||||||
},
|
|
||||||
})
|
export const Failed = Event.durable({
|
||||||
export type ExecutionSettled = typeof ExecutionSettled.Type
|
type: "session.execution.failed",
|
||||||
|
...options,
|
||||||
|
schema: { ...Base, error: SessionError.Error },
|
||||||
|
})
|
||||||
|
export type Failed = typeof Failed.Type
|
||||||
|
|
||||||
|
export const Interrupted = Event.durable({
|
||||||
|
type: "session.execution.interrupted",
|
||||||
|
...options,
|
||||||
|
schema: { ...Base, reason: Schema.Literals(["user", "shutdown", "superseded"]) },
|
||||||
|
})
|
||||||
|
export type Interrupted = typeof Interrupted.Type
|
||||||
|
}
|
||||||
|
|
||||||
export const InstructionsUpdated = Event.durable({
|
export const InstructionsUpdated = Event.durable({
|
||||||
type: "session.instructions.updated",
|
type: "session.instructions.updated",
|
||||||
|
|
@ -204,11 +208,11 @@ export namespace Step {
|
||||||
|
|
||||||
export const Ended = Event.durable({
|
export const Ended = Event.durable({
|
||||||
type: "session.step.ended",
|
type: "session.step.ended",
|
||||||
...stepSettlementOptions,
|
...options,
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
finish: Schema.String,
|
finish: FinishReason,
|
||||||
cost: Schema.Finite,
|
cost: Schema.Finite,
|
||||||
tokens: Schema.Struct({
|
tokens: Schema.Struct({
|
||||||
input: Schema.Finite,
|
input: Schema.Finite,
|
||||||
|
|
@ -227,11 +231,11 @@ export namespace Step {
|
||||||
|
|
||||||
export const Failed = Event.durable({
|
export const Failed = Event.durable({
|
||||||
type: "session.step.failed",
|
type: "session.step.failed",
|
||||||
...stepSettlementOptions,
|
...options,
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
error: UnknownError,
|
error: SessionError.Error,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Failed = typeof Failed.Type
|
export type Failed = typeof Failed.Type
|
||||||
|
|
@ -244,7 +248,7 @@ export namespace Text {
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
textID: Schema.String,
|
ordinal: NonNegativeInt,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Started = typeof Started.Type
|
export type Started = typeof Started.Type
|
||||||
|
|
@ -255,7 +259,7 @@ export namespace Text {
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
textID: Schema.String,
|
ordinal: NonNegativeInt,
|
||||||
delta: Schema.String,
|
delta: Schema.String,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -267,7 +271,7 @@ export namespace Text {
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
textID: Schema.String,
|
ordinal: NonNegativeInt,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -281,8 +285,8 @@ export namespace Reasoning {
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
reasoningID: Schema.String,
|
ordinal: NonNegativeInt,
|
||||||
providerMetadata: ProviderMetadata.pipe(optional),
|
state: SessionMessage.ProviderState.pipe(optional),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Started = typeof Started.Type
|
export type Started = typeof Started.Type
|
||||||
|
|
@ -293,7 +297,7 @@ export namespace Reasoning {
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
reasoningID: Schema.String,
|
ordinal: NonNegativeInt,
|
||||||
delta: Schema.String,
|
delta: Schema.String,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -305,9 +309,9 @@ export namespace Reasoning {
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
reasoningID: Schema.String,
|
ordinal: NonNegativeInt,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
providerMetadata: ProviderMetadata.pipe(optional),
|
state: SessionMessage.ProviderState.pipe(optional),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Ended = typeof Ended.Type
|
export type Ended = typeof Ended.Type
|
||||||
|
|
@ -357,12 +361,9 @@ export namespace Tool {
|
||||||
...options,
|
...options,
|
||||||
schema: {
|
schema: {
|
||||||
...ToolBase,
|
...ToolBase,
|
||||||
tool: Schema.String,
|
|
||||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
provider: Schema.Struct({
|
executed: Schema.Boolean,
|
||||||
executed: Schema.Boolean,
|
state: SessionMessage.ProviderState.pipe(optional),
|
||||||
metadata: ProviderMetadata.pipe(optional),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Called = typeof Called.Type
|
export type Called = typeof Called.Type
|
||||||
|
|
@ -391,10 +392,8 @@ export namespace Tool {
|
||||||
content: Schema.Array(ToolContent),
|
content: Schema.Array(ToolContent),
|
||||||
outputPaths: Schema.Array(Schema.String).pipe(optional),
|
outputPaths: Schema.Array(Schema.String).pipe(optional),
|
||||||
result: Schema.Unknown.pipe(optional),
|
result: Schema.Unknown.pipe(optional),
|
||||||
provider: Schema.Struct({
|
executed: Schema.Boolean,
|
||||||
executed: Schema.Boolean,
|
resultState: SessionMessage.ProviderState.pipe(optional),
|
||||||
metadata: ProviderMetadata.pipe(optional),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Success = typeof Success.Type
|
export type Success = typeof Success.Type
|
||||||
|
|
@ -404,39 +403,27 @@ export namespace Tool {
|
||||||
...options,
|
...options,
|
||||||
schema: {
|
schema: {
|
||||||
...ToolBase,
|
...ToolBase,
|
||||||
error: UnknownError,
|
error: SessionError.Error,
|
||||||
result: Schema.Unknown.pipe(optional),
|
result: Schema.Unknown.pipe(optional),
|
||||||
provider: Schema.Struct({
|
executed: Schema.Boolean,
|
||||||
executed: Schema.Boolean,
|
resultState: SessionMessage.ProviderState.pipe(optional),
|
||||||
metadata: ProviderMetadata.pipe(optional),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Failed = typeof Failed.Type
|
export type Failed = typeof Failed.Type
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RetryError = Schema.Struct({
|
export const RetryScheduled = Event.durable({
|
||||||
message: Schema.String,
|
type: "session.retry.scheduled",
|
||||||
statusCode: Schema.Finite.pipe(optional),
|
|
||||||
isRetryable: Schema.Boolean,
|
|
||||||
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
|
||||||
responseBody: Schema.String.pipe(optional),
|
|
||||||
metadata: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
|
||||||
}).annotate({
|
|
||||||
identifier: "session.retry.error",
|
|
||||||
})
|
|
||||||
export interface RetryError extends Schema.Schema.Type<typeof RetryError> {}
|
|
||||||
|
|
||||||
export const Retried = Event.durable({
|
|
||||||
type: "session.retried",
|
|
||||||
...options,
|
...options,
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
attempt: Schema.Finite,
|
assistantMessageID: SessionMessage.ID,
|
||||||
error: RetryError,
|
attempt: PositiveInt,
|
||||||
|
at: NonNegativeInt,
|
||||||
|
error: SessionError.Error,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Retried = typeof Retried.Type
|
export type RetryScheduled = typeof RetryScheduled.Type
|
||||||
|
|
||||||
export namespace Compaction {
|
export namespace Compaction {
|
||||||
export const Started = Event.durable({
|
export const Started = Event.durable({
|
||||||
|
|
@ -481,7 +468,7 @@ export namespace RevertEvent {
|
||||||
export const Committed = Event.durable({
|
export const Committed = Event.durable({
|
||||||
type: "session.revert.committed",
|
type: "session.revert.committed",
|
||||||
...options,
|
...options,
|
||||||
schema: { ...Base, messageID: SessionMessage.ID },
|
schema: { ...Base, to: SessionMessage.ID },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -493,7 +480,10 @@ export const Definitions = Event.inventory(
|
||||||
Forked,
|
Forked,
|
||||||
PromptPromoted,
|
PromptPromoted,
|
||||||
PromptAdmitted,
|
PromptAdmitted,
|
||||||
ExecutionSettled,
|
Execution.Started,
|
||||||
|
Execution.Succeeded,
|
||||||
|
Execution.Failed,
|
||||||
|
Execution.Interrupted,
|
||||||
InstructionsUpdated,
|
InstructionsUpdated,
|
||||||
Synthetic,
|
Synthetic,
|
||||||
Skill.Activated,
|
Skill.Activated,
|
||||||
|
|
@ -515,7 +505,7 @@ export const Definitions = Event.inventory(
|
||||||
Tool.Progress,
|
Tool.Progress,
|
||||||
Tool.Success,
|
Tool.Success,
|
||||||
Tool.Failed,
|
Tool.Failed,
|
||||||
Retried,
|
RetryScheduled,
|
||||||
Compaction.Started,
|
Compaction.Started,
|
||||||
Compaction.Delta,
|
Compaction.Delta,
|
||||||
Compaction.Ended,
|
Compaction.Ended,
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,16 @@ export * as SessionMessage from "./session-message.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional } from "./schema.js"
|
import { optional } from "./schema.js"
|
||||||
import { ProviderMetadata, ToolContent } from "./llm.js"
|
import { ToolContent } from "./llm.js"
|
||||||
import { Model } from "./model.js"
|
import { Model } from "./model.js"
|
||||||
import { FileAttachment, Prompt } from "./prompt.js"
|
import { FileAttachment, Prompt } from "./prompt.js"
|
||||||
import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema.js"
|
import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js"
|
||||||
import { SessionID } from "./session-id.js"
|
import { SessionID } from "./session-id.js"
|
||||||
import { ascending } from "./identifier.js"
|
import { ascending } from "./identifier.js"
|
||||||
import { Event } from "./event.js"
|
import { Event } from "./event.js"
|
||||||
import { Shell as ShellSchema } from "./shell.js"
|
import { Shell as ShellSchema } from "./shell.js"
|
||||||
|
import { FinishReason } from "./llm.js"
|
||||||
|
import { SessionError } from "./session-error.js"
|
||||||
|
|
||||||
export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
|
export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
|
||||||
Schema.brand("Session.Message.ID"),
|
Schema.brand("Session.Message.ID"),
|
||||||
|
|
@ -20,18 +22,17 @@ export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
|
||||||
)
|
)
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
|
||||||
export interface UnknownError extends Schema.Schema.Type<typeof UnknownError> {}
|
|
||||||
export const UnknownError = Schema.Struct({
|
|
||||||
type: Schema.Literal("unknown"),
|
|
||||||
message: Schema.String,
|
|
||||||
}).annotate({ identifier: "Session.Error.Unknown" })
|
|
||||||
|
|
||||||
const Base = {
|
const Base = {
|
||||||
id: ID,
|
id: ID,
|
||||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||||
time: Schema.Struct({ created: DateTimeUtcFromMillis }),
|
time: Schema.Struct({ created: DateTimeUtcFromMillis }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ProviderState = Schema.Record(Schema.String, Schema.Unknown).annotate({
|
||||||
|
identifier: "Session.Message.ProviderState",
|
||||||
|
})
|
||||||
|
export type ProviderState = typeof ProviderState.Type
|
||||||
|
|
||||||
export interface AgentSelected extends Schema.Schema.Type<typeof AgentSelected> {}
|
export interface AgentSelected extends Schema.Schema.Type<typeof AgentSelected> {}
|
||||||
export const AgentSelected = Schema.Struct({
|
export const AgentSelected = Schema.Struct({
|
||||||
...Base,
|
...Base,
|
||||||
|
|
@ -123,7 +124,7 @@ export const ToolStateError = Schema.Struct({
|
||||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
content: ToolContent.pipe(Schema.Array),
|
content: ToolContent.pipe(Schema.Array),
|
||||||
structured: Schema.Record(Schema.String, Schema.Unknown),
|
structured: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
error: UnknownError,
|
error: SessionError.Error,
|
||||||
result: Schema.Unknown.pipe(optional),
|
result: Schema.Unknown.pipe(optional),
|
||||||
}).annotate({ identifier: "Session.Message.ToolState.Error" })
|
}).annotate({ identifier: "Session.Message.ToolState.Error" })
|
||||||
|
|
||||||
|
|
@ -137,11 +138,9 @@ export const AssistantTool = Schema.Struct({
|
||||||
type: Schema.Literal("tool"),
|
type: Schema.Literal("tool"),
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
provider: Schema.Struct({
|
executed: Schema.Boolean.pipe(optional),
|
||||||
executed: Schema.Boolean,
|
providerState: ProviderState.pipe(optional),
|
||||||
metadata: ProviderMetadata.pipe(optional),
|
providerResultState: ProviderState.pipe(optional),
|
||||||
resultMetadata: ProviderMetadata.pipe(optional),
|
|
||||||
}).pipe(optional),
|
|
||||||
state: ToolState,
|
state: ToolState,
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
created: DateTimeUtcFromMillis,
|
created: DateTimeUtcFromMillis,
|
||||||
|
|
@ -154,16 +153,14 @@ export const AssistantTool = Schema.Struct({
|
||||||
export interface AssistantText extends Schema.Schema.Type<typeof AssistantText> {}
|
export interface AssistantText extends Schema.Schema.Type<typeof AssistantText> {}
|
||||||
export const AssistantText = Schema.Struct({
|
export const AssistantText = Schema.Struct({
|
||||||
type: Schema.Literal("text"),
|
type: Schema.Literal("text"),
|
||||||
id: Schema.String,
|
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
}).annotate({ identifier: "Session.Message.Assistant.Text" })
|
}).annotate({ identifier: "Session.Message.Assistant.Text" })
|
||||||
|
|
||||||
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
|
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
|
||||||
export const AssistantReasoning = Schema.Struct({
|
export const AssistantReasoning = Schema.Struct({
|
||||||
type: Schema.Literal("reasoning"),
|
type: Schema.Literal("reasoning"),
|
||||||
id: Schema.String,
|
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
providerMetadata: ProviderMetadata.pipe(optional),
|
state: ProviderState.pipe(optional),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
created: DateTimeUtcFromMillis,
|
created: DateTimeUtcFromMillis,
|
||||||
completed: DateTimeUtcFromMillis.pipe(optional),
|
completed: DateTimeUtcFromMillis.pipe(optional),
|
||||||
|
|
@ -175,6 +172,13 @@ export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning,
|
||||||
)
|
)
|
||||||
export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool
|
export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool
|
||||||
|
|
||||||
|
export interface AssistantRetry extends Schema.Schema.Type<typeof AssistantRetry> {}
|
||||||
|
export const AssistantRetry = Schema.Struct({
|
||||||
|
attempt: PositiveInt,
|
||||||
|
at: DateTimeUtcFromMillis,
|
||||||
|
error: SessionError.Error,
|
||||||
|
}).annotate({ identifier: "Session.Message.Assistant.Retry" })
|
||||||
|
|
||||||
export interface Assistant extends Schema.Schema.Type<typeof Assistant> {}
|
export interface Assistant extends Schema.Schema.Type<typeof Assistant> {}
|
||||||
export const Assistant = Schema.Struct({
|
export const Assistant = Schema.Struct({
|
||||||
...Base,
|
...Base,
|
||||||
|
|
@ -187,7 +191,7 @@ export const Assistant = Schema.Struct({
|
||||||
end: Schema.String.pipe(optional),
|
end: Schema.String.pipe(optional),
|
||||||
files: Schema.Array(RelativePath).pipe(optional),
|
files: Schema.Array(RelativePath).pipe(optional),
|
||||||
}).pipe(optional),
|
}).pipe(optional),
|
||||||
finish: Schema.String.pipe(optional),
|
finish: FinishReason.pipe(optional),
|
||||||
cost: Schema.Finite.pipe(optional),
|
cost: Schema.Finite.pipe(optional),
|
||||||
tokens: Schema.Struct({
|
tokens: Schema.Struct({
|
||||||
input: Schema.Finite,
|
input: Schema.Finite,
|
||||||
|
|
@ -195,7 +199,8 @@ export const Assistant = Schema.Struct({
|
||||||
reasoning: Schema.Finite,
|
reasoning: Schema.Finite,
|
||||||
cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }),
|
cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }),
|
||||||
}).pipe(optional),
|
}).pipe(optional),
|
||||||
error: UnknownError.pipe(optional),
|
error: SessionError.Error.pipe(optional),
|
||||||
|
retry: AssistantRetry.pipe(optional),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
created: DateTimeUtcFromMillis,
|
created: DateTimeUtcFromMillis,
|
||||||
completed: DateTimeUtcFromMillis.pipe(optional),
|
completed: DateTimeUtcFromMillis.pipe(optional),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Schema } from "effect"
|
import { DateTime, Schema } from "effect"
|
||||||
import { Agent } from "../src/agent.js"
|
import { Agent } from "../src/agent.js"
|
||||||
import { FileSystem } from "../src/filesystem.js"
|
import { FileSystem } from "../src/filesystem.js"
|
||||||
import { Model } from "../src/model.js"
|
import { Model } from "../src/model.js"
|
||||||
|
|
@ -8,6 +8,7 @@ import { Provider } from "../src/provider.js"
|
||||||
import { Pty } from "../src/pty.js"
|
import { Pty } from "../src/pty.js"
|
||||||
import { Question } from "../src/question.js"
|
import { Question } from "../src/question.js"
|
||||||
import { Session } from "../src/session.js"
|
import { Session } from "../src/session.js"
|
||||||
|
import { SessionMessage } from "../src/session-message.js"
|
||||||
import { SessionTodo } from "../src/session-todo.js"
|
import { SessionTodo } from "../src/session-todo.js"
|
||||||
import { optional } from "../src/schema.js"
|
import { optional } from "../src/schema.js"
|
||||||
|
|
||||||
|
|
@ -81,4 +82,25 @@ describe("contract hygiene", () => {
|
||||||
expect(source).not.toContain("Schema.Any")
|
expect(source).not.toContain("Schema.Any")
|
||||||
expect(source).not.toContain("Schema.mutable")
|
expect(source).not.toContain("Schema.mutable")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("assistant content keeps only domain identities", () => {
|
||||||
|
expect(SessionMessage.AssistantText.make({ type: "text", text: "hello" })).toEqual({
|
||||||
|
type: "text",
|
||||||
|
text: "hello",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "thinking", state: { id: "opaque" } }),
|
||||||
|
).toEqual({ type: "reasoning", text: "thinking", state: { id: "opaque" } })
|
||||||
|
expect(
|
||||||
|
SessionMessage.AssistantTool.make({
|
||||||
|
type: "tool",
|
||||||
|
id: "call_1",
|
||||||
|
name: "search",
|
||||||
|
executed: true,
|
||||||
|
providerState: { itemId: "item_1" },
|
||||||
|
state: { status: "pending", input: "" },
|
||||||
|
time: { created: DateTime.makeUnsafe(0) },
|
||||||
|
}),
|
||||||
|
).not.toHaveProperty("provider")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import { IdeEvent } from "../src/ide-event.js"
|
||||||
import { McpEvent } from "../src/mcp-event.js"
|
import { McpEvent } from "../src/mcp-event.js"
|
||||||
import { Plugin } from "../src/plugin.js"
|
import { Plugin } from "../src/plugin.js"
|
||||||
import { SessionEvent } from "../src/session-event.js"
|
import { SessionEvent } from "../src/session-event.js"
|
||||||
|
import { SessionID } from "../src/session-id.js"
|
||||||
|
import { SessionMessage } from "../src/session-message.js"
|
||||||
import { SessionTodo } from "../src/session-todo.js"
|
import { SessionTodo } from "../src/session-todo.js"
|
||||||
import { SessionV1 } from "../src/session-v1.js"
|
import { SessionV1 } from "../src/session-v1.js"
|
||||||
import { WorkspaceEvent } from "../src/workspace-event.js"
|
import { WorkspaceEvent } from "../src/workspace-event.js"
|
||||||
|
|
@ -104,7 +106,10 @@ describe("public event manifest", () => {
|
||||||
"session.forked.1",
|
"session.forked.1",
|
||||||
"session.prompt.promoted.1",
|
"session.prompt.promoted.1",
|
||||||
"session.prompt.admitted.1",
|
"session.prompt.admitted.1",
|
||||||
|
"session.execution.started.1",
|
||||||
|
"session.execution.succeeded.1",
|
||||||
|
"session.execution.failed.1",
|
||||||
|
"session.execution.interrupted.1",
|
||||||
"session.instructions.updated.1",
|
"session.instructions.updated.1",
|
||||||
"session.synthetic.1",
|
"session.synthetic.1",
|
||||||
"session.skill.activated.1",
|
"session.skill.activated.1",
|
||||||
|
|
@ -123,7 +128,7 @@ describe("public event manifest", () => {
|
||||||
"session.tool.failed.1",
|
"session.tool.failed.1",
|
||||||
"session.reasoning.started.1",
|
"session.reasoning.started.1",
|
||||||
"session.reasoning.ended.1",
|
"session.reasoning.ended.1",
|
||||||
"session.retried.1",
|
"session.retry.scheduled.1",
|
||||||
"session.compaction.started.1",
|
"session.compaction.started.1",
|
||||||
"session.compaction.ended.1",
|
"session.compaction.ended.1",
|
||||||
"session.revert.staged.1",
|
"session.revert.staged.1",
|
||||||
|
|
@ -136,4 +141,33 @@ describe("public event manifest", () => {
|
||||||
)
|
)
|
||||||
expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true)
|
expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps simplified session fragment and tool payloads on durable version 1", () => {
|
||||||
|
const sessionID = SessionID.make("ses_test")
|
||||||
|
const assistantMessageID = SessionMessage.ID.make("msg_test")
|
||||||
|
const text = SessionEvent.Text.Started.data.make({ sessionID, assistantMessageID, ordinal: 0 })
|
||||||
|
const reasoning = SessionEvent.Reasoning.Ended.data.make({
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
ordinal: 0,
|
||||||
|
text: "thought",
|
||||||
|
state: { signature: "sig" },
|
||||||
|
})
|
||||||
|
const tool = SessionEvent.Tool.Called.data.make({
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
callID: "call_test",
|
||||||
|
input: {},
|
||||||
|
executed: true,
|
||||||
|
state: { itemId: "item_test" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(text).not.toHaveProperty("textID")
|
||||||
|
expect(reasoning).not.toHaveProperty("reasoningID")
|
||||||
|
expect(reasoning).not.toHaveProperty("providerMetadata")
|
||||||
|
expect(tool).not.toHaveProperty("tool")
|
||||||
|
expect(tool).not.toHaveProperty("provider")
|
||||||
|
expect(SessionEvent.Text.Started.durable?.version).toBe(1)
|
||||||
|
expect(SessionEvent.Tool.Called.durable?.version).toBe(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
46
packages/schema/test/session-error.test.ts
Normal file
46
packages/schema/test/session-error.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Schema } from "effect"
|
||||||
|
import { LLM, SessionError } from "../src/index.js"
|
||||||
|
|
||||||
|
describe("SessionError", () => {
|
||||||
|
test("exports one identified open envelope", () => {
|
||||||
|
expect(SessionError.Error.ast.annotations?.identifier).toBe("Session.StructuredError")
|
||||||
|
expect(Object.keys(SessionError).filter((key) => key !== "SessionError")).toEqual(["Error"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("round trips current and future error types through JSON", () => {
|
||||||
|
const values: SessionError.Error[] = [
|
||||||
|
{ type: "provider.rate-limit", message: "Slow down" },
|
||||||
|
{ type: "provider.auth", message: "Authentication failed" },
|
||||||
|
{ type: "provider.future-condition", message: "A future provider failure" },
|
||||||
|
{ type: "unknown", message: "Unexpected" },
|
||||||
|
]
|
||||||
|
const codec = Schema.fromJsonString(SessionError.Error)
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
const encoded = Schema.encodeSync(codec)(value)
|
||||||
|
expect(Schema.decodeUnknownSync(codec)(encoded)).toEqual(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts future fields while exposing only the stable envelope", () => {
|
||||||
|
expect(
|
||||||
|
Schema.decodeUnknownSync(SessionError.Error)({
|
||||||
|
type: "provider.timeout",
|
||||||
|
message: "Timeout",
|
||||||
|
retryAfterMs: 2_500,
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "provider.timeout", message: "Timeout" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects missing envelope fields", () => {
|
||||||
|
expect(() => Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.auth" })).toThrow()
|
||||||
|
expect(() => Schema.decodeUnknownSync(SessionError.Error)({ message: "Missing type" })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("FinishReason is the closed browser-safe provider set", () => {
|
||||||
|
const reasons = ["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const
|
||||||
|
expect(reasons.map((reason) => Schema.decodeUnknownSync(LLM.FinishReason)(reason))).toEqual([...reasons])
|
||||||
|
expect(() => Schema.decodeUnknownSync(LLM.FinishReason)("other")).toThrow()
|
||||||
|
})
|
||||||
|
|
@ -30,6 +30,8 @@ type OpenApiDocument = {
|
||||||
|
|
||||||
const document = (await Bun.file("./openapi.json").json()) as OpenApiDocument
|
const document = (await Bun.file("./openapi.json").json()) as OpenApiDocument
|
||||||
const v2Document = (await Bun.file("./openapi-v2.json").json()) as OpenApiDocument
|
const v2Document = (await Bun.file("./openapi-v2.json").json()) as OpenApiDocument
|
||||||
|
normalizeComponentNames(v2Document)
|
||||||
|
deduplicateEquivalentComponent(v2Document, "Shell", "Shell1")
|
||||||
renameCollidingComponents(document, v2Document)
|
renameCollidingComponents(document, v2Document)
|
||||||
document.paths = { ...document.paths, ...v2Document.paths }
|
document.paths = { ...document.paths, ...v2Document.paths }
|
||||||
document.components = {
|
document.components = {
|
||||||
|
|
@ -60,7 +62,7 @@ if (schemas) {
|
||||||
visit({ ...document, components: { ...document.components, schemas: undefined } })
|
visit({ ...document, components: { ...document.components, schemas: undefined } })
|
||||||
for (const name of Object.keys(schemas)) {
|
for (const name of Object.keys(schemas)) {
|
||||||
if (
|
if (
|
||||||
/^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test(
|
/^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+$/.test(
|
||||||
name,
|
name,
|
||||||
) &&
|
) &&
|
||||||
!reachable.has(name)
|
!reachable.has(name)
|
||||||
|
|
@ -100,17 +102,28 @@ await createClient({
|
||||||
const generatedTypesPath = "./src/v2/gen/types.gen.ts"
|
const generatedTypesPath = "./src/v2/gen/types.gen.ts"
|
||||||
const generatedTypes = await Bun.file(generatedTypesPath).text()
|
const generatedTypes = await Bun.file(generatedTypesPath).text()
|
||||||
if (
|
if (
|
||||||
/export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test(
|
/export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+ =/.test(
|
||||||
generatedTypes,
|
generatedTypes,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error("Session history generated duplicate Session event variants")
|
throw new Error("Session history generated duplicate Session event variants")
|
||||||
}
|
}
|
||||||
const logTypesPatched = generatedTypes.replace(
|
const sessionErrorTypesPatched = deduplicateEquivalentGeneratedTypes(
|
||||||
|
generatedTypes,
|
||||||
|
"SessionStructuredError",
|
||||||
|
/^SessionStructuredError\d+$/,
|
||||||
|
)
|
||||||
|
const obsoleteSessionNext = [...sessionErrorTypesPatched.matchAll(/export type (SessionNext\w*) =/g)].map(
|
||||||
|
(match) => match[1],
|
||||||
|
)
|
||||||
|
if (obsoleteSessionNext.length > 0) {
|
||||||
|
throw new Error(`Obsolete SessionNext generated type noise reintroduced: ${obsoleteSessionNext.join(", ")}`)
|
||||||
|
}
|
||||||
|
const logTypesPatched = sessionErrorTypesPatched.replace(
|
||||||
/(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/,
|
/(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/,
|
||||||
"$1number",
|
"$1number",
|
||||||
)
|
)
|
||||||
if (logTypesPatched === generatedTypes) {
|
if (logTypesPatched === sessionErrorTypesPatched) {
|
||||||
throw new Error("Session log numeric query patch did not apply")
|
throw new Error("Session log numeric query patch did not apply")
|
||||||
}
|
}
|
||||||
const sessionListTypesPatched = logTypesPatched.replace(
|
const sessionListTypesPatched = logTypesPatched.replace(
|
||||||
|
|
@ -128,12 +141,21 @@ if (sessionMessagesTypesPatched === sessionListTypesPatched) {
|
||||||
throw new Error("Session messages numeric query patch did not apply")
|
throw new Error("Session messages numeric query patch did not apply")
|
||||||
}
|
}
|
||||||
const eventSubscribeTypesPatched = sessionMessagesTypesPatched.replace(
|
const eventSubscribeTypesPatched = sessionMessagesTypesPatched.replace(
|
||||||
/(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStreamV2;?\s*\};?/,
|
/(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStream(?:V2)?;?\s*\};?/,
|
||||||
"$1V2Event",
|
"$1V2Event",
|
||||||
)
|
)
|
||||||
if (eventSubscribeTypesPatched === sessionMessagesTypesPatched) {
|
if (eventSubscribeTypesPatched === sessionMessagesTypesPatched) {
|
||||||
throw new Error("Event subscribe response patch did not apply")
|
throw new Error("Event subscribe response patch did not apply")
|
||||||
}
|
}
|
||||||
|
if (/SessionStructuredError\d/.test(eventSubscribeTypesPatched)) {
|
||||||
|
throw new Error("Session structured error generated a name-mangled duplicate")
|
||||||
|
}
|
||||||
|
if (/\bSessionNext\w*\b/.test(eventSubscribeTypesPatched)) {
|
||||||
|
throw new Error("Obsolete SessionNext generated type noise reintroduced")
|
||||||
|
}
|
||||||
|
if (/export type Shell\d+V2 =/.test(eventSubscribeTypesPatched)) {
|
||||||
|
throw new Error("Shell generated a name-mangled duplicate")
|
||||||
|
}
|
||||||
await Bun.write(generatedTypesPath, eventSubscribeTypesPatched)
|
await Bun.write(generatedTypesPath, eventSubscribeTypesPatched)
|
||||||
|
|
||||||
const querySerializerPath = "./src/v2/gen/client/utils.gen.ts"
|
const querySerializerPath = "./src/v2/gen/client/utils.gen.ts"
|
||||||
|
|
@ -206,6 +228,10 @@ function renameCollidingComponents(target: OpenApiDocument, source: OpenApiDocum
|
||||||
const renames = new Map<string, string>()
|
const renames = new Map<string, string>()
|
||||||
for (const name of Object.keys(sourceSchemas)) {
|
for (const name of Object.keys(sourceSchemas)) {
|
||||||
if (!Object.hasOwn(targetSchemas, name)) continue
|
if (!Object.hasOwn(targetSchemas, name)) continue
|
||||||
|
if (JSON.stringify(normalizeSchema(sourceSchemas[name])) === JSON.stringify(normalizeSchema(targetSchemas[name]))) {
|
||||||
|
delete sourceSchemas[name]
|
||||||
|
continue
|
||||||
|
}
|
||||||
let renamed = `${name}V2`
|
let renamed = `${name}V2`
|
||||||
let index = 2
|
let index = 2
|
||||||
while (Object.hasOwn(targetSchemas, renamed) || Object.hasOwn(sourceSchemas, renamed)) {
|
while (Object.hasOwn(targetSchemas, renamed) || Object.hasOwn(sourceSchemas, renamed)) {
|
||||||
|
|
@ -225,6 +251,136 @@ function renameCollidingComponents(target: OpenApiDocument, source: OpenApiDocum
|
||||||
source.paths = rewriteRefs(source.paths, renames) as Record<string, unknown> | undefined
|
source.paths = rewriteRefs(source.paths, renames) as Record<string, unknown> | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeComponentNames(document: OpenApiDocument) {
|
||||||
|
const schemas = document.components?.schemas
|
||||||
|
if (!schemas) return
|
||||||
|
|
||||||
|
const canonical = new Map(Object.entries(schemas))
|
||||||
|
const renames = new Map<string, string>()
|
||||||
|
for (const name of Object.keys(schemas)) {
|
||||||
|
const next = componentTypeName(name)
|
||||||
|
if (next === name) continue
|
||||||
|
const existing = canonical.get(next)
|
||||||
|
if (existing !== undefined) {
|
||||||
|
if (JSON.stringify(normalizeSchema(schemas[name])) !== JSON.stringify(normalizeSchema(existing))) continue
|
||||||
|
renames.set(name, next)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
renames.set(name, next)
|
||||||
|
canonical.set(next, schemas[name])
|
||||||
|
}
|
||||||
|
if (renames.size === 0) return
|
||||||
|
|
||||||
|
const renamed = new Set<string>()
|
||||||
|
document.components = {
|
||||||
|
...document.components,
|
||||||
|
schemas: Object.fromEntries(
|
||||||
|
[
|
||||||
|
...Object.entries(schemas).filter(([name]) => !renames.has(name)),
|
||||||
|
...Object.entries(schemas).flatMap(([name, schema]) => {
|
||||||
|
const next = renames.get(name)
|
||||||
|
if (!next || Object.hasOwn(schemas, next) || renamed.has(next)) return []
|
||||||
|
renamed.add(next)
|
||||||
|
return [[next, schema] as const]
|
||||||
|
}),
|
||||||
|
].map(([name, schema]) => [name, rewriteRefs(schema, renames)]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
document.paths = rewriteRefs(document.paths, renames) as Record<string, unknown> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentTypeName(name: string) {
|
||||||
|
if (!name.includes(".")) return name
|
||||||
|
return name
|
||||||
|
.split(".")
|
||||||
|
.filter((part) => !/^\d+$/.test(part))
|
||||||
|
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicateEquivalentComponent(document: OpenApiDocument, canonical: string, duplicate: string) {
|
||||||
|
const schemas = document.components?.schemas
|
||||||
|
if (!schemas?.[canonical] || !schemas[duplicate]) return
|
||||||
|
if (JSON.stringify(normalizeSchema(schemas[canonical])) !== JSON.stringify(normalizeSchema(schemas[duplicate]))) {
|
||||||
|
throw new Error(`${duplicate} no longer has the same wire shape as ${canonical}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renames = new Map([[duplicate, canonical]])
|
||||||
|
const rewritten = rewriteRefs(schemas, renames) as Record<string, unknown>
|
||||||
|
delete rewritten[duplicate]
|
||||||
|
document.components = { ...document.components, schemas: rewritten }
|
||||||
|
document.paths = rewriteRefs(document.paths, renames) as Record<string, unknown> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicateEquivalentGeneratedTypes(source: string, canonical: string, duplicates: RegExp) {
|
||||||
|
const canonicalType = generatedType(source, canonical)
|
||||||
|
if (!canonicalType) throw new Error(`Generated canonical type missing: ${canonical}`)
|
||||||
|
const names = [...source.matchAll(/export type (\w+) =/g)]
|
||||||
|
.map((match) => match[1])
|
||||||
|
.filter((name): name is string => name !== undefined && duplicates.test(name))
|
||||||
|
|
||||||
|
return names.reduce((patched, name) => {
|
||||||
|
const duplicate = generatedType(patched, name)
|
||||||
|
const currentCanonical = generatedType(patched, canonical)
|
||||||
|
if (!duplicate || !currentCanonical) throw new Error(`Generated type declaration missing while comparing ${name}`)
|
||||||
|
if (normalizeGeneratedType(currentCanonical.shape) !== normalizeGeneratedType(duplicate.shape)) {
|
||||||
|
throw new Error(`${name} no longer has the same generated type shape as ${canonical}`)
|
||||||
|
}
|
||||||
|
return (patched.slice(0, duplicate.start) + patched.slice(duplicate.end)).replaceAll(name, canonical)
|
||||||
|
}, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatedType(source: string, name: string) {
|
||||||
|
const start = source.indexOf(`export type ${name} =`)
|
||||||
|
if (start === -1) return undefined
|
||||||
|
const next = source.indexOf("\n\nexport type ", start + 1)
|
||||||
|
const shapeEnd = next === -1 ? source.length : next
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end: next === -1 ? source.length : next + 2,
|
||||||
|
shape: source.slice(source.indexOf("=", start) + 1, shapeEnd),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeneratedType(shape: string) {
|
||||||
|
return shape.replaceAll(/\s/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSchema(value: unknown, key?: string): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const flattened =
|
||||||
|
key === "anyOf"
|
||||||
|
? value.flatMap((item) =>
|
||||||
|
typeof item === "object" && item !== null && Object.keys(item).length === 1 && "anyOf" in item
|
||||||
|
? Array.isArray(item.anyOf)
|
||||||
|
? item.anyOf
|
||||||
|
: [item]
|
||||||
|
: [item],
|
||||||
|
)
|
||||||
|
: value
|
||||||
|
const expanded =
|
||||||
|
key === "anyOf"
|
||||||
|
? flattened.flatMap((item) => {
|
||||||
|
if (typeof item !== "object" || item === null || !("type" in item) || !("enum" in item)) return [item]
|
||||||
|
if (Object.keys(item).some((property) => property !== "type" && property !== "enum")) return [item]
|
||||||
|
if (!Array.isArray(item.enum)) return [item]
|
||||||
|
return item.enum.map((member) => ({ type: item.type, enum: [member] }))
|
||||||
|
})
|
||||||
|
: flattened
|
||||||
|
const normalized = expanded.map((item) => normalizeSchema(item))
|
||||||
|
if (key !== "anyOf" && key !== "required" && key !== "enum") return normalized
|
||||||
|
return [...new Map(normalized.map((item) => [JSON.stringify(item), item])).values()].sort((a, b) =>
|
||||||
|
JSON.stringify(a).localeCompare(JSON.stringify(b)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (typeof value !== "object" || value === null) return value
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([property, child]) => [property, normalizeSchema(child, property)]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function rewriteRefs(value: unknown, renames: Map<string, string>): unknown {
|
function rewriteRefs(value: unknown, renames: Map<string, string>): unknown {
|
||||||
if (Array.isArray(value)) return value.map((item) => rewriteRefs(item, renames))
|
if (Array.isArray(value)) return value.map((item) => rewriteRefs(item, renames))
|
||||||
if (typeof value !== "object" || value === null) return value
|
if (typeof value !== "object" || value === null) return value
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,8 @@ import type {
|
||||||
FindTextResponses,
|
FindTextResponses,
|
||||||
FormatterStatusErrors,
|
FormatterStatusErrors,
|
||||||
FormatterStatusResponses,
|
FormatterStatusResponses,
|
||||||
FormCreatePayload2,
|
FormCreatePayloadV2,
|
||||||
FormReply2,
|
FormReply,
|
||||||
GlobalConfigGetErrors,
|
GlobalConfigGetErrors,
|
||||||
GlobalConfigGetResponses,
|
GlobalConfigGetResponses,
|
||||||
GlobalConfigUpdateErrors,
|
GlobalConfigUpdateErrors,
|
||||||
|
|
@ -92,8 +92,8 @@ import type {
|
||||||
GlobalUpgradeResponses,
|
GlobalUpgradeResponses,
|
||||||
InstanceDisposeErrors,
|
InstanceDisposeErrors,
|
||||||
InstanceDisposeResponses,
|
InstanceDisposeResponses,
|
||||||
InstructionEntryKey2,
|
InstructionEntryKeyV2,
|
||||||
LocationRef2,
|
LocationRefV2,
|
||||||
LspStatusErrors,
|
LspStatusErrors,
|
||||||
LspStatusResponses,
|
LspStatusResponses,
|
||||||
McpAddErrors,
|
McpAddErrors,
|
||||||
|
|
@ -114,7 +114,7 @@ import type {
|
||||||
McpRemoteConfig,
|
McpRemoteConfig,
|
||||||
McpStatusErrors,
|
McpStatusErrors,
|
||||||
McpStatusResponses,
|
McpStatusResponses,
|
||||||
ModelRef2,
|
ModelRef,
|
||||||
MoveSessionDestination,
|
MoveSessionDestination,
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
Part as Part2,
|
Part as Part2,
|
||||||
|
|
@ -131,8 +131,8 @@ import type {
|
||||||
PermissionRespondErrors,
|
PermissionRespondErrors,
|
||||||
PermissionRespondResponses,
|
PermissionRespondResponses,
|
||||||
PermissionRuleset,
|
PermissionRuleset,
|
||||||
PermissionV2Reply2,
|
PermissionV2Reply,
|
||||||
PermissionV2Source2,
|
PermissionV2SourceV2,
|
||||||
ProjectCommands,
|
ProjectCommands,
|
||||||
ProjectCurrentErrors,
|
ProjectCurrentErrors,
|
||||||
ProjectCurrentResponses,
|
ProjectCurrentResponses,
|
||||||
|
|
@ -145,9 +145,9 @@ import type {
|
||||||
ProjectListResponses,
|
ProjectListResponses,
|
||||||
ProjectUpdateErrors,
|
ProjectUpdateErrors,
|
||||||
ProjectUpdateResponses,
|
ProjectUpdateResponses,
|
||||||
PromptAgentAttachment2,
|
PromptAgentAttachment,
|
||||||
PromptInputFileAttachment2,
|
PromptInput,
|
||||||
PromptInputV2,
|
PromptInputFileAttachment,
|
||||||
ProviderAuthErrors,
|
ProviderAuthErrors,
|
||||||
ProviderAuthResponses,
|
ProviderAuthResponses,
|
||||||
ProviderListErrors,
|
ProviderListErrors,
|
||||||
|
|
@ -179,7 +179,7 @@ import type {
|
||||||
QuestionRejectResponses,
|
QuestionRejectResponses,
|
||||||
QuestionReplyErrors,
|
QuestionReplyErrors,
|
||||||
QuestionReplyResponses,
|
QuestionReplyResponses,
|
||||||
QuestionV2Reply2,
|
QuestionV2Reply,
|
||||||
SessionAbortErrors,
|
SessionAbortErrors,
|
||||||
SessionAbortResponses,
|
SessionAbortResponses,
|
||||||
SessionChildrenErrors,
|
SessionChildrenErrors,
|
||||||
|
|
@ -460,7 +460,7 @@ import type {
|
||||||
VcsDiffResponses,
|
VcsDiffResponses,
|
||||||
VcsGetErrors,
|
VcsGetErrors,
|
||||||
VcsGetResponses,
|
VcsGetResponses,
|
||||||
VcsMode2,
|
VcsMode,
|
||||||
VcsStatusErrors,
|
VcsStatusErrors,
|
||||||
VcsStatusResponses,
|
VcsStatusResponses,
|
||||||
WorktreeCreateErrors,
|
WorktreeCreateErrors,
|
||||||
|
|
@ -5292,7 +5292,7 @@ export class Entry extends HeyApiClient {
|
||||||
public remove<ThrowOnError extends boolean = false>(
|
public remove<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
key: InstructionEntryKey2
|
key: InstructionEntryKeyV2
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -5326,7 +5326,7 @@ export class Entry extends HeyApiClient {
|
||||||
public put<ThrowOnError extends boolean = false>(
|
public put<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
key: InstructionEntryKey2
|
key: InstructionEntryKeyV2
|
||||||
value?: unknown
|
value?: unknown
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
|
|
@ -5395,7 +5395,7 @@ export class Form extends HeyApiClient {
|
||||||
public create<ThrowOnError extends boolean = false>(
|
public create<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
formCreatePayload: FormCreatePayload2
|
formCreatePayloadV2: FormCreatePayloadV2
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -5405,7 +5405,7 @@ export class Form extends HeyApiClient {
|
||||||
{
|
{
|
||||||
args: [
|
args: [
|
||||||
{ in: "path", key: "sessionID" },
|
{ in: "path", key: "sessionID" },
|
||||||
{ key: "formCreatePayload", map: "body" },
|
{ key: "formCreatePayloadV2", map: "body" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -5493,7 +5493,7 @@ export class Form extends HeyApiClient {
|
||||||
parameters: {
|
parameters: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
formID: string
|
formID: string
|
||||||
formReply: FormReply2
|
formReply: FormReply
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -5593,7 +5593,7 @@ export class Permission2 extends HeyApiClient {
|
||||||
metadata?: {
|
metadata?: {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
source?: PermissionV2Source2
|
source?: PermissionV2SourceV2
|
||||||
agent?: string | null
|
agent?: string | null
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
|
|
@ -5674,7 +5674,7 @@ export class Permission2 extends HeyApiClient {
|
||||||
parameters: {
|
parameters: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
requestID: string
|
requestID: string
|
||||||
reply?: PermissionV2Reply2
|
reply?: PermissionV2Reply
|
||||||
message?: string | null
|
message?: string | null
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
|
|
@ -5742,7 +5742,7 @@ export class Question2 extends HeyApiClient {
|
||||||
parameters: {
|
parameters: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
requestID: string
|
requestID: string
|
||||||
questionV2Reply: QuestionV2Reply2
|
questionV2Reply: QuestionV2Reply
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -5863,8 +5863,8 @@ export class Session3 extends HeyApiClient {
|
||||||
parameters?: {
|
parameters?: {
|
||||||
id?: string | null
|
id?: string | null
|
||||||
agent?: string | null
|
agent?: string | null
|
||||||
model?: ModelRef2 | null
|
model?: ModelRef | null
|
||||||
location?: LocationRef2 | null
|
location?: LocationRefV2 | null
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -6006,7 +6006,7 @@ export class Session3 extends HeyApiClient {
|
||||||
public switchModel<ThrowOnError extends boolean = false>(
|
public switchModel<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
model?: ModelRef2
|
model?: ModelRef
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -6081,7 +6081,7 @@ export class Session3 extends HeyApiClient {
|
||||||
parameters: {
|
parameters: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
id?: string | null
|
id?: string | null
|
||||||
prompt?: PromptInputV2
|
prompt?: PromptInput
|
||||||
delivery?: "steer" | "queue" | null
|
delivery?: "steer" | "queue" | null
|
||||||
resume?: boolean | null
|
resume?: boolean | null
|
||||||
},
|
},
|
||||||
|
|
@ -6125,9 +6125,9 @@ export class Session3 extends HeyApiClient {
|
||||||
command?: string
|
command?: string
|
||||||
arguments?: string | null
|
arguments?: string | null
|
||||||
agent?: string | null
|
agent?: string | null
|
||||||
model?: ModelRef2 | null
|
model?: ModelRef | null
|
||||||
files?: Array<PromptInputFileAttachment2>
|
files?: Array<PromptInputFileAttachment>
|
||||||
agents?: Array<PromptAgentAttachment2>
|
agents?: Array<PromptAgentAttachment>
|
||||||
delivery?: "steer" | "queue" | null
|
delivery?: "steer" | "queue" | null
|
||||||
resume?: boolean | null
|
resume?: boolean | null
|
||||||
},
|
},
|
||||||
|
|
@ -6559,7 +6559,7 @@ export class Generate extends HeyApiClient {
|
||||||
workspace?: string | null
|
workspace?: string | null
|
||||||
} | null
|
} | null
|
||||||
prompt?: string
|
prompt?: string
|
||||||
model?: ModelRef2 | null
|
model?: ModelRef | null
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -8013,7 +8013,7 @@ export class Vcs2 extends HeyApiClient {
|
||||||
directory?: string | null
|
directory?: string | null
|
||||||
workspace?: string | null
|
workspace?: string | null
|
||||||
} | null
|
} | null
|
||||||
mode: VcsMode2
|
mode: VcsMode
|
||||||
context?: string | null
|
context?: string | null
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -500,7 +500,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.data.type === "session") {
|
if (route.data.type === "session") {
|
||||||
const session = sync.session.get(route.data.sessionID)
|
const session = data.session.get(route.data.sessionID)
|
||||||
if (!session || isDefaultTitle(session.title)) {
|
if (!session || isDefaultTitle(session.title)) {
|
||||||
renderer.setTerminalTitle("OpenCode")
|
renderer.setTerminalTitle("OpenCode")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ type Data = {
|
||||||
// session ID in that family, including the key itself once its info arrives.
|
// session ID in that family, including the key itself once its info arrives.
|
||||||
family: Record<string, string[]>
|
family: Record<string, string[]>
|
||||||
status: Record<string, DataSessionStatus>
|
status: Record<string, DataSessionStatus>
|
||||||
|
compaction: Partial<Record<string, string>>
|
||||||
message: Record<string, SessionMessage[]>
|
message: Record<string, SessionMessage[]>
|
||||||
input: Record<string, string[]>
|
input: Record<string, string[]>
|
||||||
permission: Record<string, PermissionV2Request[]>
|
permission: Record<string, PermissionV2Request[]>
|
||||||
|
|
@ -90,6 +91,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
info: {},
|
info: {},
|
||||||
family: {},
|
family: {},
|
||||||
status: {},
|
status: {},
|
||||||
|
compaction: {},
|
||||||
message: {},
|
message: {},
|
||||||
input: {},
|
input: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
|
|
@ -149,14 +151,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
item.type === "tool" && (callID === undefined || item.id === callID),
|
item.type === "tool" && (callID === undefined || item.id === callID),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
|
latestText(assistant: SessionMessageAssistant | undefined) {
|
||||||
return assistant?.content.findLast(
|
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||||
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
latestReasoning(assistant: SessionMessageAssistant | undefined) {
|
||||||
return assistant?.content.findLast(
|
return assistant?.content.findLast(
|
||||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
|
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -273,7 +273,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||||
break
|
break
|
||||||
case "session.prompt.promoted": {
|
case "session.prompt.promoted": {
|
||||||
setSessionStatus(event.data.sessionID, "running")
|
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const position = index.get(event.data.inputID)
|
const position = index.get(event.data.inputID)
|
||||||
if (position === undefined) return
|
if (position === undefined) return
|
||||||
|
|
@ -335,7 +334,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.shell.started":
|
case "session.shell.started":
|
||||||
setSessionStatus(event.data.sessionID, "running")
|
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
message.append(draft, index, {
|
message.append(draft, index, {
|
||||||
id: messageIDFromEvent(event.id),
|
id: messageIDFromEvent(event.id),
|
||||||
|
|
@ -346,7 +344,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.shell.ended":
|
case "session.shell.ended":
|
||||||
setSessionStatus(event.data.sessionID, "idle")
|
|
||||||
message.update(event.data.sessionID, (draft) => {
|
message.update(event.data.sessionID, (draft) => {
|
||||||
const match = message.shell(draft, event.data.shell.id)
|
const match = message.shell(draft, event.data.shell.id)
|
||||||
if (!match) return
|
if (!match) return
|
||||||
|
|
@ -356,11 +353,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.step.started":
|
case "session.step.started":
|
||||||
setSessionStatus(event.data.sessionID, "running")
|
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
if (index.has(event.data.assistantMessageID)) return
|
const position = index.get(event.data.assistantMessageID)
|
||||||
|
const existing = position === undefined ? undefined : draft[position]
|
||||||
|
if (existing?.type === "assistant") {
|
||||||
|
existing.agent = event.data.agent
|
||||||
|
existing.model = event.data.model
|
||||||
|
existing.retry = undefined
|
||||||
|
existing.error = undefined
|
||||||
|
existing.finish = undefined
|
||||||
|
existing.time.completed = undefined
|
||||||
|
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
|
||||||
|
return
|
||||||
|
}
|
||||||
const currentAssistant = message.activeAssistant(draft)
|
const currentAssistant = message.activeAssistant(draft)
|
||||||
if (currentAssistant) currentAssistant.time.completed = event.created
|
if (currentAssistant) {
|
||||||
|
currentAssistant.retry = undefined
|
||||||
|
currentAssistant.time.completed = event.created
|
||||||
|
}
|
||||||
message.append(draft, index, {
|
message.append(draft, index, {
|
||||||
id: event.data.assistantMessageID,
|
id: event.data.assistantMessageID,
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
|
|
@ -373,7 +383,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.step.ended":
|
case "session.step.ended":
|
||||||
setSessionStatus(event.data.sessionID, "running")
|
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||||
if (!currentAssistant) return
|
if (!currentAssistant) return
|
||||||
|
|
@ -392,32 +401,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
currentAssistant.time.completed = event.created
|
currentAssistant.time.completed = event.created
|
||||||
currentAssistant.finish = "error"
|
currentAssistant.finish = "error"
|
||||||
currentAssistant.error = event.data.error
|
currentAssistant.error = event.data.error
|
||||||
|
currentAssistant.retry = undefined
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.text.started":
|
case "session.text.started":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||||
type: "text",
|
type: "text",
|
||||||
id: event.data.textID,
|
|
||||||
text: "",
|
text: "",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.text.delta":
|
case "session.text.delta":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const match = message.latestText(
|
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||||
message.assistant(draft, index, event.data.assistantMessageID),
|
|
||||||
event.data.textID,
|
|
||||||
)
|
|
||||||
if (match) match.text += event.data.delta
|
if (match) match.text += event.data.delta
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.text.ended":
|
case "session.text.ended":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const match = message.latestText(
|
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||||
message.assistant(draft, index, event.data.assistantMessageID),
|
|
||||||
event.data.textID,
|
|
||||||
)
|
|
||||||
if (match) match.text = event.data.text
|
if (match) match.text = event.data.text
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|
@ -458,7 +461,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
)
|
)
|
||||||
if (!match) return
|
if (!match) return
|
||||||
match.time.ran = event.created
|
match.time.ran = event.created
|
||||||
match.provider = event.data.provider
|
match.executed = event.data.executed
|
||||||
|
match.providerState = event.data.state
|
||||||
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
|
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|
@ -487,11 +491,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
content: [...event.data.content],
|
content: [...event.data.content],
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
}
|
}
|
||||||
match.provider = {
|
match.executed = event.data.executed || match.executed === true
|
||||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
match.providerResultState = event.data.resultState
|
||||||
metadata: match.provider?.metadata,
|
|
||||||
resultMetadata: event.data.provider.metadata,
|
|
||||||
}
|
|
||||||
match.time.completed = event.created
|
match.time.completed = event.created
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|
@ -510,11 +511,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
content: match.state.status === "running" ? match.state.content : [],
|
content: match.state.status === "running" ? match.state.content : [],
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
}
|
}
|
||||||
match.provider = {
|
match.executed = event.data.executed || match.executed === true
|
||||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
match.providerResultState = event.data.resultState
|
||||||
metadata: match.provider?.metadata,
|
|
||||||
resultMetadata: event.data.provider.metadata,
|
|
||||||
}
|
|
||||||
match.time.completed = event.created
|
match.time.completed = event.created
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|
@ -522,41 +520,55 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: event.data.reasoningID,
|
|
||||||
text: "",
|
text: "",
|
||||||
providerMetadata: event.data.providerMetadata,
|
state: event.data.state,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.reasoning.delta":
|
case "session.reasoning.delta":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const match = message.latestReasoning(
|
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||||
message.assistant(draft, index, event.data.assistantMessageID),
|
|
||||||
event.data.reasoningID,
|
|
||||||
)
|
|
||||||
if (match) match.text += event.data.delta
|
if (match) match.text += event.data.delta
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.reasoning.ended":
|
case "session.reasoning.ended":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const match = message.latestReasoning(
|
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||||
message.assistant(draft, index, event.data.assistantMessageID),
|
|
||||||
event.data.reasoningID,
|
|
||||||
)
|
|
||||||
if (match) {
|
if (match) {
|
||||||
match.text = event.data.text
|
match.text = event.data.text
|
||||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
if (event.data.state !== undefined) match.state = event.data.state
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.retried":
|
case "session.retry.scheduled":
|
||||||
case "session.compaction.started":
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
|
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||||
|
if (!currentAssistant) return
|
||||||
|
currentAssistant.retry = {
|
||||||
|
attempt: event.data.attempt,
|
||||||
|
at: event.data.at,
|
||||||
|
error: event.data.error,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.execution.started":
|
||||||
setSessionStatus(event.data.sessionID, "running")
|
setSessionStatus(event.data.sessionID, "running")
|
||||||
break
|
break
|
||||||
case "session.execution.settled":
|
case "session.compaction.started":
|
||||||
|
setStore("session", "compaction", event.data.sessionID, "")
|
||||||
|
break
|
||||||
|
case "session.execution.succeeded":
|
||||||
|
case "session.execution.failed":
|
||||||
|
case "session.execution.interrupted":
|
||||||
setSessionStatus(event.data.sessionID, "idle")
|
setSessionStatus(event.data.sessionID, "idle")
|
||||||
|
if (store.session.compaction[event.data.sessionID] !== undefined)
|
||||||
|
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||||
|
message.update(event.data.sessionID, (draft) => {
|
||||||
|
const currentAssistant = message.activeAssistant(draft)
|
||||||
|
if (currentAssistant) currentAssistant.retry = undefined
|
||||||
|
})
|
||||||
break
|
break
|
||||||
case "session.revert.staged":
|
case "session.revert.staged":
|
||||||
if (store.session.info[event.data.sessionID])
|
if (store.session.info[event.data.sessionID])
|
||||||
|
|
@ -573,17 +585,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
"session",
|
"session",
|
||||||
"input",
|
"input",
|
||||||
event.data.sessionID,
|
event.data.sessionID,
|
||||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.messageID),
|
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
|
||||||
)
|
)
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const position = draft.findIndex((item) => item.id >= event.data.messageID)
|
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||||
if (position === -1) return
|
if (position === -1) return
|
||||||
for (const item of draft.splice(position)) index.delete(item.id)
|
for (const item of draft.splice(position)) index.delete(item.id)
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.compaction.delta":
|
case "session.compaction.delta":
|
||||||
|
setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text)
|
||||||
break
|
break
|
||||||
case "session.compaction.ended":
|
case "session.compaction.ended":
|
||||||
|
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
message.append(draft, index, {
|
message.append(draft, index, {
|
||||||
id: messageIDFromEvent(event.id),
|
id: messageIDFromEvent(event.id),
|
||||||
|
|
@ -710,6 +724,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
return store.session.input[sessionID]?.includes(inputID) ?? false
|
return store.session.input[sessionID]?.includes(inputID) ?? false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
compaction(sessionID: string) {
|
||||||
|
return store.session.compaction[sessionID]
|
||||||
|
},
|
||||||
async refresh(sessionID: string) {
|
async refresh(sessionID: string) {
|
||||||
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
||||||
registerSession(sessionID)
|
registerSession(sessionID)
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ function sessionErrorMessage(error: SessionError) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tui: TuiPlugin = async (api) => {
|
const tui: TuiPlugin = async (api) => {
|
||||||
const active = new Set<string>()
|
|
||||||
const errored = new Set<string>()
|
const errored = new Set<string>()
|
||||||
|
const terminal = new Set<string>()
|
||||||
const forms = new Set<string>()
|
const forms = new Set<string>()
|
||||||
const questions = new Set<string>()
|
const questions = new Set<string>()
|
||||||
const permissions = new Set<string>()
|
const permissions = new Set<string>()
|
||||||
|
|
@ -73,14 +73,13 @@ const tui: TuiPlugin = async (api) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const started = (sessionID: string) => {
|
const started = (sessionID: string) => {
|
||||||
active.add(sessionID)
|
|
||||||
errored.delete(sessionID)
|
errored.delete(sessionID)
|
||||||
|
terminal.delete(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ended = (sessionID: string) => {
|
const ended = (sessionID: string) => {
|
||||||
if (!active.has(sessionID)) return
|
if (terminal.has(sessionID)) return
|
||||||
active.delete(sessionID)
|
terminal.add(sessionID)
|
||||||
|
|
||||||
if (errored.has(sessionID)) {
|
if (errored.has(sessionID)) {
|
||||||
errored.delete(sessionID)
|
errored.delete(sessionID)
|
||||||
return
|
return
|
||||||
|
|
@ -90,28 +89,25 @@ const tui: TuiPlugin = async (api) => {
|
||||||
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||||
}
|
}
|
||||||
|
|
||||||
api.event.on("session.prompt.promoted", (event) => started(event.data.sessionID))
|
api.event.on("session.execution.started", (event) => started(event.data.sessionID))
|
||||||
api.event.on("session.shell.started", (event) => started(event.data.sessionID))
|
api.event.on("session.execution.succeeded", (event) => ended(event.data.sessionID))
|
||||||
api.event.on("session.step.started", (event) => started(event.data.sessionID))
|
api.event.on("session.execution.interrupted", (event) => ended(event.data.sessionID))
|
||||||
api.event.on("session.retried", (event) => started(event.data.sessionID))
|
api.event.on("session.execution.failed", (event) => {
|
||||||
api.event.on("session.compaction.started", (event) => started(event.data.sessionID))
|
|
||||||
api.event.on("session.shell.ended", (event) => ended(event.data.sessionID))
|
|
||||||
api.event.on("session.step.ended", (event) => {
|
|
||||||
if (event.data.finish === "tool-calls") return
|
|
||||||
ended(event.data.sessionID)
|
|
||||||
})
|
|
||||||
api.event.on("session.step.failed", (event) => {
|
|
||||||
const sessionID = event.data.sessionID
|
const sessionID = event.data.sessionID
|
||||||
if (!active.has(sessionID)) return
|
if (errored.has(sessionID)) {
|
||||||
|
ended(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
errored.add(sessionID)
|
errored.add(sessionID)
|
||||||
notify(api, sessionID, "Session error", "error")
|
notify(api, sessionID, event.data.error.message, "error")
|
||||||
ended(sessionID)
|
ended(sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
api.event.on("session.error", (event) => {
|
api.event.on("session.error", (event) => {
|
||||||
const sessionID = event.data.sessionID
|
const sessionID = event.data.sessionID
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
if (!active.has(sessionID)) return
|
if (api.state.session.status(sessionID)?.type !== "busy") return
|
||||||
|
if (errored.has(sessionID)) return
|
||||||
errored.add(sessionID)
|
errored.add(sessionID)
|
||||||
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
|
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ import { usePluginRuntime } from "../../plugin/runtime"
|
||||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
||||||
import { usePathFormatter } from "../../context/path-format"
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
import { LocationProvider } from "../../context/location"
|
import { LocationProvider } from "../../context/location"
|
||||||
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
|
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
|
||||||
import { switchLabel } from "../../util/model"
|
import { switchLabel } from "../../util/model"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
@ -914,6 +914,9 @@ export function Session() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={data.session.compaction(route.sessionID)}>
|
||||||
|
{(text) => <CompactionMessage text={text()} />}
|
||||||
|
</Show>
|
||||||
<BackgroundToolHint messages={messages()} />
|
<BackgroundToolHint messages={messages()} />
|
||||||
<Show when={session()?.revert?.messageID}>
|
<Show when={session()?.revert?.messageID}>
|
||||||
<RevertMessage
|
<RevertMessage
|
||||||
|
|
@ -1096,7 +1099,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
|
||||||
const part = createMemo(() => {
|
const part = createMemo(() => {
|
||||||
const item = message()
|
const item = message()
|
||||||
if (item?.type !== "assistant") return
|
if (item?.type !== "assistant") return
|
||||||
return item.content.find((part) => part.id === props.partRef.partID)
|
return resolvePart(item, props.partRef.partID)
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Show when={part()}>
|
<Show when={part()}>
|
||||||
|
|
@ -1136,7 +1139,7 @@ function SessionGroupView(props: {
|
||||||
refs.flatMap((ref) => {
|
refs.flatMap((ref) => {
|
||||||
const message = props.message(ref.messageID)
|
const message = props.message(ref.messageID)
|
||||||
if (message?.type !== "assistant") return []
|
if (message?.type !== "assistant") return []
|
||||||
const part = message.content.find((part) => part.id === ref.partID)
|
const part = resolvePart(message, ref.partID)
|
||||||
if (part?.type !== "tool") return []
|
if (part?.type !== "tool") return []
|
||||||
return [part]
|
return [part]
|
||||||
})
|
})
|
||||||
|
|
@ -1216,6 +1219,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||||
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
<AssistantRetry retry={props.message.retry} />
|
||||||
<box paddingLeft={3} marginTop={props.message.error && !interrupted() ? 1 : 0}>
|
<box paddingLeft={3} marginTop={props.message.error && !interrupted() ? 1 : 0}>
|
||||||
<text>
|
<text>
|
||||||
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
|
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
|
||||||
|
|
@ -1269,9 +1273,15 @@ function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactionMessage() {
|
function CompactionMessage(props: { text?: string }) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} />
|
return (
|
||||||
|
<box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive}>
|
||||||
|
<Show when={props.text}>
|
||||||
|
<text fg={theme.textMuted}>{props.text}</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: "added" | "modified" | "deleted") {
|
function statusLabel(status: "added" | "modified" | "deleted") {
|
||||||
|
|
@ -1547,6 +1557,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
|
||||||
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
<AssistantRetry retry={props.message.retry} />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.last || final() || props.message.error}>
|
<Match when={props.last || final() || props.message.error}>
|
||||||
<box paddingLeft={3}>
|
<box paddingLeft={3}>
|
||||||
|
|
@ -1566,6 +1577,21 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
return (
|
||||||
|
<Show when={props.retry}>
|
||||||
|
{(retry) => (
|
||||||
|
<box paddingLeft={3} marginTop={1}>
|
||||||
|
<text fg={theme.textMuted}>
|
||||||
|
Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}]
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
|
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const pathFormatter = usePathFormatter()
|
const pathFormatter = usePathFormatter()
|
||||||
|
|
@ -2137,7 +2163,7 @@ function Shell(props: ToolProps) {
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={shellID()}>
|
<Show when={shellID()}>
|
||||||
<text>
|
<text>
|
||||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> Backgrounded </span>
|
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> Background </span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={collapsed().overflow}>
|
<Show when={collapsed().overflow}>
|
||||||
|
|
|
||||||
|
|
@ -74,19 +74,17 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() =>
|
() =>
|
||||||
data.session.message
|
data.session.message.list(sessionID()).flatMap((message) =>
|
||||||
.list(sessionID())
|
message.type === "user"
|
||||||
.flatMap((message) =>
|
? [
|
||||||
message.type === "user"
|
{
|
||||||
? [
|
id: message.id,
|
||||||
{
|
created: message.time.created,
|
||||||
id: message.id,
|
input: data.session.input.has(sessionID(), message.id),
|
||||||
created: message.time.created,
|
},
|
||||||
input: data.session.input.has(sessionID(), message.id),
|
]
|
||||||
},
|
: [],
|
||||||
]
|
),
|
||||||
: [],
|
|
||||||
),
|
|
||||||
() => setRows(reconcile(reduce())),
|
() => setRows(reconcile(reduce())),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -138,6 +136,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const removeFooter = (messageID: string) =>
|
||||||
|
setRows(
|
||||||
|
produce((draft) => {
|
||||||
|
const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID)
|
||||||
|
if (index !== -1) draft.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const isQueued = (messageID: string) => {
|
const isQueued = (messageID: string) => {
|
||||||
return data.session.input.has(sessionID(), messageID)
|
return data.session.input.has(sessionID(), messageID)
|
||||||
}
|
}
|
||||||
|
|
@ -166,24 +172,30 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||||
data.on("session.compaction.ended", message),
|
data.on("session.compaction.ended", message),
|
||||||
data.on("session.text.delta", (event) => {
|
data.on("session.text.delta", (event) => {
|
||||||
if (event.data.sessionID === sessionID())
|
if (event.data.sessionID === sessionID())
|
||||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
|
||||||
}),
|
}),
|
||||||
data.on("session.text.ended", (event) => {
|
data.on("session.text.ended", (event) => {
|
||||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
|
||||||
}),
|
}),
|
||||||
data.on("session.reasoning.delta", (event) => {
|
data.on("session.reasoning.delta", (event) => {
|
||||||
if (event.data.sessionID === sessionID())
|
if (event.data.sessionID === sessionID())
|
||||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` })
|
||||||
}),
|
}),
|
||||||
data.on("session.reasoning.ended", (event) => {
|
data.on("session.reasoning.ended", (event) => {
|
||||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` })
|
||||||
}),
|
}),
|
||||||
data.on("session.tool.input.started", (event) => {
|
data.on("session.tool.input.started", (event) => {
|
||||||
if (event.data.sessionID === sessionID())
|
if (event.data.sessionID === sessionID())
|
||||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
|
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
|
||||||
}),
|
}),
|
||||||
|
data.on("session.retry.scheduled", (event) => {
|
||||||
|
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
|
||||||
|
}),
|
||||||
|
data.on("session.step.started", (event) => {
|
||||||
|
if (event.data.sessionID === sessionID()) removeFooter(event.data.assistantMessageID)
|
||||||
|
}),
|
||||||
data.on("session.step.ended", (event) => {
|
data.on("session.step.ended", (event) => {
|
||||||
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
||||||
appendFooter(event.data.assistantMessageID)
|
appendFooter(event.data.assistantMessageID)
|
||||||
|
|
@ -207,11 +219,13 @@ export function reduceSessionRows(messages: SessionMessage[], inputs = new Set<s
|
||||||
rows.push({ type: "message", messageID: message.id })
|
rows.push({ type: "message", messageID: message.id })
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
const ordinals = { text: 0, reasoning: 0 }
|
||||||
message.content.forEach((part) => {
|
message.content.forEach((part) => {
|
||||||
|
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
|
||||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||||
append(rows, { messageID: message.id, partID: part.id }, part)
|
append(rows, { messageID: message.id, partID }, part)
|
||||||
})
|
})
|
||||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) {
|
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
|
||||||
completePrevious(rows)
|
completePrevious(rows)
|
||||||
rows.push({ type: "assistant-footer", messageID: message.id })
|
rows.push({ type: "assistant-footer", messageID: message.id })
|
||||||
}
|
}
|
||||||
|
|
@ -221,6 +235,15 @@ export function reduceSessionRows(messages: SessionMessage[], inputs = new Set<s
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
||||||
|
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
|
||||||
|
if (tool) return tool
|
||||||
|
const match = /^(text|reasoning):(\d+)$/.exec(partID)
|
||||||
|
if (!match) return
|
||||||
|
const ordinal = Number(match[2])
|
||||||
|
return message.content.filter((part) => part.type === match[1])[ordinal]
|
||||||
|
}
|
||||||
|
|
||||||
function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) {
|
function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) {
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
if (exploration(part.name)) {
|
if (exploration(part.name)) {
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,24 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("app.exit prints the session epilogue after scoped cleanup", async () => {
|
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
|
||||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||||
const core = await import("@opentui/core")
|
const core = await import("@opentui/core")
|
||||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||||
|
let initialTitle!: () => void
|
||||||
|
const initialTitleSet = new Promise<void>((resolve) => {
|
||||||
|
initialTitle = resolve
|
||||||
|
})
|
||||||
|
let renamedTitle!: () => void
|
||||||
|
const renamedTitleSet = new Promise<void>((resolve) => {
|
||||||
|
renamedTitle = resolve
|
||||||
|
})
|
||||||
|
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||||
|
setup.renderer.setTerminalTitle = (title) => {
|
||||||
|
if (title === "OC | Demo session") initialTitle()
|
||||||
|
if (title === "OC | Renamed session") renamedTitle()
|
||||||
|
setTitle(title)
|
||||||
|
}
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
if (url.pathname === "/api/session")
|
if (url.pathname === "/api/session")
|
||||||
|
|
@ -100,7 +114,7 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
|
||||||
client: createClient(calls.fetch),
|
client: createClient(calls.fetch),
|
||||||
api: createApi(calls.fetch),
|
api: createApi(calls.fetch),
|
||||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
||||||
args: { continue: true },
|
args: { sessionID: "dummy" },
|
||||||
pluginHost: {
|
pluginHost: {
|
||||||
async start(input) {
|
async start(input) {
|
||||||
api = input.api
|
api = input.api
|
||||||
|
|
@ -112,12 +126,19 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
await ready
|
await ready
|
||||||
await setup.renderOnce()
|
await initialTitleSet
|
||||||
await setup.renderOnce()
|
events.emit({
|
||||||
|
id: "evt_renamed",
|
||||||
|
created: 1,
|
||||||
|
type: "session.renamed",
|
||||||
|
durable: { aggregateID: "dummy", seq: 1, version: 1 },
|
||||||
|
data: { sessionID: "dummy", title: "Renamed session" },
|
||||||
|
})
|
||||||
|
await renamedTitleSet
|
||||||
api?.keymap.dispatchCommand("app.exit")
|
api?.keymap.dispatchCommand("app.exit")
|
||||||
await task
|
await task
|
||||||
|
|
||||||
expect(stdout).toContain("Demo session")
|
expect(stdout).toContain("Renamed session")
|
||||||
expect(stdout).toContain("opencode -s dummy")
|
expect(stdout).toContain("opencode -s dummy")
|
||||||
} finally {
|
} finally {
|
||||||
process.stdout.write = originalWrite
|
process.stdout.write = originalWrite
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ async function setup() {
|
||||||
state: {
|
state: {
|
||||||
session: {
|
session: {
|
||||||
get: (sessionID: string) => sessions[sessionID],
|
get: (sessionID: string) => sessions[sessionID],
|
||||||
|
status: () => ({ type: "busy" }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -96,46 +97,34 @@ function durable(sessionID: string) {
|
||||||
return { aggregateID: sessionID, seq: 0, version: 1 }
|
return { aggregateID: sessionID, seq: 0, version: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
function stepStarted(id: string, sessionID = "session"): V2Event {
|
function executionStarted(id: string, sessionID = "session"): V2Event {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.step.started",
|
type: "session.execution.started",
|
||||||
durable: durable(sessionID),
|
durable: durable(sessionID),
|
||||||
data: {
|
data: { sessionID },
|
||||||
sessionID,
|
|
||||||
assistantMessageID: `msg_${id}`,
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
|
function executionSucceeded(id: string, sessionID = "session"): V2Event {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.step.ended",
|
type: "session.execution.succeeded",
|
||||||
durable: durable(sessionID),
|
durable: durable(sessionID),
|
||||||
data: {
|
data: { sessionID },
|
||||||
sessionID,
|
|
||||||
assistantMessageID: `msg_${id}`,
|
|
||||||
finish,
|
|
||||||
cost: 0,
|
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stepFailed(id: string, sessionID = "session"): V2Event {
|
function executionFailed(id: string, sessionID = "session"): V2Event {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.step.failed",
|
type: "session.execution.failed",
|
||||||
durable: durable(sessionID),
|
durable: durable(sessionID),
|
||||||
data: {
|
data: {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: `msg_${id}`,
|
|
||||||
error: { type: "unknown", message: "boom" },
|
error: { type: "unknown", message: "boom" },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -224,12 +213,12 @@ describe("internal notifications TUI plugin", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
|
test("notifies for terminal lifecycle events even when attached after execution started", async () => {
|
||||||
const harness = await setup()
|
const harness = await setup()
|
||||||
|
|
||||||
harness.emit(stepEnded("event-1"))
|
harness.emit(executionSucceeded("event-1"))
|
||||||
harness.emit(stepStarted("event-2"))
|
harness.emit(executionStarted("event-2"))
|
||||||
harness.emit(stepEnded("event-3"))
|
harness.emit(executionSucceeded("event-3"))
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([
|
expect(harness.notifications).toEqual([
|
||||||
{
|
{
|
||||||
|
|
@ -238,6 +227,12 @@ describe("internal notifications TUI plugin", () => {
|
||||||
notification: { when: "blurred" },
|
notification: { when: "blurred" },
|
||||||
sound: { name: "done", when: "always" },
|
sound: { name: "done", when: "always" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Demo session",
|
||||||
|
message: "Session done",
|
||||||
|
notification: { when: "blurred" },
|
||||||
|
sound: { name: "done", when: "always" },
|
||||||
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -250,8 +245,8 @@ describe("internal notifications TUI plugin", () => {
|
||||||
type: "form.created",
|
type: "form.created",
|
||||||
data: { form: form("form-1", "subagent") },
|
data: { form: form("form-1", "subagent") },
|
||||||
})
|
})
|
||||||
harness.emit(stepStarted("event-2", "subagent"))
|
harness.emit(executionStarted("event-2", "subagent"))
|
||||||
harness.emit(stepEnded("event-3", "subagent"))
|
harness.emit(executionSucceeded("event-3", "subagent"))
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([
|
expect(harness.notifications).toEqual([
|
||||||
{
|
{
|
||||||
|
|
@ -272,14 +267,14 @@ describe("internal notifications TUI plugin", () => {
|
||||||
test("notifies session errors once and suppresses the following idle done notification", async () => {
|
test("notifies session errors once and suppresses the following idle done notification", async () => {
|
||||||
const harness = await setup()
|
const harness = await setup()
|
||||||
|
|
||||||
harness.emit(stepStarted("event-1"))
|
harness.emit(executionStarted("event-1"))
|
||||||
harness.emit(stepFailed("event-2"))
|
harness.emit(executionFailed("event-2"))
|
||||||
harness.emit(stepEnded("event-3"))
|
harness.emit(executionSucceeded("event-3"))
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([
|
expect(harness.notifications).toEqual([
|
||||||
{
|
{
|
||||||
title: "Demo session",
|
title: "Demo session",
|
||||||
message: "Session error",
|
message: "boom",
|
||||||
notification: { when: "blurred" },
|
notification: { when: "blurred" },
|
||||||
sound: { name: "error", when: "always" },
|
sound: { name: "error", when: "always" },
|
||||||
},
|
},
|
||||||
|
|
@ -289,20 +284,21 @@ describe("internal notifications TUI plugin", () => {
|
||||||
test("special-cases aborts and model response timeouts", async () => {
|
test("special-cases aborts and model response timeouts", async () => {
|
||||||
const harness = await setup()
|
const harness = await setup()
|
||||||
|
|
||||||
harness.emit(stepStarted("event-1", "abort"))
|
harness.emit(executionStarted("event-1", "abort"))
|
||||||
harness.emit({
|
harness.emit({
|
||||||
id: "event-2",
|
id: "event-2",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.error",
|
type: "session.error",
|
||||||
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||||
})
|
})
|
||||||
harness.emit(stepStarted("event-3", "timeout"))
|
harness.emit(executionStarted("event-3", "timeout"))
|
||||||
harness.emit({
|
harness.emit({
|
||||||
id: "event-4",
|
id: "event-4",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.error",
|
type: "session.error",
|
||||||
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||||
})
|
})
|
||||||
|
harness.emit(executionFailed("event-5", "timeout"))
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([
|
expect(harness.notifications).toEqual([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { onMount } from "solid-js"
|
||||||
import { ProjectProvider } from "../../../src/context/project"
|
import { ProjectProvider } from "../../../src/context/project"
|
||||||
import { SDKProvider } from "../../../src/context/sdk"
|
import { SDKProvider } from "../../../src/context/sdk"
|
||||||
import { DataProvider, useData } from "../../../src/context/data"
|
import { DataProvider, useData } from "../../../src/context/data"
|
||||||
import { createSessionRows } from "../../../src/routes/session/rows"
|
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||||
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
|
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
|
||||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
|
|
||||||
|
|
@ -177,16 +177,11 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
||||||
|
|
||||||
await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000)
|
await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000)
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_step_started_after_reconnect",
|
id: "evt_execution_started_after_reconnect",
|
||||||
created: 1,
|
created: 1,
|
||||||
type: "session.step.started",
|
type: "session.execution.started",
|
||||||
durable: durable("session-new"),
|
durable: durable("session-new"),
|
||||||
data: {
|
data: { sessionID: "session-new" },
|
||||||
sessionID: "session-new",
|
|
||||||
assistantMessageID: "message-new",
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await wait(() => data.session.status("session-new") === "running")
|
await wait(() => data.session.status("session-new") === "running")
|
||||||
resolveActive(json({ data: {} }))
|
resolveActive(json({ data: {} }))
|
||||||
|
|
@ -326,7 +321,7 @@ test("removes committed revert messages from local state", async () => {
|
||||||
created: 3,
|
created: 3,
|
||||||
type: "session.revert.committed",
|
type: "session.revert.committed",
|
||||||
durable: durable(sessionID, 3),
|
durable: durable(sessionID, 3),
|
||||||
data: { sessionID, messageID: "msg_002" },
|
data: { sessionID, to: "msg_002" },
|
||||||
})
|
})
|
||||||
|
|
||||||
await wait(() => data.session.message.ids(sessionID).length === 1)
|
await wait(() => data.session.message.ids(sessionID).length === 1)
|
||||||
|
|
@ -406,9 +401,11 @@ test("tracks session status from active sessions and execution events", async ()
|
||||||
if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
|
if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
|
||||||
}, events)
|
}, events)
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
|
let rows!: SessionRow[]
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
data = useData()
|
data = useData()
|
||||||
|
rows = createSessionRows(() => "session-retry")
|
||||||
return <box />
|
return <box />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -428,6 +425,15 @@ test("tracks session status from active sessions and execution events", async ()
|
||||||
await wait(() => data.session.status("session-active") === "running")
|
await wait(() => data.session.status("session-active") === "running")
|
||||||
expect(data.session.status("session-idle")).toBe("idle")
|
expect(data.session.status("session-idle")).toBe("idle")
|
||||||
|
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_execution_started",
|
||||||
|
created: 0,
|
||||||
|
type: "session.execution.started",
|
||||||
|
durable: durable("session-live"),
|
||||||
|
data: { sessionID: "session-live" },
|
||||||
|
})
|
||||||
|
await wait(() => data.session.status("session-live") === "running")
|
||||||
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_step_started",
|
id: "evt_step_started",
|
||||||
created: 0,
|
created: 0,
|
||||||
|
|
@ -440,8 +446,6 @@ test("tracks session status from active sessions and execution events", async ()
|
||||||
model: { id: "model", providerID: "provider" },
|
model: { id: "model", providerID: "provider" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await wait(() => data.session.status("session-live") === "running")
|
|
||||||
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_step_ended",
|
id: "evt_step_ended",
|
||||||
created: 0,
|
created: 0,
|
||||||
|
|
@ -462,16 +466,23 @@ test("tracks session status from active sessions and execution events", async ()
|
||||||
expect(data.session.status("session-live")).toBe("running")
|
expect(data.session.status("session-live")).toBe("running")
|
||||||
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_execution_settled",
|
id: "evt_execution_succeeded",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.succeeded",
|
||||||
data: {
|
durable: durable("session-live", 1, 3),
|
||||||
sessionID: "session-live",
|
data: { sessionID: "session-live" },
|
||||||
outcome: "success",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await wait(() => data.session.status("session-live") === "idle")
|
await wait(() => data.session.status("session-live") === "idle")
|
||||||
|
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_failed_execution_started",
|
||||||
|
created: 0,
|
||||||
|
type: "session.execution.started",
|
||||||
|
durable: durable("session-failed"),
|
||||||
|
data: { sessionID: "session-failed" },
|
||||||
|
})
|
||||||
|
await wait(() => data.session.status("session-failed") === "running")
|
||||||
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_failed_step_started",
|
id: "evt_failed_step_started",
|
||||||
created: 0,
|
created: 0,
|
||||||
|
|
@ -484,8 +495,6 @@ test("tracks session status from active sessions and execution events", async ()
|
||||||
model: { id: "model", providerID: "provider" },
|
model: { id: "model", providerID: "provider" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await wait(() => data.session.status("session-failed") === "running")
|
|
||||||
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_step_failed",
|
id: "evt_step_failed",
|
||||||
created: 0,
|
created: 0,
|
||||||
|
|
@ -494,26 +503,146 @@ test("tracks session status from active sessions and execution events", async ()
|
||||||
data: {
|
data: {
|
||||||
sessionID: "session-failed",
|
sessionID: "session-failed",
|
||||||
assistantMessageID: "message-failed",
|
assistantMessageID: "message-failed",
|
||||||
error: { type: "unknown", message: "Provider unavailable" },
|
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await wait(() => {
|
await wait(() => {
|
||||||
const assistant = data.session.message.get("session-failed", "message-failed")
|
const assistant = data.session.message.get("session-failed", "message-failed")
|
||||||
return assistant?.type === "assistant" && assistant.finish === "error"
|
return (
|
||||||
|
assistant?.type === "assistant" &&
|
||||||
|
assistant.finish === "error" &&
|
||||||
|
assistant.error?.type === "provider.content-filter"
|
||||||
|
)
|
||||||
})
|
})
|
||||||
expect(data.session.status("session-failed")).toBe("running")
|
expect(data.session.status("session-failed")).toBe("running")
|
||||||
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_failed_execution_settled",
|
id: "evt_failed_execution_failed",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.execution.settled",
|
type: "session.execution.failed",
|
||||||
|
durable: durable("session-failed", 1, 3),
|
||||||
data: {
|
data: {
|
||||||
sessionID: "session-failed",
|
sessionID: "session-failed",
|
||||||
outcome: "failure",
|
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||||
error: { type: "unknown", message: "Provider unavailable" },
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await wait(() => data.session.status("session-failed") === "idle")
|
await wait(() => data.session.status("session-failed") === "idle")
|
||||||
|
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_retry_execution_started",
|
||||||
|
created: 0,
|
||||||
|
type: "session.execution.started",
|
||||||
|
durable: durable("session-retry"),
|
||||||
|
data: { sessionID: "session-retry" },
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_retry_step_started",
|
||||||
|
created: 0,
|
||||||
|
type: "session.step.started",
|
||||||
|
durable: durable("session-retry", 1, 2),
|
||||||
|
data: {
|
||||||
|
sessionID: "session-retry",
|
||||||
|
assistantMessageID: "message-retry",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_retry_scheduled",
|
||||||
|
created: 0,
|
||||||
|
type: "session.retry.scheduled",
|
||||||
|
durable: durable("session-retry", 1, 3),
|
||||||
|
data: {
|
||||||
|
sessionID: "session-retry",
|
||||||
|
assistantMessageID: "message-retry",
|
||||||
|
attempt: 2,
|
||||||
|
at: 2_000,
|
||||||
|
error: { type: "provider.transport", message: "Disconnected" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wait(() => {
|
||||||
|
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||||
|
return assistant?.type === "assistant" && assistant.retry?.attempt === 2
|
||||||
|
})
|
||||||
|
await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_retry_next_step",
|
||||||
|
created: 2_000,
|
||||||
|
type: "session.step.started",
|
||||||
|
durable: durable("session-retry", 1, 4),
|
||||||
|
data: {
|
||||||
|
sessionID: "session-retry",
|
||||||
|
assistantMessageID: "message-retry",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wait(() => {
|
||||||
|
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||||
|
return assistant?.type === "assistant" && assistant.retry === undefined
|
||||||
|
})
|
||||||
|
await wait(() => !rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
|
||||||
|
expect(data.session.message.list("session-retry").filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_retry_scheduled_again",
|
||||||
|
created: 2_000,
|
||||||
|
type: "session.retry.scheduled",
|
||||||
|
durable: durable("session-retry", 1, 5),
|
||||||
|
data: {
|
||||||
|
sessionID: "session-retry",
|
||||||
|
assistantMessageID: "message-retry",
|
||||||
|
attempt: 3,
|
||||||
|
at: 6_000,
|
||||||
|
error: { type: "provider.transport", message: "Disconnected again" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wait(() => {
|
||||||
|
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||||
|
return assistant?.type === "assistant" && assistant.retry?.attempt === 3
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_retry_interrupted",
|
||||||
|
created: 2_000,
|
||||||
|
type: "session.execution.interrupted",
|
||||||
|
durable: durable("session-retry", 1, 6),
|
||||||
|
data: { sessionID: "session-retry", reason: "shutdown" },
|
||||||
|
})
|
||||||
|
await wait(() => data.session.status("session-retry") === "idle")
|
||||||
|
expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry")
|
||||||
|
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_compaction_started",
|
||||||
|
created: 0,
|
||||||
|
type: "session.compaction.started",
|
||||||
|
durable: durable("session-live", 2),
|
||||||
|
data: { sessionID: "session-live", reason: "auto" },
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_compaction_delta_1",
|
||||||
|
created: 0,
|
||||||
|
type: "session.compaction.delta",
|
||||||
|
data: { sessionID: "session-live", text: "Live " },
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_compaction_delta_2",
|
||||||
|
created: 0,
|
||||||
|
type: "session.compaction.delta",
|
||||||
|
data: { sessionID: "session-live", text: "summary" },
|
||||||
|
})
|
||||||
|
await wait(() => data.session.compaction("session-live") === "Live summary")
|
||||||
|
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_compaction_ended",
|
||||||
|
created: 0,
|
||||||
|
type: "session.compaction.ended",
|
||||||
|
durable: durable("session-live", 3),
|
||||||
|
data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" },
|
||||||
|
})
|
||||||
|
await wait(() => data.session.compaction("session-live") === undefined)
|
||||||
|
expect(data.session.message.get("session-live", "msg_compaction_ended")).toMatchObject({
|
||||||
|
type: "compaction",
|
||||||
|
summary: "Live summary",
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|
@ -1020,9 +1149,9 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
assistantMessageID: "msg_explicit_assistant_9",
|
assistantMessageID: "msg_explicit_assistant_9",
|
||||||
callID: "call-1",
|
callID: "call-1",
|
||||||
tool: "bash",
|
|
||||||
input: {},
|
input: {},
|
||||||
provider: { executed: false, metadata: { fake: { call: true } } },
|
executed: false,
|
||||||
|
state: { call: true },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
|
|
@ -1035,7 +1164,8 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||||
assistantMessageID: "msg_explicit_assistant_9",
|
assistantMessageID: "msg_explicit_assistant_9",
|
||||||
callID: "call-1",
|
callID: "call-1",
|
||||||
error: { type: "unknown", message: "aborted" },
|
error: { type: "unknown", message: "aborted" },
|
||||||
provider: { executed: false, metadata: { fake: { result: true } } },
|
executed: false,
|
||||||
|
resultState: { result: true },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1061,11 +1191,9 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||||
expect(tool.state.input).toEqual({})
|
expect(tool.state.input).toEqual({})
|
||||||
expect(tool.state.structured).toEqual({})
|
expect(tool.state.structured).toEqual({})
|
||||||
expect(tool.state.content).toEqual([])
|
expect(tool.state.content).toEqual([])
|
||||||
expect(tool.provider).toEqual({
|
expect(tool.executed).toBe(false)
|
||||||
executed: false,
|
expect(tool.providerState).toEqual({ call: true })
|
||||||
metadata: { fake: { call: true } },
|
expect(tool.providerResultState).toEqual({ result: true })
|
||||||
resultMetadata: { fake: { result: true } },
|
|
||||||
})
|
|
||||||
expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([
|
expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([
|
||||||
"agent-switched",
|
"agent-switched",
|
||||||
"model-switched",
|
"model-switched",
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,19 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
||||||
const messages: SessionMessage[] = [
|
const messages: SessionMessage[] = [
|
||||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||||
assistant("assistant-1", [
|
assistant("assistant-1", [
|
||||||
{ type: "text", id: "text-1", text: "Looking" },
|
{ type: "text", text: "Looking" },
|
||||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||||
{ type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } },
|
{ type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } },
|
||||||
]),
|
]),
|
||||||
assistant("assistant-2", [
|
assistant("assistant-2", [
|
||||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } },
|
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } },
|
||||||
{ type: "text", id: "text-2", text: "Done" },
|
{ type: "text", text: "Done" },
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
|
|
||||||
expect(reduceSessionRows(messages)).toEqual([
|
expect(reduceSessionRows(messages)).toEqual([
|
||||||
{ type: "message", messageID: "user-1" },
|
{ type: "message", messageID: "user-1" },
|
||||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text-1" } },
|
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||||
{
|
{
|
||||||
type: "group",
|
type: "group",
|
||||||
kind: "exploration",
|
kind: "exploration",
|
||||||
|
|
@ -30,7 +30,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
||||||
{ messageID: "assistant-2", partID: "grep-1" },
|
{ messageID: "assistant-2", partID: "grep-1" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text-2" } },
|
{ type: "part", ref: { messageID: "assistant-2", partID: "text:0" } },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -62,20 +62,38 @@ test("keeps non-exploration tools as individual part rows", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("assigns stable kind ordinals within an assistant message", () => {
|
||||||
|
const messages: SessionMessage[] = [
|
||||||
|
assistant("assistant-1", [
|
||||||
|
{ type: "text", text: "First" },
|
||||||
|
{ type: "reasoning", text: "Think" },
|
||||||
|
{ type: "text", text: "Second" },
|
||||||
|
{ type: "reasoning", text: "Check" },
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(reduceSessionRows(messages)).toEqual([
|
||||||
|
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||||
|
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
||||||
|
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
|
||||||
|
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:1" } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("groups across empty assistant reasoning parts", () => {
|
test("groups across empty assistant reasoning parts", () => {
|
||||||
const messages: SessionMessage[] = [
|
const messages: SessionMessage[] = [
|
||||||
assistant("assistant-1", [
|
assistant("assistant-1", [
|
||||||
{ type: "reasoning", id: "reasoning-1", text: "Looking" },
|
{ type: "reasoning", text: "Looking" },
|
||||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||||
]),
|
]),
|
||||||
assistant("assistant-2", [
|
assistant("assistant-2", [
|
||||||
{ type: "reasoning", id: "reasoning-2", text: "" },
|
{ type: "reasoning", text: "" },
|
||||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
|
|
||||||
expect(reduceSessionRows(messages)).toEqual([
|
expect(reduceSessionRows(messages)).toEqual([
|
||||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } },
|
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
||||||
{
|
{
|
||||||
type: "group",
|
type: "group",
|
||||||
kind: "exploration",
|
kind: "exploration",
|
||||||
|
|
@ -95,9 +113,7 @@ test("completes exploration groups when another row follows", () => {
|
||||||
])
|
])
|
||||||
finished.finish = "stop"
|
finished.finish = "stop"
|
||||||
const messages: SessionMessage[] = [
|
const messages: SessionMessage[] = [
|
||||||
assistant("assistant-1", [
|
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
|
||||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
|
|
||||||
]),
|
|
||||||
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
|
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
|
||||||
finished,
|
finished,
|
||||||
]
|
]
|
||||||
|
|
@ -182,6 +198,17 @@ test("renders synthetic messages with descriptions", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("renders a footer for a pre-output retry assistant after replay", () => {
|
||||||
|
const message = assistant("assistant-retry", [])
|
||||||
|
message.retry = {
|
||||||
|
attempt: 2,
|
||||||
|
at: 2_000,
|
||||||
|
error: { type: "provider.transport", message: "Disconnected" },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
|
||||||
|
})
|
||||||
|
|
||||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||||
return {
|
return {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,28 @@ Compatibility:
|
||||||
- Existing changelog entries retain the names that were accurate when those changes occurred.
|
- Existing changelog entries retain the names that were accurate when those changes occurred.
|
||||||
- Behavior is unchanged: this is a vocabulary and contract rename only.
|
- Behavior is unchanged: this is a vocabulary and contract rename only.
|
||||||
|
|
||||||
|
## 2026-07-04: Canonicalize Generated Shell Type Name
|
||||||
|
|
||||||
|
- Collapse the legacy JavaScript SDK generator's equivalent `Shell1V2` component into the canonical `ShellV2` contract.
|
||||||
|
|
||||||
|
Compatibility:
|
||||||
|
|
||||||
|
- The Shell wire shape is unchanged. Generated event and endpoint types now consistently reference `ShellV2`.
|
||||||
|
|
||||||
|
## 2026-07-03: Add Execution Lifecycle, Retry, And Structured Session Errors
|
||||||
|
|
||||||
|
- Replace live-only `session.execution.settled` and unused `session.retried` with durable v1 `session.execution.started`, `session.execution.succeeded`, `session.execution.failed`, `session.execution.interrupted`, and `session.retry.scheduled` events.
|
||||||
|
- Add an open `SessionError` wire envelope with dot-cased type values and the browser-safe `FinishReason` contract.
|
||||||
|
- Project retry state onto the current assistant and classify content-filter finishes as failed steps.
|
||||||
|
- Reuse one projected assistant across pre-output retry steps; each provider call remains a distinct step and consumes agent allowance.
|
||||||
|
|
||||||
|
Compatibility:
|
||||||
|
|
||||||
|
- Experimental V2 event, sequence, input, and message-projection rows are reset. Durable event contracts restart at v1.
|
||||||
|
- `SessionError.type` remains an open string so new error classifications do not require event-version or database migrations. Unknown fields are ignored by older decoders; richer public details require a separate compatibility design.
|
||||||
|
- Execution lifecycle events are historical observations of one process-local coordinator busy period. Unmatched starts never establish current liveness or recovery work; `/api/session/active` remains the current-process liveness authority.
|
||||||
|
- Scheduled retries are historical UI state after a crash and never trigger provider recovery.
|
||||||
|
|
||||||
## 2026-07-03: Require Durable Envelope On Durable Events
|
## 2026-07-03: Require Durable Envelope On Durable Events
|
||||||
|
|
||||||
- Make the wire `durable` envelope required on durable event definitions.
|
- Make the wire `durable` envelope required on durable event definitions.
|
||||||
|
|
@ -952,3 +974,17 @@ Compatibility:
|
||||||
Compatibility:
|
Compatibility:
|
||||||
|
|
||||||
- V2 durable events and projections are experimental and are reset by `20260703190000_reset_v2_shell_event_payloads`; existing V2 event rows, event sequences, projected session messages, and admitted inputs are wiped.
|
- V2 durable events and projections are experimental and are reset by `20260703190000_reset_v2_shell_event_payloads`; existing V2 event rows, event sequences, projected session messages, and admitted inputs are wiped.
|
||||||
|
|
||||||
|
## 2026-07-03: Simplify Assistant Fragments And Provider State
|
||||||
|
|
||||||
|
- Remove provider block IDs from current Session text and reasoning event payloads and projected content. A Session assistant step allows at most one open fragment of each kind; events carry a Session-assigned kind-specific ordinal so live updates and hydrated projections share the stable `(assistantMessageID, kind, ordinal)` reference. Tool correlation continues to use `callID`.
|
||||||
|
- Replace nested `providerMetadata` with opaque, provider-un-nested `state` at the Session boundary. Reasoning uses `state`; tool calls use `state`, settlements use `resultState`, and projected tools expose `providerState` and `providerResultState`. Replay re-nests state under the selected provider only for the same successful model.
|
||||||
|
- Flatten `executed` on tool events and projected tools, and remove the redundant tool name from `session.tool.called` because `session.tool.input.started` owns it.
|
||||||
|
- Rename `session.revert.committed.messageID` to `to`, paired with `session.forked.from`.
|
||||||
|
- Publish live-only `session.compaction.delta` events for accepted summary chunks while `session.compaction.ended.1` remains the durable full summary.
|
||||||
|
- OpenAI Responses closes output text on `response.output_text.done` or its message `response.output_item.done` boundary. Provider documentation and recorded stream shapes show sequential output items, not valid overlapping text or reasoning blocks of the same kind.
|
||||||
|
|
||||||
|
Compatibility:
|
||||||
|
|
||||||
|
- All changed durable definitions remain version 1. `20260703200000_reset_v2_session_events` performs the single reset for this Session event contract update, wiping experimental V2 events, sequences, projected messages, and admitted inputs.
|
||||||
|
- Promise, Effect, and legacy JavaScript SDK surfaces are regenerated from the simplified schemas.
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ sessions.prompt({ id?, sessionID, prompt, delivery?, resume? })
|
||||||
|
|
||||||
sessions.interrupt(sessionID)
|
sessions.interrupt(sessionID)
|
||||||
-> interrupts active execution on this process
|
-> interrupts active execution on this process
|
||||||
-> waits for runner cleanup and settlement
|
-> waits for runner cleanup and a terminal lifecycle observation
|
||||||
-> clears a coalesced follow-up wake already registered with this coordinator
|
-> clears a coalesced follow-up wake already registered with this coordinator
|
||||||
-> preserves durable inbox rows for a later wake or resume
|
-> preserves durable inbox rows for a later wake or resume
|
||||||
-> idle or missing Session is a no-op
|
-> idle or missing Session is a no-op
|
||||||
|
|
@ -47,7 +47,15 @@ SessionExecution.resume(sessionID)
|
||||||
|
|
||||||
`SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
`SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||||
|
|
||||||
The local runner issues one explicit `llm.stream(request)` per step, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, and reloads projected history once before continuation. Promoting any new user input resets the selected agent's configured step allowance; multiple steers promoted at one boundary reset it once. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across steps. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed.
|
The local runner issues one explicit `llm.stream(request)` per step, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every owned tool fiber after provider-stream closure, and reloads projected history once before continuation. For every in-process step, `session.step.started` precedes its tool calls, every local and hosted call settles as `session.tool.success` or `session.tool.failed`, and only then may the runner publish the single terminal `session.step.ended` or `session.step.failed`. Streamed provider-error evidence is retained until this closeout; thrown provider failures and interruption use the same settlement-first ordering. Promoting any new user input resets the selected agent's configured step allowance; multiple steers promoted at one boundary reset it once.
|
||||||
|
|
||||||
|
`callID` is unique only within its owning step, not across the Session. Tool events therefore carry `assistantMessageID`, and consumers correlate a call through the step that owns that assistant message rather than inventing a synthetic composite key. Before assembling a provider request, the runner's cross-drain `failInterruptedTools` recovery durably fails any tool still projected as pending or running from a previous process with `Tool execution interrupted`. This orphan-recovery sweep is the explicit nesting exception: it occurs in a later drain, but attributes every settlement to the original `assistantMessageID`; abandoned side effects are never silently replayed.
|
||||||
|
|
||||||
|
`session.execution.started.1` and exactly one of `session.execution.succeeded.1`, `session.execution.failed.1`, or `session.execution.interrupted.1` observe one process-local coordinator busy period, including coalesced drains and joined resumes. These durable rows are history, not a durable execution identity: replay must never infer current liveness, recovery, grouping, or resumability from an unmatched start. A drain has no durable identity or transcript boundary. `/api/session/active` is the authority for current process-local liveness, and is empty after restart. User interruption records `reason: "user"`; owner-scope interruption defaults to `"shutdown"`; `"superseded"` is reserved for explicit replacement.
|
||||||
|
|
||||||
|
Core retries only typed rate-limit, provider-internal, and transport failures before durable assistant text, reasoning, tool-call, tool-output, or tool-execution evidence. The initial call plus at most four retries use two-second exponential backoff, raised when a provider's `retryAfterMs` is larger. Every retry attempt remains a distinct step and consumes the selected agent's step allowance, while all pre-output attempts reuse one assistant message ID so retry state never creates empty transcript messages. Repeated `session.step.started.1` facts reopen that assistant projection idempotently. `session.retry.scheduled.1` is committed before each delay with the upcoming one-based attempt and absolute epoch-millisecond time, then projects onto `Assistant.retry`. The next `session.step.started.1` or terminal failure/interruption clears it. A scheduled retry surviving a crash is historical UI state only and never triggers recovery.
|
||||||
|
|
||||||
|
A normalized `step-finish` with `content-filter` publishes `session.step.failed.1` with `provider.content-filter`, never `session.step.ended.1`. Any partial streamed content remains visible; a contentless filtered response still has a failed assistant projection.
|
||||||
|
|
||||||
Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted.
|
Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted.
|
||||||
|
|
||||||
|
|
@ -124,8 +132,14 @@ Before each step, the runner estimates the complete model-visible request and co
|
||||||
|
|
||||||
Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes.
|
Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes.
|
||||||
|
|
||||||
|
The rolling summary is a continuation checkpoint with this complete heading order: `Objective`, `Important Details`, `Work State`, and `Next Move`. `Work State` records completed, active, and blocked work, while `Next Move` records the immediate and following actions. Every heading remains present even when its value is `(none)`.
|
||||||
|
|
||||||
`session.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next physical attempt, the runner observes that completed compaction and directly renders a fresh instruction baseline through `InstructionCheckpoint`. A failed or interrupted attempt therefore leaves the previous history boundary active.
|
`session.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next physical attempt, the runner observes that completed compaction and directly renders a fresh instruction baseline through `InstructionCheckpoint`. A failed or interrupted attempt therefore leaves the previous history boundary active.
|
||||||
|
|
||||||
|
Assistant text and reasoning follow a strict `started` / live-only `delta` / durable full-value `ended` lifecycle. A publisher permits at most one open fragment of each kind in a step and fails on a second start before the matching end. Provider block IDs remain internal to LLM adapters; each fragment event carries a Session-assigned kind-specific ordinal, matching the ordinal derived from projected content. UI identity is therefore the assistant message ID plus content kind and ordinal. Tool calls retain step-scoped `callID` because settlements and provider replay correlate through it.
|
||||||
|
|
||||||
|
Provider continuation state is opaque and un-nested at the Session boundary. The publisher selects only the active model provider's entry from LLM provider metadata. Same-model replay re-nests that state under the current provider; model switches and failed assistant steps continue to suppress provider-native continuation state.
|
||||||
|
|
||||||
Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending step.
|
Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending step.
|
||||||
|
|
||||||
When a provider rejects a request as context overflow before durable assistant output or tool execution, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical step with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up.
|
When a provider rejects a request as context overflow before durable assistant output or tool execution, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical step with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up.
|
||||||
|
|
@ -160,7 +174,7 @@ Status: `complete` is usable in the native V2 path, `partial` covers only part o
|
||||||
| Prompt/reference expansion | Configured-reference expansion | missing | Resolve aliases and emit durable model-visible reference context or failures. |
|
| Prompt/reference expansion | Configured-reference expansion | missing | Resolve aliases and emit durable model-visible reference context or failures. |
|
||||||
| Prompt/reference expansion | Native synthetic expansion replay | partial | V2 replays synthetic messages but only the V1 compatibility path creates them. |
|
| Prompt/reference expansion | Native synthetic expansion replay | partial | V2 replays synthetic messages but only the V1 compatibility path creates them. |
|
||||||
|
|
||||||
Provider timeout, retry, and watchdog policy is intentionally deferred. The runner does not impose a universal provider-stream inactivity or absolute timeout. A future slice should design configurable policy around provider behavior, durable failure reporting, and local drain-chain release rather than hardcoding one default for every provider.
|
Provider timeout and watchdog policy is intentionally deferred. Retry tuning beyond the narrow safe policy above remains separate work; the runner does not impose a universal provider-stream inactivity or absolute timeout.
|
||||||
|
|
||||||
Inbox delivery is explicit:
|
Inbox delivery is explicit:
|
||||||
|
|
||||||
|
|
@ -174,7 +188,7 @@ Execution has two entry points:
|
||||||
|
|
||||||
Post-crash continuation recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model provider-dispatch ambiguity, required continuation, queued-input promotion, retry policy, and visible recovery status together. It must not assume an enclosing durable execution identity that the Session model does not otherwise need.
|
Post-crash continuation recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model provider-dispatch ambiguity, required continuation, queued-input promotion, retry policy, and visible recovery status together. It must not assume an enclosing durable execution identity that the Session model does not otherwise need.
|
||||||
|
|
||||||
A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new step against that Location.
|
A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new step against that Location. Its durable lifecycle events are historical observations only; they do not replace the coordinator's process-local active registry.
|
||||||
|
|
||||||
The coordinator's active registry is also the source for `sessions.active()`. It represents only foreground Session drains owned by the current process; background subagents and tasks do not add parent Sessions to this registry. The snapshot is runtime state and is empty after a process restart.
|
The coordinator's active registry is also the source for `sessions.active()`. It represents only foreground Session drains owned by the current process; background subagents and tasks do not add parent Sessions to this registry. The snapshot is runtime state and is empty after a process restart.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ through legacy `SessionPrompt.loop(...)`:
|
||||||
and issues one explicit `llm.stream(request)` step at a time
|
and issues one explicit `llm.stream(request)` step at a time
|
||||||
- durable V2 projections record text, reasoning, provider failures, tool calls,
|
- durable V2 projections record text, reasoning, provider failures, tool calls,
|
||||||
tool results, and assistant output
|
tool results, and assistant output
|
||||||
|
- owned local tool fibers and unresolved hosted calls settle before their step's
|
||||||
|
single terminal event; cross-drain orphan recovery retains original assistant attribution
|
||||||
- a scoped `ToolRegistry` advertises definitions and the first permission-checked
|
- a scoped `ToolRegistry` advertises definitions and the first permission-checked
|
||||||
`read` built-in
|
`read` built-in
|
||||||
- local continuation reloads projected history, and promoting new user input resets the selected agent's configured step allowance
|
- local continuation reloads projected history, and promoting new user input resets the selected agent's configured step allowance
|
||||||
|
|
@ -37,9 +39,6 @@ a FIFO until the Session would otherwise become idle and then promote one at a t
|
||||||
|
|
||||||
Next reviewed slices:
|
Next reviewed slices:
|
||||||
|
|
||||||
- preserve eager structured local-tool settlement: durably record each complete
|
|
||||||
call, start its child execution immediately, await every settlement after the
|
|
||||||
step closes, then reload projected history once
|
|
||||||
- revisit per-step tool-call limits, output truncation, and operational
|
- revisit per-step tool-call limits, output truncation, and operational
|
||||||
backpressure before broadening exposure; eager local execution is deliberately
|
backpressure before broadening exposure; eager local execution is deliberately
|
||||||
unbounded in the current local slice while SQLite publication stays serialized
|
unbounded in the current local slice while SQLite publication stays serialized
|
||||||
|
|
@ -55,7 +54,7 @@ Next reviewed slices:
|
||||||
|
|
||||||
### Deferred durable continuation recovery
|
### Deferred durable continuation recovery
|
||||||
|
|
||||||
Do not infer that ambiguous provider work is safe to retry from an advisory wake.
|
Do not infer that ambiguous provider work is safe to retry from an advisory wake, an unmatched historical `session.execution.started` event, or a surviving `session.retry.scheduled` projection.
|
||||||
The first inbox-driven runner intentionally omits outer physical-attempt markers
|
The first inbox-driven runner intentionally omits outer physical-attempt markers
|
||||||
until they have a concrete consumer and a complete recovery policy.
|
until they have a concrete consumer and a complete recovery policy.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue