refactor(schema): simplify session fragment state

This commit is contained in:
Kit Langton
2026-07-03 23:12:11 -04:00
parent 8f4b62eb49
commit 7ef42ac296
38 changed files with 834 additions and 607 deletions
+39 -86
View File
@@ -1007,23 +1007,20 @@ export type SessionContextOutput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly id: string; readonly text: string }
| { readonly type: "text"; readonly text: string }
| {
readonly type: "reasoning"
readonly id: 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 type: "tool"
readonly id: string
readonly name: string
readonly provider?: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "pending"; readonly input: string }
| {
@@ -1353,7 +1350,7 @@ export type SessionLogOutput =
readonly type: "session.text.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 assistantMessageID: string; readonly textID: string }
readonly data: { readonly sessionID: string; readonly assistantMessageID: string }
}
| {
readonly id: string
@@ -1362,12 +1359,7 @@ export type SessionLogOutput =
readonly type: "session.text.ended"
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 assistantMessageID: string
readonly textID: string
readonly text: string
}
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly text: string }
}
| {
readonly id: string
@@ -1379,8 +1371,7 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
readonly state?: { readonly [x: string]: unknown }
}
}
| {
@@ -1393,9 +1384,8 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
readonly state?: { readonly [x: string]: unknown }
}
}
| {
@@ -1437,12 +1427,9 @@ export type SessionLogOutput =
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly tool: string
readonly input: { readonly [x: string]: unknown }
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
readonly executed: boolean
readonly state?: { readonly [x: string]: unknown }
}
}
| {
@@ -1481,10 +1468,8 @@ export type SessionLogOutput =
>
readonly outputPaths?: ReadonlyArray<string>
readonly result?: unknown
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
readonly executed: boolean
readonly resultState?: { readonly [x: string]: unknown }
}
}
| {
@@ -1500,10 +1485,8 @@ export type SessionLogOutput =
readonly callID: string
readonly error: { readonly type: "unknown"; readonly message: string }
readonly result?: unknown
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
readonly executed: boolean
readonly resultState?: { readonly [x: string]: unknown }
}
}
| {
@@ -1589,7 +1572,7 @@ export type SessionLogOutput =
readonly type: "session.revert.committed"
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 messageID: string }
readonly data: { readonly sessionID: string; readonly to: string }
}
)
| { readonly type: "log.synced"; readonly aggregateID: string; readonly seq?: number }
@@ -1701,23 +1684,20 @@ export type SessionMessageOutput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly id: string; readonly text: string }
| { readonly type: "text"; readonly text: string }
| {
readonly type: "reasoning"
readonly id: 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 type: "tool"
readonly id: string
readonly name: string
readonly provider?: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "pending"; readonly input: string }
| {
@@ -1901,23 +1881,20 @@ export type MessageListOutput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly id: string; readonly text: string }
| { readonly type: "text"; readonly text: string }
| {
readonly type: "reasoning"
readonly id: 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 type: "tool"
readonly id: string
readonly name: string
readonly provider?: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "pending"; readonly input: string }
| {
@@ -4637,7 +4614,7 @@ export type EventSubscribeOutput =
readonly type: "session.text.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 assistantMessageID: string; readonly textID: string }
readonly data: { readonly sessionID: string; readonly assistantMessageID: string }
}
| {
readonly id: string
@@ -4645,12 +4622,7 @@ export type EventSubscribeOutput =
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.text.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly textID: string
readonly delta: string
}
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly delta: string }
}
| {
readonly id: string
@@ -4659,12 +4631,7 @@ export type EventSubscribeOutput =
readonly type: "session.text.ended"
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 assistantMessageID: string
readonly textID: string
readonly text: string
}
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly text: string }
}
| {
readonly id: string
@@ -4676,8 +4643,7 @@ export type EventSubscribeOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
readonly state?: { readonly [x: string]: unknown }
}
}
| {
@@ -4686,12 +4652,7 @@ export type EventSubscribeOutput =
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.reasoning.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly delta: string
}
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly delta: string }
}
| {
readonly id: string
@@ -4703,9 +4664,8 @@ export type EventSubscribeOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
readonly state?: { readonly [x: string]: unknown }
}
}
| {
@@ -4760,12 +4720,9 @@ export type EventSubscribeOutput =
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly tool: string
readonly input: { readonly [x: string]: unknown }
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
readonly executed: boolean
readonly state?: { readonly [x: string]: unknown }
}
}
| {
@@ -4804,10 +4761,8 @@ export type EventSubscribeOutput =
>
readonly outputPaths?: ReadonlyArray<string>
readonly result?: unknown
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
readonly executed: boolean
readonly resultState?: { readonly [x: string]: unknown }
}
}
| {
@@ -4823,10 +4778,8 @@ export type EventSubscribeOutput =
readonly callID: string
readonly error: { readonly type: "unknown"; readonly message: string }
readonly result?: unknown
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
readonly executed: boolean
readonly resultState?: { readonly [x: string]: unknown }
}
}
| {
@@ -4920,7 +4873,7 @@ export type EventSubscribeOutput =
readonly type: "session.revert.committed"
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 messageID: string }
readonly data: { readonly sessionID: string; readonly to: string }
}
| {
readonly id: string
+1
View File
@@ -44,5 +44,6 @@ export const migrations = (
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
import("./migration/20260703200000_reset_v2_event_fragments"),
])
).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_event_fragments",
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
+7 -1
View File
@@ -225,7 +225,13 @@ const make = (dependencies: Dependencies) => {
.pipe(
Stream.runForEach((event) => {
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
}),
Effect.as(true),
+17 -25
View File
@@ -99,11 +99,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
)
const latestText = (assistant: DraftAssistant | undefined, textID: string) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text" && item.id === textID)
const latestText = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text")
const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID)
const latestReasoning = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
Effect.gen(function* () {
@@ -249,20 +249,18 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
)
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
})
},
"session.text.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID)
const match = latestText(draft)
if (match) match.text += event.data.delta
})
},
"session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID)
const match = latestText(draft)
if (match) match.text = event.data.text
})
},
@@ -292,7 +290,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match) {
match.provider = event.data.provider
match.executed = event.data.executed
match.providerState = event.data.state
match.time.ran = event.created
match.state = castDraft(
SessionMessage.ToolStateRunning.make({
@@ -318,11 +317,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
match.state = castDraft(
SessionMessage.ToolStateCompleted.make({
@@ -341,11 +337,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "pending" || match.state.status === "running")) {
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
match.state = castDraft(
SessionMessage.ToolStateError.make({
@@ -366,9 +359,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
castDraft(
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
state: event.data.state,
time: { created: event.created },
}),
),
@@ -377,17 +369,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.reasoning.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
const match = latestReasoning(draft)
if (match) match.text += event.data.delta
})
},
"session.reasoning.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
const match = latestReasoning(draft)
if (match) {
match.text = event.data.text
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
}
})
},
+2 -5
View File
@@ -687,14 +687,11 @@ const layer = Layer.effectDiscard(
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.id, event.data.messageID),
),
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.to)),
)
.get()
.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
.delete(SessionMessageTable)
.where(
+1 -1
View File
@@ -113,6 +113,6 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (session: Sess
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id,
messageID: session.revert.messageID,
to: session.revert.messageID,
})
})
+2 -4
View File
@@ -137,10 +137,7 @@ const layer = Layer.effect(
assistantMessageID: message.id,
callID: tool.id,
error: { type: "unknown", message: "Tool execution interrupted" },
provider: {
executed: tool.provider?.executed === true,
...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }),
},
executed: tool.executed === true,
})
}
}
@@ -228,6 +225,7 @@ const layer = Layer.effect(
// 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: resolved.ref,
provider: model.provider,
snapshot: startSnapshot,
})
const publication = Semaphore.makeUnsafe(1)
@@ -1,5 +1,5 @@
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 { ModelV2 } from "../../model"
import { SessionEvent } from "../event"
@@ -10,6 +10,7 @@ type Input = {
readonly sessionID: SessionSchema.ID
readonly agent: string
readonly model: ModelV2.Ref
readonly provider: string
readonly snapshot?: string
}
@@ -61,10 +62,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
called: boolean
settled: boolean
providerExecuted: boolean
providerMetadata?: ProviderMetadata
providerState?: Record<string, unknown>
}
>()
const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined
let assistantActive = false
let assistantFailed = false
@@ -86,15 +86,18 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID === undefined
? Effect.die(new Error("Tool event before assistant step start"))
: Effect.succeed(assistantMessageID)
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider]
const fragments = (
name: string,
ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>,
ended: (id: string, value: string, state?: Record<string, unknown>) => Effect.Effect<void>,
single = false,
) => {
const chunks = new Map<string, string[]>()
const start = (id: string) =>
Effect.suspend(() => {
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
chunks.set(id, [])
return Effect.void
})
@@ -105,10 +108,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
current.push(value)
return Effect.void
})
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)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
yield* ended(id, current.join(""), providerMetadata)
yield* ended(id, current.join(""), state)
chunks.delete(id)
})
const flush = Effect.fnUntraced(function* () {
@@ -117,26 +120,30 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return { start, append, end, flush }
}
const text = fragments("text", (textID, value) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
textID,
text: value,
})
}),
const text = fragments(
"text",
(_textID, value) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
text: value,
})
}),
true,
)
const reasoning = fragments("reasoning", (reasoningID, value, providerMetadata) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
reasoningID,
text: value,
providerMetadata,
})
}),
const reasoning = fragments(
"reasoning",
(_reasoningID, value, state) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
text: value,
state,
})
}),
true,
)
const toolInput = fragments("tool input", (callID, value) =>
Effect.gen(function* () {
@@ -216,10 +223,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID: tool.assistantMessageID,
callID,
error: { type: "unknown", message },
provider: {
executed: tool.providerExecuted,
...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }),
},
executed: tool.providerExecuted,
})
}
})
@@ -241,7 +245,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
textID: event.id,
})
return
case "text-delta":
@@ -249,7 +252,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
textID: event.id,
delta: event.text,
})
return
@@ -261,8 +263,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
reasoningID: event.id,
providerMetadata: event.providerMetadata,
state: providerState(event.providerMetadata),
})
return
case "reasoning-delta":
@@ -270,12 +271,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
reasoningID: event.id,
delta: event.text,
})
return
case "reasoning-end":
yield* reasoning.end(event.id, event.providerMetadata)
yield* reasoning.end(event.id, providerState(event.providerMetadata))
return
case "tool-input-start":
yield* startToolInput(event)
@@ -307,17 +307,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
tool.called = true
tool.providerExecuted = event.providerExecuted === true
tool.providerMetadata = event.providerMetadata
tool.providerState = providerState(event.providerMetadata)
yield* events.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
tool: event.name,
input: record(event.input),
provider: {
executed: tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
},
executed: tool.providerExecuted,
state: tool.providerState,
})
return
}
@@ -332,10 +329,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}
tool.settled = true
const result = settledOutput(event.output, event.result)
const provider = {
executed: event.providerExecuted === true || tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
}
const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata)
if ("error" in result) {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
@@ -343,7 +338,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
callID: event.id,
error: result.error,
result: event.result,
provider,
executed,
resultState,
})
return
}
@@ -353,8 +349,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
callID: event.id,
...result,
outputPaths,
...(provider.executed ? { result: event.result } : {}),
provider,
...(executed ? { result: event.result } : {}),
executed,
resultState,
})
return
}
@@ -370,10 +367,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: { type: "unknown", message: event.message },
provider: {
executed: tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
},
executed: tool.providerExecuted,
resultState: providerState(event.providerMetadata),
})
return
}
@@ -21,6 +21,11 @@ const media = (file: FileAttachment): ContentPart => ({
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) =>
tool.state.status === "pending"
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
@@ -31,7 +36,7 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
id: tool.id,
name: tool.name,
input: toolInput(tool),
providerExecuted: tool.provider?.executed,
providerExecuted: tool.executed,
providerMetadata,
})
@@ -40,14 +45,14 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
// TODO: Materialize remote and managed URIs before provider-history lowering.
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
const result =
tool.provider?.executed === true && tool.state.result !== undefined
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
return ToolResultPart.make({
id: tool.id,
name: tool.name,
result,
providerExecuted: tool.provider?.executed,
providerExecuted: tool.executed,
providerMetadata,
})
}
@@ -56,11 +61,11 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
id: tool.id,
name: tool.name,
result:
tool.provider?.executed === true && tool.state.result !== undefined
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
resultType: "error",
providerExecuted: tool.provider?.executed,
providerExecuted: tool.executed,
providerMetadata,
})
}
@@ -78,17 +83,22 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
{
type: "reasoning",
text: item.text,
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
providerMetadata: reuseProviderMetadata ? providerMetadata(model.provider, item.state) : undefined,
},
]
: item.text.length > 0
? [{ type: "text", text: item.text }]
: []
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
if (item.provider?.executed !== true) return [call]
const call = toolCall(
item,
reuseProviderMetadata ? providerMetadata(model.provider, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
const result = toolResult(
item,
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
reuseProviderMetadata
? providerMetadata(model.provider, item.providerResultState ?? item.providerState)
: undefined,
)
return result ? [call, result] : [call]
})
@@ -98,9 +108,14 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
})
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) =>
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
toolResult(
item,
reuseProviderMetadata
? providerMetadata(model.provider, item.providerResultState ?? item.providerState)
: undefined,
),
)
.filter((message) => message !== undefined)
.map(Message.tool)
@@ -19,7 +19,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
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 { testEffect } from "./lib/effect"
@@ -73,6 +73,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
requests = []
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const events = yield* EventV2.Service
const store = yield* SessionStore.Service
const sessionID = SessionV2.ID.make("ses_manual_compaction")
const userMessage = {
@@ -108,7 +109,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(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
expect(requests).toHaveLength(1)
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
+2 -3
View File
@@ -96,7 +96,7 @@ describe("SessionProjector", () => {
})
yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID,
messageID: boundary,
to: boundary,
})
expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
@@ -525,7 +525,6 @@ describe("SessionProjector", () => {
yield* service.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
textID: "text-stale",
})
const rows = yield* db
@@ -544,7 +543,7 @@ describe("SessionProjector", () => {
type: "assistant",
agent: "build",
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) },
}),
SessionMessage.Assistant.make({
@@ -28,17 +28,14 @@ describe("toLLMMessages", () => {
const messages = toLLMMessages(
[
assistant("empty", []),
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]),
assistant("empty-reasoning", [
SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }),
]),
assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]),
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", text: "" })]),
assistant("empty-reasoning", [SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "" })]),
assistant("text", [SessionMessage.AssistantText.make({ type: "text", text: "Partial" })]),
assistant("reasoning", [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning",
text: "",
providerMetadata: { anthropic: { signature: "sig_1" } },
state: { signature: "sig_1" },
}),
]),
],
@@ -158,12 +155,11 @@ Recent work
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }),
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-1",
text: "Think",
providerMetadata: { anthropic: { signature: "sig_1" } },
state: { signature: "sig_1" },
}),
SessionMessage.AssistantTool.make({
type: "tool",
@@ -208,11 +204,9 @@ Recent work
type: "tool",
id: "hosted",
name: "web_search",
provider: {
executed: true,
metadata: { fake: { continuation: "hosted-call" } },
resultMetadata: { fake: { continuation: "hosted-result" } },
},
executed: true,
providerState: { continuation: "hosted-call" },
providerResultState: { continuation: "hosted-result" },
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { query: "Effect" },
@@ -225,7 +219,8 @@ Recent work
type: "tool",
id: "hosted-failed",
name: "write",
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
executed: true,
providerState: { continuation: "failed" },
state: SessionMessage.ToolStateError.make({
status: "error",
input: { path: "README.md" },
@@ -245,7 +240,7 @@ Recent work
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
expect(messages[0]?.content).toEqual([
{ 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: "running", name: "read", input: { path: "README.md" } },
{
@@ -260,14 +255,14 @@ Recent work
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-call" } },
providerMetadata: { provider: { continuation: "hosted-call" } },
},
{
type: "tool-result",
id: "hosted",
name: "web_search",
providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-result" } },
providerMetadata: { provider: { continuation: "hosted-result" } },
result: { type: "text", value: "Found it" },
},
{
@@ -276,14 +271,14 @@ Recent work
name: "write",
input: { path: "README.md" },
providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } },
providerMetadata: { provider: { continuation: "failed" } },
},
{
type: "tool-result",
id: "hosted-failed",
name: "write",
providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } },
providerMetadata: { provider: { continuation: "failed" } },
result: {
type: "error",
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
@@ -317,9 +312,8 @@ Recent work
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-openai",
text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
}),
],
time: { created, completed: created },
@@ -332,7 +326,7 @@ Recent work
{
type: "reasoning",
text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
})
@@ -348,19 +342,16 @@ Recent work
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-failed",
text: "Partial thought",
providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } },
state: { itemId: "rs_failed", reasoningEncryptedContent: null },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted-failed",
name: "web_search",
provider: {
executed: true,
metadata: { openai: { itemId: "call_failed" } },
resultMetadata: { openai: { itemId: "result_failed" } },
},
executed: true,
providerState: { itemId: "call_failed" },
providerResultState: { itemId: "result_failed" },
state: SessionMessage.ToolStateError.make({
status: "error",
input: { query: "Effect" },
@@ -420,19 +411,16 @@ Recent work
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-old-model",
text: "Visible thought",
providerMetadata: { anthropic: { signature: "sig_old" } },
state: { signature: "sig_old" },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted-old-model",
name: "web_search",
provider: {
executed: true,
metadata: { openai: { itemId: "hosted-old-model" } },
resultMetadata: { openai: { itemId: "hosted-old-model" } },
},
executed: true,
providerState: { itemId: "hosted-old-model" },
providerResultState: { itemId: "hosted-old-model" },
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { query: "Effect" },
@@ -446,11 +434,9 @@ Recent work
type: "tool",
id: "local-old-model",
name: "read",
provider: {
executed: false,
metadata: { fake: { call: "old" } },
resultMetadata: { fake: { result: "old" } },
},
executed: false,
providerState: { call: "old" },
providerResultState: { result: "old" },
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { path: "README.md" },
@@ -47,6 +47,7 @@ const capture = () => {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
},
provider: "openai",
}),
}
}
@@ -90,7 +91,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()
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
@@ -98,6 +99,19 @@ test("provider-executed success retains its compatibility result", async () => {
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 () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
@@ -114,7 +128,7 @@ test("binary failure emits no success event", async () => {
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)({
sessionID,
assistantMessageID: SessionMessage.ID.create(),
@@ -122,7 +136,7 @@ test("old success event data containing result still decodes", () => {
structured: { type: "media", 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" }] },
provider: { executed: false },
executed: true,
})
expect(decoded.result).toMatchObject({ type: "content" })
})
+59 -27
View File
@@ -455,7 +455,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
LLMEvent.textStart({ id }),
...chunks.map((text) => LLMEvent.textDelta({ id, text })),
]
const expectedContent = { type: "text", id, text }
const expectedContent = { type: "text", text }
return {
delta: SessionEvent.Text.Delta,
partialEvents,
@@ -475,7 +475,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
LLMEvent.reasoningStart({ id }),
...chunks.map((text) => LLMEvent.reasoningDelta({ id, text })),
]
const expectedContent = { type: "reasoning", id, text }
const expectedContent = { type: "reasoning", text }
return {
delta: SessionEvent.Reasoning.Delta,
partialEvents,
@@ -1552,7 +1552,7 @@ describe("SessionRunnerLLM", () => {
finish: "tool-calls",
tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } },
content: [
{ type: "reasoning", id: "reasoning-1", text: "Think" },
{ type: "reasoning", text: "Think" },
{
type: "tool",
id: "call-error",
@@ -1567,7 +1567,9 @@ describe("SessionRunnerLLM", () => {
type: "tool",
id: "call-provider",
name: "web_search",
provider: { executed: true, metadata: { fake: { source: "provider" } } },
executed: true,
providerState: { source: "provider" },
providerResultState: { source: "provider" },
state: {
status: "completed",
input: { query: "hello" },
@@ -1637,7 +1639,7 @@ describe("SessionRunnerLLM", () => {
},
],
},
{ type: "assistant", finish: "stop", content: [{ type: "text", id: "text-final", text: "Done" }] },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] },
])
}),
)
@@ -1696,15 +1698,24 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepStart({ index: 0 }),
LLMEvent.reasoningStart({ id: "reasoning-anthropic" }),
LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }),
LLMEvent.reasoningEnd({ id: "reasoning-anthropic", providerMetadata: { anthropic: { signature: "sig_1" } } }),
LLMEvent.reasoningEnd({
id: "reasoning-anthropic",
providerMetadata: { fake: { signature: "sig_1" }, anthropic: { ignored: true } },
}),
LLMEvent.reasoningStart({
id: "reasoning-openai",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
providerMetadata: {
fake: { itemId: "rs_1", reasoningEncryptedContent: null },
openai: { ignored: true },
},
}),
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
LLMEvent.reasoningEnd({
id: "reasoning-openai",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
providerMetadata: {
fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
openai: { ignored: true },
},
}),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
@@ -1717,11 +1728,11 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
content: [
{ type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } },
{ type: "reasoning", text: "Signed thought", state: { signature: "sig_1" } },
{
type: "reasoning",
text: "Encrypted thought",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
},
],
},
@@ -1732,11 +1743,11 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests[1]?.messages[1]?.content).toEqual([
{ type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } },
{ type: "reasoning", text: "Signed thought", providerMetadata: { fake: { signature: "sig_1" } } },
{
type: "reasoning",
text: "Encrypted thought",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
providerMetadata: { fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
@@ -1756,14 +1767,14 @@ describe("SessionRunnerLLM", () => {
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "hosted-search" } },
providerMetadata: { fake: { itemId: "hosted-search" }, openai: { ignored: true } },
}),
LLMEvent.toolResult({
id: "hosted-search",
name: "web_search",
result: { type: "json", value: [{ title: "Effect" }] },
providerExecuted: true,
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
providerMetadata: { fake: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
}),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
@@ -1783,7 +1794,7 @@ describe("SessionRunnerLLM", () => {
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "hosted-search" } },
providerMetadata: { fake: { itemId: "hosted-search" } },
},
{
type: "tool-result",
@@ -1791,7 +1802,7 @@ describe("SessionRunnerLLM", () => {
name: "web_search",
result: { type: "json", value: [{ title: "Effect" }] },
providerExecuted: true,
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
providerMetadata: { fake: { blockType: "web_search_tool_result" } },
},
])
}),
@@ -1980,7 +1991,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Run once" },
{ type: "assistant", finish: "stop", content: [{ type: "text", id: "text-once", text: "Once" }] },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Once" }] },
])
}),
)
@@ -2402,9 +2413,8 @@ describe("SessionRunnerLLM", () => {
sessionID,
assistantMessageID,
callID: "call-interrupted",
tool: "echo",
input: { text: "stale" },
provider: { executed: false },
executed: false,
})
requests.length = 0
response = []
@@ -2462,9 +2472,9 @@ describe("SessionRunnerLLM", () => {
sessionID,
assistantMessageID,
callID: "call-hosted-interrupted",
tool: "web_search",
input: { query: "stale" },
provider: { executed: true, metadata: { openai: { itemId: "call-hosted-interrupted" } } },
executed: true,
state: { itemId: "call-hosted-interrupted" },
})
requests.length = 0
response = []
@@ -2477,7 +2487,7 @@ describe("SessionRunnerLLM", () => {
type: "tool-call",
id: "call-hosted-interrupted",
providerExecuted: true,
providerMetadata: { openai: { itemId: "call-hosted-interrupted" } },
providerMetadata: { fake: { itemId: "call-hosted-interrupted" } },
},
{ type: "tool-result", id: "call-hosted-interrupted", providerExecuted: true, result: { type: "error" } },
])
@@ -2726,7 +2736,7 @@ describe("SessionRunnerLLM", () => {
},
],
},
{ type: "assistant", finish: "stop", content: [{ type: "text", id: "text-after-error", text: "Recovered" }] },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
}),
)
@@ -3339,7 +3349,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("keeps interleaved assistant text blocks separate", () =>
it.effect("rejects a second text start before the open fragment ends", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -3352,9 +3362,31 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-1" }),
LLMEvent.textStart({ id: "text-2" }),
]
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
expect(defect).toBeInstanceOf(Error)
if (!(defect instanceof Error)) return
expect(defect.message).toBe("text start before end: text-2")
}),
)
it.effect("projects sequential text fragments as separate content parts", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Two blocks" }), resume: false })
responses = undefined
streamGate = undefined
streamStarted = undefined
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-1" }),
LLMEvent.textDelta({ id: "text-1", text: "First" }),
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
LLMEvent.textEnd({ id: "text-1" }),
LLMEvent.textStart({ id: "text-2" }),
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
LLMEvent.textEnd({ id: "text-2" }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
@@ -3367,8 +3399,8 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
content: [
{ type: "text", id: "text-1", text: "First" },
{ type: "text", id: "text-2", text: "Second" },
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
],
},
])
@@ -76,9 +76,8 @@ describe("Tool.Progress", () => {
sessionID,
assistantMessageID,
callID,
tool: "bash",
input: { command: "pwd" },
provider: { executed: false },
executed: false,
})
})
@@ -104,7 +103,7 @@ describe("Tool.Progress", () => {
callID: "call-success",
structured: { phase: "done" },
content: content("complete"),
provider: { executed: false },
executed: false,
})
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
@@ -123,7 +122,7 @@ describe("Tool.Progress", () => {
assistantMessageID,
callID: "call-failed",
error: { type: "unknown", message: "boom" },
provider: { executed: false },
executed: false,
})
expect((yield* readAssistant).content[1]).toMatchObject({
state: {
+1 -1
View File
@@ -197,7 +197,7 @@ test("Core reuses the canonical shared schemas", async () => {
test("shared record schemas construct and decode plain objects", () => {
const made = Prompt.make({ 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(decoded)).toBe(Object.prototype)
-3
View File
@@ -75,7 +75,6 @@ const executionNode = makeGlobalNode({
const session = yield* store.get(id)
if (!session) return
const assistantMessageID = SessionMessage.ID.create()
const textID = "text_shell_test"
yield* events.publish(SessionEvent.Step.Started, {
sessionID: id,
assistantMessageID,
@@ -85,12 +84,10 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Text.Started, {
sessionID: id,
assistantMessageID,
textID,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: id,
assistantMessageID,
textID,
text: "ok",
})
yield* events.publish(SessionEvent.Step.Ended, {
-3
View File
@@ -49,7 +49,6 @@ const executionNode = makeGlobalNode({
}
completed.add(sessionID)
const assistantMessageID = SessionMessage.ID.create()
const textID = "text_subagent_test"
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
@@ -59,12 +58,10 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID,
textID,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID,
textID,
text: childText,
})
yield* events.publish(SessionEvent.Step.Ended, {
@@ -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 => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
@@ -810,6 +815,11 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) {
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, item.id) }, events] satisfies StepResult
}
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id]
@@ -920,6 +930,7 @@ const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult =>
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.done") return Effect.succeed(onOutputTextDone(state, event))
if (
event.type === "response.reasoning_text.delta" ||
event.type === "response.reasoning_summary.delta" ||
@@ -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", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -190,11 +190,12 @@ export async function runNonInteractivePrompt(input: Input) {
}
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
}
if (event.type === "session.text.ended") {
const started = starts.get(event.data.textID)
const started = starts.get("text")
starts.delete("text")
const part: TextPart = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
@@ -208,18 +209,19 @@ export async function runNonInteractivePrompt(input: Input) {
}
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
}
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 = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "reasoning",
text: event.data.text,
metadata: event.data.providerMetadata,
metadata: event.data.state,
time: { start: started?.timestamp ?? time, end: time },
}
if (emit("reasoning", time, { part })) continue
@@ -257,10 +259,10 @@ export async function runNonInteractivePrompt(input: Input) {
id: current?.id ?? partID(event.id),
timestamp: current?.timestamp ?? time,
assistantMessageID: event.data.assistantMessageID,
tool: event.data.tool,
tool: current?.tool ?? "tool",
input: event.data.input,
raw: current?.raw,
provider: event.data.provider,
provider: { executed: event.data.executed, state: event.data.state },
})
continue
}
@@ -287,7 +289,7 @@ export async function runNonInteractivePrompt(input: Input) {
outputPaths: event.data.outputPaths,
result: event.data.result,
providerCall: current.provider,
providerResult: event.data.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },
rawInput: current.raw,
},
time: { start: current.timestamp, end: time },
@@ -314,7 +316,7 @@ export async function runNonInteractivePrompt(input: Input) {
metadata: {
result: event.data.result,
providerCall: current.provider,
providerResult: event.data.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },
rawInput: current.raw,
},
time: { start: current.timestamp, end: time },
@@ -42,8 +42,18 @@ export function legacyTool(input: {
name: string
state: SessionMessageAssistantTool["state"]
time: SessionMessageAssistantTool["time"]
provider?: SessionMessageAssistantTool["provider"]
executed?: boolean
providerState?: Record<string, unknown>
providerResultState?: Record<string, unknown>
}): ToolPart {
const providerCall =
input.executed === undefined && input.providerState === undefined
? undefined
: { executed: input.executed, state: input.providerState }
const providerResult =
input.executed === undefined && input.providerResultState === undefined
? undefined
: { executed: input.executed, state: input.providerResultState }
const base = {
id: `prt_${input.callID}`,
sessionID: input.sessionID,
@@ -65,7 +75,7 @@ export function legacyTool(input: {
status: "running",
input: input.state.input,
title: input.name,
metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider },
metadata: { structured: input.state.structured, content: input.state.content, providerCall },
time: { start: input.time.ran ?? input.time.created },
},
}
@@ -83,7 +93,8 @@ export function legacyTool(input: {
content: input.state.content,
outputPaths: input.state.outputPaths,
result: input.state.result,
providerCall: input.provider,
providerCall,
providerResult,
},
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
},
@@ -99,7 +110,8 @@ export function legacyTool(input: {
structured: input.state.structured,
content: input.state.content,
result: input.state.result,
providerCall: input.provider,
providerCall,
providerResult,
},
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
},
@@ -141,6 +153,8 @@ type ToolTrack = {
name: string
input: Record<string, unknown>
started: number
executed?: boolean
providerState?: Record<string, unknown>
}
type ChildState = {
@@ -157,6 +171,10 @@ type ChildState = {
projectedText: Map<string, string>
reasoning: Map<string, string>
projectedReasoning: Map<string, string>
textOrdinals: Map<string, number>
reasoningOrdinals: Map<string, number>
activeText: Map<string, string>
activeReasoning: Map<string, string>
tools: Map<string, ToolTrack>
finishedTools: Set<string>
messageIDs: Set<string>
@@ -217,6 +235,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
// Live subagent tool calls in the parent, so tool.success structured output
// can be joined with the call's input metadata.
const pendingCalls = new Map<string, Record<string, unknown>>()
const subagentCalls = new Set<string>()
// Foreign sessions already resolved through session.get. Non-children stay
// cached so unrelated concurrent sessions are checked at most once.
const checked = new Set<string>()
@@ -225,6 +244,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const pendingEvents = new Map<string, V2Event[]>()
const hydrations = new Map<string, Promise<void>>()
let selected: string | undefined
const fragmentKey = (messageID: string, partID: string) => `${messageID}\u0000${partID}`
const ensureChild = (sessionID: string): ChildState => {
const existing = children.get(sessionID)
@@ -241,6 +261,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
projectedText: new Map(),
reasoning: new Map(),
projectedReasoning: new Map(),
textOrdinals: new Map(),
reasoningOrdinals: new Map(),
activeText: new Map(),
activeReasoning: new Map(),
tools: new Map(),
finishedTools: new Set(),
messageIDs: new Set(),
@@ -308,7 +332,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
name: item.name,
state: item.state,
time: item.time,
provider: item.provider,
executed: item.executed,
providerState: item.providerState,
providerResultState: item.providerResultState,
})
if (item.state.status === "pending") return
child.callIDs.add(item.id)
@@ -327,6 +353,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
child.projectedText.clear()
child.reasoning.clear()
child.projectedReasoning.clear()
child.textOrdinals.clear()
child.reasoningOrdinals.clear()
child.activeText.clear()
child.activeReasoning.clear()
child.finishedTools.clear()
child.messageIDs.clear()
child.callIDs.clear()
@@ -337,36 +367,44 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
if (message.type !== "assistant") continue
child.messageIDs.add(message.id)
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
if (item.type === "text") {
child.text.set(item.id, item.text)
child.projectedText.set(item.id, item.text)
setFrame(child, `text:${item.id}`, {
const id = `text:${textOrdinal++}`
const key = fragmentKey(message.id, id)
child.text.set(key, item.text)
child.projectedText.set(key, item.text)
setFrame(child, key, {
kind: "assistant",
source: "assistant",
text: item.text,
phase: "progress",
messageID: message.id,
partID: item.id,
partID: id,
})
continue
}
if (item.type === "reasoning") {
child.reasoning.set(item.id, item.text)
child.projectedReasoning.set(item.id, item.text)
const id = `reasoning:${reasoningOrdinal++}`
const key = fragmentKey(message.id, id)
child.reasoning.set(key, item.text)
child.projectedReasoning.set(key, item.text)
if (input.thinking)
setFrame(child, `reasoning:${item.id}`, {
setFrame(child, key, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${item.text}`,
phase: "progress",
messageID: message.id,
partID: item.id,
partID: id,
})
continue
}
childTool(child, item, message.id)
}
child.textOrdinals.set(message.id, textOrdinal)
child.reasoningOrdinals.set(message.id, reasoningOrdinal)
if (message.error) {
setFrame(child, `error:${message.id}`, {
kind: "error",
@@ -438,74 +476,104 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
input.emit()
return
}
if (event.type === "session.text.started") {
const ordinal = child.textOrdinals.get(event.data.assistantMessageID) ?? 0
child.textOrdinals.set(event.data.assistantMessageID, ordinal + 1)
child.activeText.set(event.data.assistantMessageID, `text:${ordinal}`)
return
}
if (event.type === "session.text.delta") {
const projected = child.projectedText.get(event.data.textID)
const id =
child.activeText.get(event.data.assistantMessageID) ??
`text:${Math.max(0, (child.textOrdinals.get(event.data.assistantMessageID) ?? 1) - 1)}`
const key = fragmentKey(event.data.assistantMessageID, id)
const projected = child.projectedText.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
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
}
const next = (child.text.get(event.data.textID) ?? "") + event.data.delta
child.text.set(event.data.textID, next)
setFrame(child, `text:${event.data.textID}`, {
const next = (child.text.get(key) ?? "") + event.data.delta
child.text.set(key, next)
setFrame(child, key, {
kind: "assistant",
source: "assistant",
text: next,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
partID: id,
})
touch(child, event.created)
notifyDetail(child)
return
}
if (event.type === "session.text.ended") {
child.text.set(event.data.textID, event.data.text)
child.projectedText.delete(event.data.textID)
setFrame(child, `text:${event.data.textID}`, {
const id =
child.activeText.get(event.data.assistantMessageID) ??
`text:${Math.max(0, (child.textOrdinals.get(event.data.assistantMessageID) ?? 1) - 1)}`
const key = fragmentKey(event.data.assistantMessageID, id)
child.text.set(key, event.data.text)
child.projectedText.delete(key)
child.activeText.delete(event.data.assistantMessageID)
setFrame(child, key, {
kind: "assistant",
source: "assistant",
text: event.data.text,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
partID: id,
})
touch(child, event.created)
notifyDetail(child)
return
}
if (event.type === "session.reasoning.started") {
const ordinal = child.reasoningOrdinals.get(event.data.assistantMessageID) ?? 0
child.reasoningOrdinals.set(event.data.assistantMessageID, ordinal + 1)
child.activeReasoning.set(event.data.assistantMessageID, `reasoning:${ordinal}`)
return
}
if (event.type === "session.reasoning.delta") {
const projected = child.projectedReasoning.get(event.data.reasoningID)
const id =
child.activeReasoning.get(event.data.assistantMessageID) ??
`reasoning:${Math.max(0, (child.reasoningOrdinals.get(event.data.assistantMessageID) ?? 1) - 1)}`
const key = fragmentKey(event.data.assistantMessageID, id)
const projected = child.projectedReasoning.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
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
}
const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta
child.reasoning.set(event.data.reasoningID, next)
const next = (child.reasoning.get(key) ?? "") + event.data.delta
child.reasoning.set(key, next)
if (!input.thinking) return
setFrame(child, `reasoning:${event.data.reasoningID}`, {
setFrame(child, key, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${next}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
partID: id,
})
notifyDetail(child)
return
}
if (event.type === "session.reasoning.ended") {
child.reasoning.set(event.data.reasoningID, event.data.text)
child.projectedReasoning.delete(event.data.reasoningID)
const id =
child.activeReasoning.get(event.data.assistantMessageID) ??
`reasoning:${Math.max(0, (child.reasoningOrdinals.get(event.data.assistantMessageID) ?? 1) - 1)}`
const key = fragmentKey(event.data.assistantMessageID, id)
child.reasoning.set(key, event.data.text)
child.projectedReasoning.delete(key)
child.activeReasoning.delete(event.data.assistantMessageID)
if (!input.thinking) return
setFrame(child, `reasoning:${event.data.reasoningID}`, {
setFrame(child, key, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${event.data.text}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
partID: id,
})
notifyDetail(child)
return
@@ -517,17 +585,20 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (event.type === "session.tool.called") {
const current = child.tools.get(event.data.callID)
child.tools.set(event.data.callID, {
name: event.data.tool,
name: current?.name ?? "tool",
input: event.data.input,
started: current?.started ?? event.created,
executed: event.data.executed,
providerState: event.data.state,
})
childTool(
child,
{
type: "tool",
id: event.data.callID,
name: event.data.tool,
provider: event.data.provider,
name: current?.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created },
},
@@ -547,7 +618,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
provider: event.data.provider,
executed: event.data.executed,
providerState: current?.providerState,
providerResultState: event.data.resultState,
state: failed
? {
status: "error",
@@ -613,17 +686,23 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return {
main(event) {
if (event.type === "session.tool.input.started") {
if (event.data.name === "subagent") subagentCalls.add(event.data.callID)
return
}
if (event.type === "session.tool.called") {
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
if (subagentCalls.has(event.data.callID)) pendingCalls.set(event.data.callID, event.data.input)
return
}
if (event.type === "session.tool.failed") {
pendingCalls.delete(event.data.callID)
subagentCalls.delete(event.data.callID)
return
}
if (event.type !== "session.tool.success") return
const pending = pendingCalls.get(event.data.callID)
pendingCalls.delete(event.data.callID)
subagentCalls.delete(event.data.callID)
const found = childSessionID(record(event.data.structured))
if (!found) return
const child = ensureChild(found.sessionID)
@@ -95,6 +95,8 @@ type ToolState = {
input: Record<string, unknown>
started: number
running: boolean
executed?: boolean
providerState?: Record<string, unknown>
}
type State = {
@@ -106,6 +108,10 @@ type State = {
projectedText: Map<string, string>
reasoning: Map<string, string>
projectedReasoning: Map<string, string>
textOrdinals: Map<string, number>
reasoningOrdinals: Map<string, number>
activeText: Map<string, string>
activeReasoning: Map<string, string>
tools: Map<string, ToolState>
finishedTools: Set<string>
skillMessages: Set<string>
@@ -288,6 +294,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
projectedText: new Map(),
reasoning: new Map(),
projectedReasoning: new Map(),
textOrdinals: new Map(),
reasoningOrdinals: new Map(),
activeText: new Map(),
activeReasoning: new Map(),
tools: new Map(),
finishedTools: new Set(),
skillMessages: new Set(),
@@ -356,7 +366,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
name: item.name,
state: item.state,
time: item.time,
provider: item.provider,
executed: item.executed,
providerState: item.providerState,
providerResultState: item.providerResultState,
})
if (item.state.status === "pending") return
if (item.state.status === "running") {
@@ -443,9 +455,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
if (message.type !== "assistant") return
state.messageIDs.add(message.id)
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
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
state.text.set(key, item.text)
if (render) state.projectedText.set(key, item.text)
@@ -457,13 +472,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: item.text.slice(sent),
phase: "progress",
messageID: message.id,
partID: item.id,
partID: id,
},
])
continue
}
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
state.reasoning.set(key, item.text)
if (render) state.projectedReasoning.set(key, item.text)
@@ -475,13 +491,15 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent),
phase: "progress",
messageID: message.id,
partID: item.id,
partID: id,
},
])
continue
}
if (render) renderTool(message.id, item)
}
state.textOrdinals.set(message.id, textOrdinal)
state.reasoningOrdinals.set(message.id, reasoningOrdinal)
if (render && message.error && !state.errors.has(message.id)) {
state.errors.add(message.id)
write([
@@ -596,8 +614,17 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (owned) wait.resolve()
return
}
if (event.type === "session.text.started") {
const ordinal = state.textOrdinals.get(event.data.assistantMessageID) ?? 0
state.textOrdinals.set(event.data.assistantMessageID, ordinal + 1)
state.activeText.set(event.data.assistantMessageID, `text:${ordinal}`)
return
}
if (event.type === "session.text.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const id =
state.activeText.get(event.data.assistantMessageID) ??
`text:${Math.max(0, (state.textOrdinals.get(event.data.assistantMessageID) ?? 1) - 1)}`
const key = streamPartKey(event.data.assistantMessageID, id)
const projected = state.projectedText.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@@ -613,13 +640,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: event.data.delta,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
partID: id,
},
])
return
}
if (event.type === "session.text.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const id =
state.activeText.get(event.data.assistantMessageID) ??
`text:${Math.max(0, (state.textOrdinals.get(event.data.assistantMessageID) ?? 1) - 1)}`
const key = streamPartKey(event.data.assistantMessageID, id)
const previous = state.text.get(key) ?? ""
if (event.data.text.length > previous.length)
write([
@@ -629,15 +659,25 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: event.data.text.slice(previous.length),
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
partID: id,
},
])
state.text.set(key, event.data.text)
state.projectedText.delete(key)
state.activeText.delete(event.data.assistantMessageID)
return
}
if (event.type === "session.reasoning.started") {
const ordinal = state.reasoningOrdinals.get(event.data.assistantMessageID) ?? 0
state.reasoningOrdinals.set(event.data.assistantMessageID, ordinal + 1)
state.activeReasoning.set(event.data.assistantMessageID, `reasoning:${ordinal}`)
return
}
if (event.type === "session.reasoning.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const id =
state.activeReasoning.get(event.data.assistantMessageID) ??
`reasoning:${Math.max(0, (state.reasoningOrdinals.get(event.data.assistantMessageID) ?? 1) - 1)}`
const key = streamPartKey(event.data.assistantMessageID, id)
const projected = state.projectedReasoning.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@@ -654,13 +694,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
partID: id,
},
])
return
}
if (event.type === "session.reasoning.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const id =
state.activeReasoning.get(event.data.assistantMessageID) ??
`reasoning:${Math.max(0, (state.reasoningOrdinals.get(event.data.assistantMessageID) ?? 1) - 1)}`
const key = streamPartKey(event.data.assistantMessageID, id)
const previous = state.reasoning.get(key) ?? ""
if (input.thinking && event.data.text.length > previous.length)
write([
@@ -670,11 +713,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
partID: id,
},
])
state.reasoning.set(key, event.data.text)
state.projectedReasoning.delete(key)
state.activeReasoning.delete(event.data.assistantMessageID)
return
}
if (event.type === "session.tool.input.started") {
@@ -690,11 +734,21 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.tool.called") {
if (state.finishedTools.has(event.data.callID)) return
const current = state.tools.get(event.data.callID)
state.tools.set(event.data.callID, {
messageID: event.data.assistantMessageID,
name: current?.name ?? "tool",
input: event.data.input,
started: current?.started ?? event.created,
running: true,
executed: event.data.executed,
providerState: event.data.state,
})
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: event.data.tool,
provider: event.data.provider,
name: current?.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created },
}
@@ -709,7 +763,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
provider: event.data.provider,
executed: event.data.executed,
providerState: current?.providerState,
providerResultState: event.data.resultState,
state: failed
? {
status: "error",
@@ -1046,6 +1102,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.projectedText.clear()
state.reasoning.clear()
state.projectedReasoning.clear()
state.textOrdinals.clear()
state.reasoningOrdinals.clear()
state.activeText.clear()
state.activeReasoning.clear()
state.tools.clear()
state.finishedTools.clear()
state.skillMessages.clear()
@@ -214,7 +214,6 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
textID: "txt_1",
delta: "answer",
},
})
@@ -709,7 +708,7 @@ describe("V2 mini transport", () => {
type: "assistant",
agent: "build",
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 },
},
],
@@ -728,7 +727,6 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
textID: "txt_1",
delta: "answer",
},
})
@@ -741,7 +739,7 @@ describe("V2 mini transport", () => {
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()
events.push(connected())
const client = sdk({ streams: [events] })
@@ -754,8 +752,8 @@ describe("V2 mini transport", () => {
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{ type: "reasoning", id: "reasoning-0", text: "second thought" },
{ type: "text", id: "text-0", text: "second answer" },
{ type: "reasoning", text: "second thought" },
{ type: "text", text: "second answer" },
],
time: { created: 4, completed: 5 },
},
@@ -765,8 +763,8 @@ describe("V2 mini transport", () => {
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{ type: "reasoning", id: "reasoning-0", text: "first thought" },
{ type: "text", id: "text-0", text: "first answer" },
{ type: "reasoning", text: "first thought" },
{ type: "text", text: "first answer" },
],
time: { created: 2, completed: 3 },
},
@@ -814,7 +812,6 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
reasoningID: "reasoning_1",
text: "considering",
},
})
@@ -1509,7 +1506,6 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
textID: "txt_child",
delta: "child answer",
},
})
@@ -1633,6 +1629,18 @@ describe("V2 mini transport", () => {
},
})
// 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({
id: "evt_parent_call",
created: 0,
@@ -1642,9 +1650,8 @@ describe("V2 mini transport", () => {
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
callID: "call_sub",
tool: "subagent",
input: { agent: "explore", description: "Find things", prompt: "go", background: true },
provider: { executed: true },
executed: true,
},
})
events.push({
@@ -1658,7 +1665,7 @@ describe("V2 mini transport", () => {
callID: "call_sub",
structured: { sessionID: "ses_child", status: "running", output: "" },
content: [],
provider: { executed: true },
executed: true,
},
})
// The settled event arrives after adoption, so it applies directly.
@@ -101,7 +101,6 @@ test.skip("text ended populates assistant text content", () => {
data: {
sessionID,
assistantMessageID,
textID: "text-1",
},
} satisfies SessionEvent.Event),
)
@@ -115,7 +114,6 @@ test.skip("text ended populates assistant text content", () => {
data: {
sessionID,
assistantMessageID,
textID: "text-1",
text: "hello assistant",
},
} satisfies SessionEvent.Event),
@@ -123,7 +121,7 @@ test.skip("text ended populates assistant text content", () => {
expect(state.messages[0]?.type).toBe("assistant")
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", () => {
@@ -176,9 +174,9 @@ test.skip("tool completion stores completed timestamp", () => {
sessionID,
assistantMessageID,
callID,
tool: "bash",
input: { command: "pwd" },
provider: { executed: true, metadata: { fake: { source: "provider" } } },
executed: true,
state: { source: "provider" },
},
} satisfies SessionEvent.Event),
)
@@ -195,7 +193,8 @@ test.skip("tool completion stores completed timestamp", () => {
callID,
structured: {},
content: [{ type: "text", text: "/tmp" }],
provider: { executed: true, metadata: { fake: { status: "done" } } },
executed: true,
resultState: { status: "done" },
},
} satisfies SessionEvent.Event),
)
@@ -205,7 +204,11 @@ test.skip("tool completion stores completed timestamp", () => {
expect(state.messages[0].content[0]?.type).toBe("tool")
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].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", () => {
+10 -23
View File
@@ -3,7 +3,7 @@ export * as SessionEvent from "./session-event.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { Event } from "./event.js"
import { ProviderMetadata, ToolContent } from "./llm.js"
import { ToolContent } from "./llm.js"
import { Delivery } from "./session-delivery.js"
import { Model } from "./model.js"
import { NonNegativeInt, RelativePath } from "./schema.js"
@@ -244,7 +244,6 @@ export namespace Text {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
textID: Schema.String,
},
})
export type Started = typeof Started.Type
@@ -255,7 +254,6 @@ export namespace Text {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
textID: Schema.String,
delta: Schema.String,
},
})
@@ -267,7 +265,6 @@ export namespace Text {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
textID: Schema.String,
text: Schema.String,
},
})
@@ -281,8 +278,7 @@ export namespace Reasoning {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
reasoningID: Schema.String,
providerMetadata: ProviderMetadata.pipe(optional),
state: SessionMessage.ProviderState.pipe(optional),
},
})
export type Started = typeof Started.Type
@@ -293,7 +289,6 @@ export namespace Reasoning {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
reasoningID: Schema.String,
delta: Schema.String,
},
})
@@ -305,9 +300,8 @@ export namespace Reasoning {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
reasoningID: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(optional),
state: SessionMessage.ProviderState.pipe(optional),
},
})
export type Ended = typeof Ended.Type
@@ -357,12 +351,9 @@ export namespace Tool {
...options,
schema: {
...ToolBase,
tool: Schema.String,
input: Schema.Record(Schema.String, Schema.Unknown),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(optional),
}),
executed: Schema.Boolean,
state: SessionMessage.ProviderState.pipe(optional),
},
})
export type Called = typeof Called.Type
@@ -391,10 +382,8 @@ export namespace Tool {
content: Schema.Array(ToolContent),
outputPaths: Schema.Array(Schema.String).pipe(optional),
result: Schema.Unknown.pipe(optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(optional),
}),
executed: Schema.Boolean,
resultState: SessionMessage.ProviderState.pipe(optional),
},
})
export type Success = typeof Success.Type
@@ -406,10 +395,8 @@ export namespace Tool {
...ToolBase,
error: UnknownError,
result: Schema.Unknown.pipe(optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(optional),
}),
executed: Schema.Boolean,
resultState: SessionMessage.ProviderState.pipe(optional),
},
})
export type Failed = typeof Failed.Type
@@ -481,7 +468,7 @@ export namespace RevertEvent {
export const Committed = Event.durable({
type: "session.revert.committed",
...options,
schema: { ...Base, messageID: SessionMessage.ID },
schema: { ...Base, to: SessionMessage.ID },
})
}
+10 -9
View File
@@ -2,7 +2,7 @@ export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { ProviderMetadata, ToolContent } from "./llm.js"
import { ToolContent } from "./llm.js"
import { Model } from "./model.js"
import { FileAttachment, Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema.js"
@@ -32,6 +32,11 @@ const Base = {
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 const AgentSelected = Schema.Struct({
...Base,
@@ -137,11 +142,9 @@ export const AssistantTool = Schema.Struct({
type: Schema.Literal("tool"),
id: Schema.String,
name: Schema.String,
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(optional),
resultMetadata: ProviderMetadata.pipe(optional),
}).pipe(optional),
executed: Schema.Boolean.pipe(optional),
providerState: ProviderState.pipe(optional),
providerResultState: ProviderState.pipe(optional),
state: ToolState,
time: Schema.Struct({
created: DateTimeUtcFromMillis,
@@ -154,16 +157,14 @@ export const AssistantTool = Schema.Struct({
export interface AssistantText extends Schema.Schema.Type<typeof AssistantText> {}
export const AssistantText = Schema.Struct({
type: Schema.Literal("text"),
id: Schema.String,
text: Schema.String,
}).annotate({ identifier: "Session.Message.Assistant.Text" })
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
export const AssistantReasoning = Schema.Struct({
type: Schema.Literal("reasoning"),
id: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(optional),
state: ProviderState.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(optional),
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { DateTime, Schema } from "effect"
import { Agent } from "../src/agent.js"
import { FileSystem } from "../src/filesystem.js"
import { Model } from "../src/model.js"
@@ -7,6 +7,7 @@ import { Project } from "../src/project.js"
import { Pty } from "../src/pty.js"
import { Question } from "../src/question.js"
import { Session } from "../src/session.js"
import { SessionMessage } from "../src/session-message.js"
import { SessionTodo } from "../src/session-todo.js"
import { optional } from "../src/schema.js"
@@ -67,4 +68,25 @@ describe("contract hygiene", () => {
expect(source).not.toContain("Schema.Any")
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")
})
})
@@ -16,6 +16,8 @@ import { FileSystemV1 } from "../src/filesystem-v1.js"
import { IdeEvent } from "../src/ide-event.js"
import { McpEvent } from "../src/mcp-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 { SessionV1 } from "../src/session-v1.js"
import { WorkspaceEvent } from "../src/workspace-event.js"
@@ -130,4 +132,32 @@ describe("public event manifest", () => {
)
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 })
const reasoning = SessionEvent.Reasoning.Ended.data.make({
sessionID,
assistantMessageID,
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)
})
})
+67 -152
View File
@@ -1024,7 +1024,6 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
}
}
| {
@@ -1033,7 +1032,6 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
delta: string
}
}
@@ -1043,7 +1041,6 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
text: string
}
}
@@ -1053,8 +1050,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
| {
@@ -1063,7 +1059,6 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
delta: string
}
}
@@ -1073,9 +1068,8 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
| {
@@ -1115,14 +1109,11 @@ export type GlobalEvent = {
sessionID: string
assistantMessageID: string
callID: string
tool: string
input: {
[key: string]: unknown
}
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
state?: SessionMessageProviderState
}
}
| {
@@ -1151,10 +1142,8 @@ export type GlobalEvent = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
| {
@@ -1166,10 +1155,8 @@ export type GlobalEvent = {
callID: string
error: SessionErrorUnknown
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
| {
@@ -1227,7 +1214,7 @@ export type GlobalEvent = {
type: "session.revert.committed"
properties: {
sessionID: string
messageID: string
to: string
}
}
| {
@@ -3276,10 +3263,8 @@ export type SessionErrorUnknown = {
message: string
}
export type LlmProviderMetadata = {
[key: string]: {
[key: string]: unknown
}
export type SessionMessageProviderState = {
[key: string]: unknown
}
export type ToolTextContent = {
@@ -3887,7 +3872,6 @@ export type SyncEventSessionTextStarted = {
data: {
sessionID: string
assistantMessageID: string
textID: string
}
}
}
@@ -3903,7 +3887,6 @@ export type SyncEventSessionTextEnded = {
data: {
sessionID: string
assistantMessageID: string
textID: string
text: string
}
}
@@ -3920,8 +3903,7 @@ export type SyncEventSessionReasoningStarted = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
}
@@ -3937,9 +3919,8 @@ export type SyncEventSessionReasoningEnded = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
}
@@ -3990,14 +3971,11 @@ export type SyncEventSessionToolCalled = {
sessionID: string
assistantMessageID: string
callID: string
tool: string
input: {
[key: string]: unknown
}
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
state?: SessionMessageProviderState
}
}
}
@@ -4040,10 +4018,8 @@ export type SyncEventSessionToolSuccess = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
}
@@ -4062,10 +4038,8 @@ export type SyncEventSessionToolFailed = {
callID: string
error: SessionErrorUnknown
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
}
@@ -4157,7 +4131,7 @@ export type SyncEventSessionRevertCommitted = {
aggregateID: string
data: {
sessionID: string
messageID: string
to: string
}
}
}
@@ -4387,15 +4361,13 @@ export type SessionMessageShell = {
export type SessionMessageAssistantText = {
type: "text"
id: string
text: string
}
export type SessionMessageAssistantReasoning = {
type: "reasoning"
id: string
text: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
time?: {
created: number
completed?: number
@@ -4449,11 +4421,9 @@ export type SessionMessageAssistantTool = {
type: "tool"
id: string
name: string
provider?: {
executed: boolean
metadata?: LlmProviderMetadata
resultMetadata?: LlmProviderMetadata
}
executed?: boolean
providerState?: SessionMessageProviderState
providerResultState?: SessionMessageProviderState
state:
| SessionMessageToolStatePending
| SessionMessageToolStateRunning
@@ -4864,7 +4834,6 @@ export type SessionTextStarted = {
data: {
sessionID: string
assistantMessageID: string
textID: string
}
}
@@ -4884,7 +4853,6 @@ export type SessionTextEnded = {
data: {
sessionID: string
assistantMessageID: string
textID: string
text: string
}
}
@@ -4905,8 +4873,7 @@ export type SessionReasoningStarted = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
@@ -4926,9 +4893,8 @@ export type SessionReasoningEnded = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
@@ -4991,14 +4957,11 @@ export type SessionToolCalled = {
sessionID: string
assistantMessageID: string
callID: string
tool: string
input: {
[key: string]: unknown
}
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
state?: SessionMessageProviderState
}
}
@@ -5049,10 +5012,8 @@ export type SessionToolSuccess = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
@@ -5075,10 +5036,8 @@ export type SessionToolFailed = {
callID: string
error: SessionErrorUnknown
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
@@ -5194,7 +5153,7 @@ export type SessionRevertCommitted = {
location?: LocationRef
data: {
sessionID: string
messageID: string
to: string
}
}
@@ -5751,7 +5710,6 @@ export type SessionTextDelta = {
data: {
sessionID: string
assistantMessageID: string
textID: string
delta: string
}
}
@@ -5767,7 +5725,6 @@ export type SessionReasoningDelta = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
delta: string
}
}
@@ -6943,7 +6900,6 @@ export type EventSessionTextStarted = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
}
}
@@ -6953,7 +6909,6 @@ export type EventSessionTextDelta = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
delta: string
}
}
@@ -6964,7 +6919,6 @@ export type EventSessionTextEnded = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
text: string
}
}
@@ -6975,8 +6929,7 @@ export type EventSessionReasoningStarted = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
@@ -6986,7 +6939,6 @@ export type EventSessionReasoningDelta = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
delta: string
}
}
@@ -6997,9 +6949,8 @@ export type EventSessionReasoningEnded = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
@@ -7043,14 +6994,11 @@ export type EventSessionToolCalled = {
sessionID: string
assistantMessageID: string
callID: string
tool: string
input: {
[key: string]: unknown
}
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
state?: SessionMessageProviderState
}
}
@@ -7081,10 +7029,8 @@ export type EventSessionToolSuccess = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
@@ -7097,10 +7043,8 @@ export type EventSessionToolFailed = {
callID: string
error: SessionErrorUnknown
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
@@ -7165,7 +7109,7 @@ export type EventSessionRevertCommitted = {
type: "session.revert.committed"
properties: {
sessionID: string
messageID: string
to: string
}
}
@@ -8081,21 +8025,17 @@ export type SessionMessageShell2 = {
export type SessionMessageAssistantText2 = {
type: "text"
id: string
text: string
}
export type LlmProviderMetadata2 = {
[key: string]: {
[key: string]: unknown
}
export type SessionMessageProviderState2 = {
[key: string]: unknown
}
export type SessionMessageAssistantReasoning2 = {
type: "reasoning"
id: string
text: string
providerMetadata?: LlmProviderMetadata2
state?: SessionMessageProviderState2
time?: {
created: number
completed?: number
@@ -8168,11 +8108,9 @@ export type SessionMessageAssistantTool2 = {
type: "tool"
id: string
name: string
provider?: {
executed: boolean
metadata?: LlmProviderMetadata2
resultMetadata?: LlmProviderMetadata2
}
executed?: boolean
providerState?: SessionMessageProviderState2
providerResultState?: SessionMessageProviderState2
state:
| SessionMessageToolStatePending2
| SessionMessageToolStateRunning2
@@ -8604,7 +8542,6 @@ export type SessionTextStarted2 = {
data: {
sessionID: string
assistantMessageID: string
textID: string
}
}
@@ -8624,15 +8561,12 @@ export type SessionTextEnded2 = {
data: {
sessionID: string
assistantMessageID: string
textID: string
text: string
}
}
export type LlmProviderMetadata3 = {
[key: string]: {
[key: string]: unknown
}
export type SessionMessageProviderState3 = {
[key: string]: unknown
}
export type SessionReasoningStarted2 = {
@@ -8651,15 +8585,12 @@ export type SessionReasoningStarted2 = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: LlmProviderMetadata3
state?: SessionMessageProviderState3
}
}
export type LlmProviderMetadata4 = {
[key: string]: {
[key: string]: unknown
}
export type SessionMessageProviderState4 = {
[key: string]: unknown
}
export type SessionReasoningEnded2 = {
@@ -8678,9 +8609,8 @@ export type SessionReasoningEnded2 = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
providerMetadata?: LlmProviderMetadata4
state?: SessionMessageProviderState4
}
}
@@ -8726,10 +8656,8 @@ export type SessionToolInputEnded2 = {
}
}
export type LlmProviderMetadata5 = {
[key: string]: {
[key: string]: unknown
}
export type SessionMessageProviderState5 = {
[key: string]: unknown
}
export type SessionToolCalled2 = {
@@ -8749,14 +8677,11 @@ export type SessionToolCalled2 = {
sessionID: string
assistantMessageID: string
callID: string
tool: string
input: {
[key: string]: unknown
}
provider: {
executed: boolean
metadata?: LlmProviderMetadata5
}
executed: boolean
state?: SessionMessageProviderState5
}
}
@@ -8784,10 +8709,8 @@ export type SessionToolProgress2 = {
}
}
export type LlmProviderMetadata6 = {
[key: string]: {
[key: string]: unknown
}
export type SessionMessageProviderState6 = {
[key: string]: unknown
}
export type SessionToolSuccess2 = {
@@ -8813,17 +8736,13 @@ export type SessionToolSuccess2 = {
content: Array<LlmToolContent2>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata6
}
executed: boolean
resultState?: SessionMessageProviderState6
}
}
export type LlmProviderMetadata7 = {
[key: string]: {
[key: string]: unknown
}
export type SessionMessageProviderState7 = {
[key: string]: unknown
}
export type SessionToolFailed2 = {
@@ -8845,10 +8764,8 @@ export type SessionToolFailed2 = {
callID: string
error: SessionErrorUnknown2
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata7
}
executed: boolean
resultState?: SessionMessageProviderState7
}
}
@@ -8977,7 +8894,7 @@ export type SessionRevertCommitted2 = {
location?: LocationRef2
data: {
sessionID: string
messageID: string
to: string
}
}
@@ -10251,7 +10168,6 @@ export type SessionTextDelta2 = {
data: {
sessionID: string
assistantMessageID: string
textID: string
delta: string
}
}
@@ -10267,7 +10183,6 @@ export type SessionReasoningDelta2 = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
delta: string
}
}
+25 -37
View File
@@ -51,6 +51,7 @@ type Data = {
// session ID in that family, including the key itself once its info arrives.
family: Record<string, string[]>
status: Record<string, DataSessionStatus>
compaction: Record<string, string>
message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionV2Request[]>
@@ -85,6 +86,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
info: {},
family: {},
status: {},
compaction: {},
message: {},
permission: {},
question: {},
@@ -143,14 +145,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
item.type === "tool" && (callID === undefined || item.id === callID),
)
},
latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
)
latestText(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
},
latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
latestReasoning(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
)
},
}
@@ -384,26 +384,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "text",
id: event.data.textID,
text: "",
})
})
break
case "session.text.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.textID,
)
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text += event.data.delta
})
break
case "session.text.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.textID,
)
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text = event.data.text
})
break
@@ -444,7 +437,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
if (!match) return
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: [] }
})
break
@@ -473,11 +467,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
content: [...event.data.content],
result: event.data.result,
}
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
})
break
@@ -496,11 +487,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
content: match.state.status === "running" ? match.state.content : [],
result: event.data.result,
}
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
})
break
@@ -508,41 +496,36 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "reasoning",
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
state: event.data.state,
time: { created: event.created },
})
})
break
case "session.reasoning.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.reasoningID,
)
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text += event.data.delta
})
break
case "session.reasoning.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.reasoningID,
)
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
if (match) {
match.text = event.data.text
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
case "session.retried":
case "session.compaction.started":
setSessionStatus(event.data.sessionID, "running")
setStore("session", "compaction", event.data.sessionID, "")
break
case "session.execution.settled":
setSessionStatus(event.data.sessionID, "idle")
setStore("session", "compaction", event.data.sessionID, undefined!)
break
case "session.revert.staged":
if (store.session.info[event.data.sessionID])
@@ -554,8 +537,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "info", event.data.sessionID, "revert", undefined)
break
case "session.compaction.delta":
setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text)
break
case "session.compaction.ended":
setStore("session", "compaction", event.data.sessionID, undefined!)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
@@ -676,6 +661,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
status(sessionID: string) {
return store.session.status[sessionID] ?? "idle"
},
compaction(sessionID: string) {
return store.session.compaction[sessionID]
},
async refresh(sessionID: string) {
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
registerSession(sessionID)
+14 -5
View File
@@ -70,7 +70,7 @@ import { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
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"
addDefaultParsers(parsers.parsers)
@@ -915,6 +915,9 @@ export function Session() {
/>
)}
</For>
<Show when={data.session.compaction(route.sessionID)}>
{(text) => <CompactionMessage text={text()} />}
</Show>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
@@ -1092,7 +1095,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
const part = createMemo(() => {
const item = message()
if (item?.type !== "assistant") return
return item.content.find((part) => part.id === props.partRef.partID)
return resolvePart(item, props.partRef.partID)
})
return (
<Show when={part()}>
@@ -1132,7 +1135,7 @@ function SessionGroupView(props: {
refs.flatMap((ref) => {
const message = props.message(ref.messageID)
if (message?.type !== "assistant") return []
const part = message.content.find((part) => part.id === ref.partID)
const part = message.content.find((part) => part.type === "tool" && part.id === ref.partID)
if (part?.type !== "tool") return []
return [part]
})
@@ -1261,9 +1264,15 @@ function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "
)
}
function CompactionMessage() {
function CompactionMessage(props: { text?: string }) {
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") {
+22 -6
View File
@@ -131,6 +131,12 @@ export function createSessionRows(sessionID: Accessor<string>) {
}),
)
const latestFragmentRef = (messageID: string, kind: "text" | "reasoning") => {
const message = data.session.message.get(sessionID(), messageID)
const ordinal = message?.type === "assistant" ? message.content.filter((part) => part.type === kind).length - 1 : 0
return { messageID, partID: `${kind}:${Math.max(0, ordinal)}` }
}
const isQueued = (messageID: string) => {
const message = data.session.message.get(sessionID(), messageID)
return message?.type === "user" && message.metadata?.queued === true
@@ -159,20 +165,19 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.model.selected", message),
data.on("session.compaction.ended", message),
data.on("session.text.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
if (event.data.sessionID === sessionID()) appendPart(latestFragmentRef(event.data.assistantMessageID, "text"))
}),
data.on("session.text.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
appendPart(latestFragmentRef(event.data.assistantMessageID, "text"))
}),
data.on("session.reasoning.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
appendPart(latestFragmentRef(event.data.assistantMessageID, "reasoning"))
}),
data.on("session.reasoning.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
appendPart(latestFragmentRef(event.data.assistantMessageID, "reasoning"))
}),
data.on("session.tool.input.started", (event) => {
if (event.data.sessionID === sessionID())
@@ -201,9 +206,11 @@ export function reduceSessionRows(messages: SessionMessage[]) {
rows.push({ type: "message", messageID: message.id })
return rows
}
const ordinals = { text: 0, reasoning: 0 }
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
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) {
completePrevious(rows)
@@ -213,6 +220,15 @@ export function reduceSessionRows(messages: SessionMessage[]) {
}, [])
}
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 isQueuedMessage(message: SessionMessage) {
return message.type === "user" && message.metadata?.queued === true
}
+41 -8
View File
@@ -461,6 +461,40 @@ test("tracks session status from active sessions and execution events", async ()
},
})
await wait(() => data.session.status("session-failed") === "idle")
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 {
app.renderer.destroy()
}
@@ -964,9 +998,9 @@ test("settles pending tools when a live failure arrives", async () => {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
tool: "bash",
input: {},
provider: { executed: false, metadata: { fake: { call: true } } },
executed: false,
state: { call: true },
},
})
emitEvent(events, {
@@ -979,7 +1013,8 @@ test("settles pending tools when a live failure arrives", async () => {
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
error: { type: "unknown", message: "aborted" },
provider: { executed: false, metadata: { fake: { result: true } } },
executed: false,
resultState: { result: true },
},
})
@@ -1005,11 +1040,9 @@ test("settles pending tools when a live failure arrives", async () => {
expect(tool.state.input).toEqual({})
expect(tool.state.structured).toEqual({})
expect(tool.state.content).toEqual([])
expect(tool.provider).toEqual({
executed: false,
metadata: { fake: { call: true } },
resultMetadata: { fake: { result: true } },
})
expect(tool.executed).toBe(false)
expect(tool.providerState).toEqual({ call: true })
expect(tool.providerResultState).toEqual({ result: true })
expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([
"agent-switched",
"model-switched",
+26 -10
View File
@@ -6,19 +6,19 @@ test("groups exploration parts across assistant messages until a delimiter", ()
const messages: SessionMessage[] = [
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
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: "glob-1", name: "glob", state: pending(), time: { created: 3 } },
]),
assistant("assistant-2", [
{ 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([
{ 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",
kind: "exploration",
@@ -30,7 +30,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
{ 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", () => {
const messages: SessionMessage[] = [
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 } },
]),
assistant("assistant-2", [
{ type: "reasoning", id: "reasoning-2", text: "" },
{ type: "reasoning", text: "" },
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
]),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } },
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
{
type: "group",
kind: "exploration",
@@ -95,9 +113,7 @@ test("completes exploration groups when another row follows", () => {
])
finished.finish = "stop"
const messages: SessionMessage[] = [
assistant("assistant-1", [
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
]),
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
finished,
]
+14
View File
@@ -939,3 +939,17 @@ 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.
## 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; UI references use assistant message ID, kind, and ordinal. 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_event_fragments` wipes experimental V2 events, sequences, projected messages, and admitted inputs.
- Promise, Effect, and legacy JavaScript SDK surfaces are regenerated from the simplified schemas.
+4
View File
@@ -116,6 +116,10 @@ Compaction keeps the full transcript durable while replacing its active model re
`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 Context Epoch baseline. 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; projected UI identity is the assistant message ID plus content kind and ordinal. Tool calls retain `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.
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.