From 7ef42ac29608a78ec5a804cfaf718129c7a29bf5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 3 Jul 2026 23:12:11 -0400 Subject: [PATCH] refactor(schema): simplify session fragment state --- .../client/src/promise/generated/types.ts | 125 ++++------ packages/core/src/database/migration.gen.ts | 1 + ...20260703200000_reset_v2_event_fragments.ts | 14 ++ packages/core/src/session/compaction.ts | 8 +- packages/core/src/session/message-updater.ts | 42 ++-- packages/core/src/session/projector.ts | 7 +- packages/core/src/session/revert.ts | 2 +- packages/core/src/session/runner/llm.ts | 6 +- .../src/session/runner/publish-llm-event.ts | 99 ++++---- .../core/src/session/runner/to-llm-message.ts | 37 ++- packages/core/test/session-compaction.test.ts | 8 +- packages/core/test/session-projector.test.ts | 5 +- .../core/test/session-runner-message.test.ts | 72 +++--- .../test/session-runner-tool-events.test.ts | 20 +- packages/core/test/session-runner.test.ts | 86 ++++--- .../core/test/session-tool-progress.test.ts | 7 +- packages/core/test/shared-schema.test.ts | 2 +- packages/core/test/tool-shell.test.ts | 3 - packages/core/test/tool-subagent.test.ts | 3 - .../llm/src/protocols/openai-responses.ts | 11 + .../test/provider/openai-responses.test.ts | 29 +++ .../src/cli/cmd/run/noninteractive.ts | 20 +- .../src/cli/cmd/run/stream-v2.subagent.ts | 155 ++++++++++--- .../src/cli/cmd/run/stream-v2.transport.ts | 92 ++++++-- .../test/cli/run/stream-v2.transport.test.ts | 33 +-- .../test/v2/session-message-updater.test.ts | 17 +- packages/schema/src/session-event.ts | 33 +-- packages/schema/src/session-message.ts | 19 +- packages/schema/test/contract-hygiene.test.ts | 24 +- packages/schema/test/event-manifest.test.ts | 30 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 219 ++++++------------ packages/tui/src/context/data.tsx | 62 ++--- packages/tui/src/routes/session/index.tsx | 19 +- packages/tui/src/routes/session/rows.ts | 28 ++- packages/tui/test/cli/tui/data.test.tsx | 49 +++- .../tui/test/cli/tui/session-rows.test.ts | 36 ++- specs/v2/schema-changelog.md | 14 ++ specs/v2/session.md | 4 + 38 files changed, 834 insertions(+), 607 deletions(-) create mode 100644 packages/core/src/database/migration/20260703200000_reset_v2_event_fragments.ts diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 151f0ff03f..3c64fc6078 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 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 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 diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index e6a236f527..00e80fda51 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -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[] diff --git a/packages/core/src/database/migration/20260703200000_reset_v2_event_fragments.ts b/packages/core/src/database/migration/20260703200000_reset_v2_event_fragments.ts new file mode 100644 index 0000000000..0541e19b3c --- /dev/null +++ b/packages/core/src/database/migration/20260703200000_reset_v2_event_fragments.ts @@ -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 diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 9ad9ddc81a..3a8758556b 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -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), diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 2a8ade7b1a..258bcaf66b 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -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 } }) }, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index f265aa6fc9..fa67dd2162 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -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( diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts index 8c0b8bbf7b..80ec187472 100644 --- a/packages/core/src/session/revert.ts +++ b/packages/core/src/session/revert.ts @@ -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, }) }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 0aeaa0e052..6213ef872c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 9a334a0a6d..cee66ba8bf 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -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 } >() - 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, + ended: (id: string, value: string, state?: Record) => Effect.Effect, + single = false, ) => { const chunks = new Map() 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) { 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 } diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index e6a91a9ef1..cb83c0fdb2 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -21,6 +21,11 @@ const media = (file: FileAttachment): ContentPart => ({ const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const providerMetadata = ( + provider: string, + state: Record | 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) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index dd5e9fb749..85d93540f0 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -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.") diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 1d77303d43..08fbf64e2b 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -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({ diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 9aed484858..4595e48f28 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -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" }, diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index d016b2694b..ac14921734 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -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" }) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6856b6a7ce..c9320730a7 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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" }, ], }, ]) diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index e730b3556b..8344771d2a 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -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: { diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index ecdd555fa8..eb45e49106 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -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) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 117eba27f1..bec55e4b8a 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -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, { diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 4dd46da691..e15e1833c2 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -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, { diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index de18bf42a0..bfb97278cf 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -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" || diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index fbc5f2a864..3d048d4eee 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -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( diff --git a/packages/opencode/src/cli/cmd/run/noninteractive.ts b/packages/opencode/src/cli/cmd/run/noninteractive.ts index 6874527222..e0b0356b7b 100644 --- a/packages/opencode/src/cli/cmd/run/noninteractive.ts +++ b/packages/opencode/src/cli/cmd/run/noninteractive.ts @@ -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 }, diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts b/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts index 1e7b039fd8..0796a082c1 100644 --- a/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts +++ b/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts @@ -42,8 +42,18 @@ export function legacyTool(input: { name: string state: SessionMessageAssistantTool["state"] time: SessionMessageAssistantTool["time"] - provider?: SessionMessageAssistantTool["provider"] + executed?: boolean + providerState?: Record + providerResultState?: Record }): 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 started: number + executed?: boolean + providerState?: Record } type ChildState = { @@ -157,6 +171,10 @@ type ChildState = { projectedText: Map reasoning: Map projectedReasoning: Map + textOrdinals: Map + reasoningOrdinals: Map + activeText: Map + activeReasoning: Map tools: Map finishedTools: Set messageIDs: Set @@ -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>() + const subagentCalls = new Set() // Foreign sessions already resolved through session.get. Non-children stay // cached so unrelated concurrent sessions are checked at most once. const checked = new Set() @@ -225,6 +244,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac const pendingEvents = new Map() const hydrations = new Map>() 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) diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts b/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts index 42e2c7bd53..b19904de4e 100644 --- a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts @@ -95,6 +95,8 @@ type ToolState = { input: Record started: number running: boolean + executed?: boolean + providerState?: Record } type State = { @@ -106,6 +108,10 @@ type State = { projectedText: Map reasoning: Map projectedReasoning: Map + textOrdinals: Map + reasoningOrdinals: Map + activeText: Map + activeReasoning: Map tools: Map finishedTools: Set skillMessages: Set @@ -288,6 +294,10 @@ export async function createSessionTransport(input: StreamInput): Promise= 0) { @@ -613,13 +640,16 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) write([ @@ -629,15 +659,25 @@ export async function createSessionTransport(input: StreamInput): Promise= 0) { @@ -654,13 +694,16 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) write([ @@ -670,11 +713,12 @@ export async function createSessionTransport(input: StreamInput): Promise { 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. diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 1fc3dda0df..22b05a5538 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -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", () => { diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 824b1690db..d14f8b7a8d 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -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 }, }) } diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index c9f39193a5..f6246007a8 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -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 {} 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 {} 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 {} 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), diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index edad66725e..c36459981f 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -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") + }) }) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 487a942d9a..82b695ca8c 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -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) + }) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2b6e1d031f..4ffc01c30e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 outputPaths?: Array 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 outputPaths?: Array 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 outputPaths?: Array 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 outputPaths?: Array 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 outputPaths?: Array 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 } } diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 82a7150c9e..ed5f9b9c62 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -51,6 +51,7 @@ type Data = { // session ID in that family, including the key itself once its info arrives. family: Record status: Record + compaction: Record message: Record permission: Record question: Record @@ -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) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index dd9fd34d07..7f64cdcb88 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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() { /> )} + + {(text) => } + { 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 ( @@ -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 + return ( + + + {props.text} + + + ) } function statusLabel(status: "added" | "modified" | "deleted") { diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index fc0b38b1ad..61dd6e7fac 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -131,6 +131,12 @@ export function createSessionRows(sessionID: Accessor) { }), ) + 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) { 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 } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index d9e82c2d3c..0fce5202ba 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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", diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index fec6f5eb87..46b1f91a37 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -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, ] diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 3fd4fb4a17..4e5d3edba6 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -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. diff --git a/specs/v2/session.md b/specs/v2/session.md index c4307b6897..14fd632a67 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -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.