From 54278125c6e29aee772c4757606d35a8e742d139 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 24 Apr 2026 18:19:59 -0400 Subject: [PATCH 01/14] fix session event typechecks and shell cwd --- packages/opencode/src/v2/session-event.ts | 590 ++++++++-------------- 1 file changed, 215 insertions(+), 375 deletions(-) diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index f922becf3a..49ec74b88b 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,127 +1,132 @@ import { Identifier } from "@/id/id" import { withStatics } from "@/util/schema" -import * as DateTime from "effect/DateTime" import { Schema } from "effect" +import { SyncEvent } from "@/sync" +import { SessionID } from "@/session/schema" +import * as DateTime from "effect/DateTime" -export namespace SessionEvent { - export const ID = Schema.String.pipe( - Schema.brand("Session.Event.ID"), - withStatics((s) => ({ - create: () => s.make(Identifier.create("evt", "ascending")), - })), - ) - export type ID = Schema.Schema.Type - type Stamp = Schema.Schema.Type - type BaseInput = { - id?: ID - metadata?: Record - timestamp?: Stamp +export const ID = Schema.String.pipe( + Schema.brand("Session.Event.ID"), + withStatics((s) => ({ + create: () => s.make(Identifier.create("evt", "ascending")), + })), +) +export type ID = Schema.Schema.Type +type Stamp = Schema.Schema.Type +type BaseInput = { + id?: ID + sessionID: SessionID + metadata?: Record + timestamp?: Stamp +} + +function defineEvent(identifier: string) { + return (input: { + type: Type + schema: Fields + version?: number + }) => { + const RawEvent = Schema.Class(identifier)({ + id: ID, + sessionID: SessionID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + timestamp: Schema.DateTimeUtc, + type: Schema.Literal(input.type), + ...input.schema, + }) + const Event = RawEvent as Exclude + + const Sync = SyncEvent.define({ + type: input.type, + version: input.version ?? 1, + aggregate: "sessionID", + schema: Event, + }) + + return Object.assign(Event, { + Sync, + create(value: BaseInput & Record) { + return new (Event as unknown as new (value: Record) => Self)({ + ...value, + id: value.id ?? ID.create(), + sessionID: value.sessionID, + timestamp: value.timestamp ?? DateTime.makeUnsafe(Date.now()), + type: input.type, + }) + }, + }) } +} - const Base = { - id: ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - timestamp: Schema.DateTimeUtc, +export class Source extends Schema.Class("Session.Event.Source")({ + start: Schema.Number, + end: Schema.Number, + text: Schema.String, +}) {} + +export class FileAttachment extends Schema.Class("Session.Event.FileAttachment")({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}) { + static create(input: FileAttachment) { + return new FileAttachment({ + uri: input.uri, + mime: input.mime, + name: input.name, + description: input.description, + source: input.source, + }) } +} - export class Source extends Schema.Class("Session.Event.Source")({ - start: Schema.Number, - end: Schema.Number, - text: Schema.String, - }) {} +export class AgentAttachment extends Schema.Class("Session.Event.AgentAttachment")({ + name: Schema.String, + source: Source.pipe(Schema.optional), +}) {} - export class FileAttachment extends Schema.Class("Session.Event.FileAttachment")({ - uri: Schema.String, - mime: Schema.String, - name: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - source: Source.pipe(Schema.optional), - }) { - static create(input: FileAttachment) { - return new FileAttachment({ - uri: input.uri, - mime: input.mime, - name: input.name, - description: input.description, - source: input.source, - }) - } - } +export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ + message: Schema.String, + statusCode: Schema.Number.pipe(Schema.optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + responseBody: Schema.String.pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), +}) {} - export class AgentAttachment extends Schema.Class("Session.Event.AgentAttachment")({ - name: Schema.String, - source: Source.pipe(Schema.optional), - }) {} - - export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ - message: Schema.String, - statusCode: Schema.Number.pipe(Schema.optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - responseBody: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - }) {} - - export class Prompt extends Schema.Class("Session.Event.Prompt")({ - ...Base, - type: Schema.Literal("prompt"), +export class Prompt extends defineEvent("Session.Event.Prompt")({ + type: "prompt", + schema: { text: Schema.String, files: Schema.Array(FileAttachment).pipe(Schema.optional), agents: Schema.Array(AgentAttachment).pipe(Schema.optional), - }) { - static create(input: BaseInput & { text: string; files?: FileAttachment[]; agents?: AgentAttachment[] }) { - return new Prompt({ - id: input.id ?? ID.create(), - type: "prompt", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - files: input.files, - agents: input.agents, - }) - } - } + }, +}) {} - export class Synthetic extends Schema.Class("Session.Event.Synthetic")({ - ...Base, - type: Schema.Literal("synthetic"), +export class Synthetic extends defineEvent("Session.Event.Synthetic")({ + type: "synthetic", + schema: { text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Synthetic({ - id: input.id ?? ID.create(), - type: "synthetic", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } + }, +}) {} - export namespace Step { - export class Started extends Schema.Class("Session.Event.Step.Started")({ - ...Base, - type: Schema.Literal("step.started"), +export namespace Step { + export class Started extends defineEvent("Session.Event.Step.Started")({ + type: "step.started", + schema: { model: Schema.Struct({ id: Schema.String, providerID: Schema.String, variant: Schema.String.pipe(Schema.optional), }), - }) { - static create(input: BaseInput & { model: { id: string; providerID: string; variant?: string } }) { - return new Started({ - id: input.id ?? ID.create(), - type: "step.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - model: input.model, - }) - } - } + }, + }) {} - export class Ended extends Schema.Class("Session.Event.Step.Ended")({ - ...Base, - type: Schema.Literal("step.ended"), + export class Ended extends defineEvent("Session.Event.Step.Ended")({ + type: "step.ended", + schema: { reason: Schema.String, cost: Schema.Number, tokens: Schema.Struct({ @@ -133,177 +138,82 @@ export namespace SessionEvent { write: Schema.Number, }), }), - }) { - static create(input: BaseInput & { reason: string; cost: number; tokens: Ended["tokens"] }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "step.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - reason: input.reason, - cost: input.cost, - tokens: input.tokens, - }) - } - } - } + }, + }) {} +} - export namespace Text { - export class Started extends Schema.Class("Session.Event.Text.Started")({ - ...Base, - type: Schema.Literal("text.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "text.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } +export namespace Text { + export class Started extends defineEvent("Session.Event.Text.Started")({ + type: "text.started", + schema: {}, + }) {} - export class Delta extends Schema.Class("Session.Event.Text.Delta")({ - ...Base, - type: Schema.Literal("text.delta"), + export class Delta extends defineEvent("Session.Event.Text.Delta")({ + type: "text.delta", + schema: { delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "text.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } + }, + }) {} - export class Ended extends Schema.Class("Session.Event.Text.Ended")({ - ...Base, - type: Schema.Literal("text.ended"), + export class Ended extends defineEvent("Session.Event.Text.Ended")({ + type: "text.ended", + schema: { text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "text.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) {} +} - export namespace Reasoning { - export class Started extends Schema.Class("Session.Event.Reasoning.Started")({ - ...Base, - type: Schema.Literal("reasoning.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "reasoning.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } +export namespace Reasoning { + export class Started extends defineEvent("Session.Event.Reasoning.Started")({ + type: "reasoning.started", + schema: {}, + }) {} - export class Delta extends Schema.Class("Session.Event.Reasoning.Delta")({ - ...Base, - type: Schema.Literal("reasoning.delta"), + export class Delta extends defineEvent("Session.Event.Reasoning.Delta")({ + type: "reasoning.delta", + schema: { delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "reasoning.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } + }, + }) {} - export class Ended extends Schema.Class("Session.Event.Reasoning.Ended")({ - ...Base, - type: Schema.Literal("reasoning.ended"), + export class Ended extends defineEvent("Session.Event.Reasoning.Ended")({ + type: "reasoning.ended", + schema: { text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "reasoning.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) {} +} - export namespace Tool { - export namespace Input { - export class Started extends Schema.Class("Session.Event.Tool.Input.Started")({ - ...Base, +export namespace Tool { + export namespace Input { + export class Started extends defineEvent("Session.Event.Tool.Input.Started")({ + type: "tool.input.started", + schema: { callID: Schema.String, name: Schema.String, - type: Schema.Literal("tool.input.started"), - }) { - static create(input: BaseInput & { callID: string; name: string }) { - return new Started({ - id: input.id ?? ID.create(), - type: "tool.input.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - name: input.name, - }) - } - } + }, + }) {} - export class Delta extends Schema.Class("Session.Event.Tool.Input.Delta")({ - ...Base, + export class Delta extends defineEvent("Session.Event.Tool.Input.Delta")({ + type: "tool.input.delta", + schema: { callID: Schema.String, - type: Schema.Literal("tool.input.delta"), delta: Schema.String, - }) { - static create(input: BaseInput & { callID: string; delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "tool.input.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - delta: input.delta, - }) - } - } + }, + }) {} - export class Ended extends Schema.Class("Session.Event.Tool.Input.Ended")({ - ...Base, + export class Ended extends defineEvent("Session.Event.Tool.Input.Ended")({ + type: "tool.input.ended", + schema: { callID: Schema.String, - type: Schema.Literal("tool.input.ended"), text: Schema.String, - }) { - static create(input: BaseInput & { callID: string; text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "tool.input.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - text: input.text, - }) - } - } - } + }, + }) {} + } - export class Called extends Schema.Class("Session.Event.Tool.Called")({ - ...Base, - type: Schema.Literal("tool.called"), + export class Called extends defineEvent("Session.Event.Tool.Called")({ + type: "tool.called", + schema: { callID: Schema.String, tool: Schema.String, input: Schema.Record(Schema.String, Schema.Unknown), @@ -311,31 +221,12 @@ export namespace SessionEvent { executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - tool: string - input: Record - provider: Called["provider"] - }, - ) { - return new Called({ - id: input.id ?? ID.create(), - type: "tool.called", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - tool: input.tool, - input: input.input, - provider: input.provider, - }) - } - } + }, + }) {} - export class Success extends Schema.Class("Session.Event.Tool.Success")({ - ...Base, - type: Schema.Literal("tool.success"), + export class Success extends defineEvent("Session.Event.Tool.Success")({ + type: "tool.success", + schema: { callID: Schema.String, title: Schema.String, output: Schema.String.pipe(Schema.optional), @@ -344,115 +235,64 @@ export namespace SessionEvent { executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - title: string - output?: string - attachments?: FileAttachment[] - provider: Success["provider"] - }, - ) { - return new Success({ - id: input.id ?? ID.create(), - type: "tool.success", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - title: input.title, - output: input.output, - attachments: input.attachments, - provider: input.provider, - }) - } - } + }, + }) {} - export class Error extends Schema.Class("Session.Event.Tool.Error")({ - ...Base, - type: Schema.Literal("tool.error"), + export class Error extends defineEvent("Session.Event.Tool.Error")({ + type: "tool.error", + schema: { callID: Schema.String, error: Schema.String, provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create(input: BaseInput & { callID: string; error: string; provider: Error["provider"] }) { - return new Error({ - id: input.id ?? ID.create(), - type: "tool.error", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - error: input.error, - provider: input.provider, - }) - } - } - } + }, + }) {} +} - export class Retried extends Schema.Class("Session.Event.Retried")({ - ...Base, - type: Schema.Literal("retried"), +export class Retried extends defineEvent("Session.Event.Retried")({ + type: "retried", + schema: { attempt: Schema.Number, error: RetryError, - }) { - static create(input: BaseInput & { attempt: number; error: RetryError }) { - return new Retried({ - id: input.id ?? ID.create(), - type: "retried", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - attempt: input.attempt, - error: input.error, - }) - } - } + }, +}) {} - export class Compacted extends Schema.Class("Session.Event.Compated")({ - ...Base, - type: Schema.Literal("compacted"), +export class Compacted extends defineEvent("Session.Event.Compacted")({ + type: "compacted", + schema: { auto: Schema.Boolean, overflow: Schema.Boolean.pipe(Schema.optional), - }) { - static create(input: BaseInput & { auto: boolean; overflow?: boolean }) { - return new Compacted({ - id: input.id ?? ID.create(), - type: "compacted", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - auto: input.auto, - overflow: input.overflow, - }) - } - } + }, +}) {} - export const Event = Schema.Union( - [ - Prompt, - Synthetic, - Step.Started, - Step.Ended, - Text.Started, - Text.Delta, - Text.Ended, - Tool.Input.Started, - Tool.Input.Delta, - Tool.Input.Ended, - Tool.Called, - Tool.Success, - Tool.Error, - Reasoning.Started, - Reasoning.Delta, - Reasoning.Ended, - Retried, - Compacted, - ], - { - mode: "oneOf", - }, - ).pipe(Schema.toTaggedUnion("type")) - export type Event = Schema.Schema.Type - export type Type = Event["type"] -} +export const Event = Schema.Union( + [ + Prompt, + Synthetic, + Step.Started, + Step.Ended, + Text.Started, + Text.Delta, + Text.Ended, + Tool.Input.Started, + Tool.Input.Delta, + Tool.Input.Ended, + Tool.Called, + Tool.Success, + Tool.Error, + Reasoning.Started, + Reasoning.Delta, + Reasoning.Ended, + Retried, + Compacted, + ], + { + mode: "oneOf", + }, +).pipe(Schema.toTaggedUnion("type")) +export type Event = Schema.Schema.Type +export type Type = Event["type"] + +export * as SessionEvent from "./session-event" From c827c4a722de3ed9c58044389140ad59be9a5ad1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 26 Apr 2026 12:52:28 -0400 Subject: [PATCH 02/14] refactor(session): define v2 session events as schemas --- .../opencode/src/v2/session-entry-stepper.ts | 36 +- packages/opencode/src/v2/session-entry.ts | 16 +- packages/opencode/src/v2/session-event.ts | 262 ++-- packages/opencode/src/v2/session-prompt.ts | 36 + .../session/session-entry-stepper.test.ts | 1061 ++++++----------- specs/v2/session-concepts-gap.md | 131 ++ 6 files changed, 710 insertions(+), 832 deletions(-) create mode 100644 packages/opencode/src/v2/session-prompt.ts create mode 100644 specs/v2/session-concepts-gap.md diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts index 3fe4266c04..3fce4eab50 100644 --- a/packages/opencode/src/v2/session-entry-stepper.ts +++ b/packages/opencode/src/v2/session-entry-stepper.ts @@ -64,7 +64,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning") SessionEvent.Event.match(event, { - prompt: (event) => { + "session.prompted": (event) => { const entry = SessionEntry.User.fromEvent(event) if (currentAssistant) { adapter.appendPending(entry) @@ -72,10 +72,10 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E } adapter.appendEntry(entry) }, - synthetic: (event) => { + "session.synthetic": (event) => { adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event)) }, - "step.started": (event) => { + "session.step.started": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -85,7 +85,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E } adapter.appendEntry(SessionEntry.Assistant.fromEvent(event)) }, - "step.ended": (event) => { + "session.step.ended": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -96,7 +96,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "text.started": () => { + "session.text.started": () => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -108,7 +108,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "text.delta": (event) => { + "session.text.delta": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -118,8 +118,8 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "text.ended": () => {}, - "tool.input.started": (event) => { + "session.text.ended": () => {}, + "session.tool.input.started": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -139,7 +139,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "tool.input.delta": (event) => { + "session.tool.input.delta": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -150,8 +150,8 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "tool.input.ended": () => {}, - "tool.called": (event) => { + "session.tool.input.ended": () => {}, + "session.tool.called": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -167,7 +167,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "tool.success": (event) => { + "session.tool.success": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -186,7 +186,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "tool.error": (event) => { + "session.tool.error": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -203,7 +203,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "reasoning.started": () => { + "session.reasoning.started": () => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -215,7 +215,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "reasoning.delta": (event) => { + "session.reasoning.delta": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -225,7 +225,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "reasoning.ended": (event) => { + "session.reasoning.ended": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -235,7 +235,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - retried: (event) => { + "session.retried": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -244,7 +244,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - compacted: (event) => { + "session.compacted": (event) => { adapter.appendEntry(SessionEntry.Compaction.fromEvent(event)) }, }) diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index b261d8b5b2..398ec14cc7 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { Prompt } from "./session-prompt" import { SessionEvent } from "./session-event" export const ID = SessionEvent.ID @@ -14,22 +15,22 @@ const Base = { export class User extends Schema.Class("Session.Entry.User")({ ...Base, - text: SessionEvent.Prompt.fields.text, - files: SessionEvent.Prompt.fields.files, - agents: SessionEvent.Prompt.fields.agents, + text: Prompt.fields.text, + files: Prompt.fields.files, + agents: Prompt.fields.agents, type: Schema.Literal("user"), time: Schema.Struct({ created: Schema.DateTimeUtc, }), }) { - static fromEvent(event: SessionEvent.Prompt) { + static fromEvent(event: SessionEvent.Prompted) { return new User({ id: event.id, type: "user", metadata: event.metadata, - text: event.text, - files: event.files, - agents: event.agents, + text: event.prompt.text, + files: event.prompt.files, + agents: event.prompt.agents, time: { created: event.timestamp }, }) } @@ -43,6 +44,7 @@ export class Synthetic extends Schema.Class("Session.Entry.Synthetic" static fromEvent(event: SessionEvent.Synthetic) { return new Synthetic({ ...event, + type: "synthetic", time: { created: event.timestamp }, }) } diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 49ec74b88b..5623f1c485 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,9 +1,10 @@ import { Identifier } from "@/id/id" +import { FileAttachment, Prompt } from "./session-prompt" +import { SessionID } from "@/session/schema" +import { SyncEvent } from "@/sync" import { withStatics } from "@/util/schema" import { Schema } from "effect" -import { SyncEvent } from "@/sync" -import { SessionID } from "@/session/schema" -import * as DateTime from "effect/DateTime" +export { FileAttachment } export const ID = Schema.String.pipe( Schema.brand("Session.Event.ID"), @@ -12,109 +13,64 @@ export const ID = Schema.String.pipe( })), ) export type ID = Schema.Schema.Type -type Stamp = Schema.Schema.Type -type BaseInput = { - id?: ID - sessionID: SessionID - metadata?: Record - timestamp?: Stamp + +function defineEvent(input: { + type: Type + schema: Fields + version?: number +}) { + const Event = Schema.Struct({ + id: ID, + sessionID: SessionID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + timestamp: Schema.DateTimeUtc, + type: Schema.Literal(input.type), + version: Schema.Number.pipe(Schema.optional), + ...input.schema, + }).annotate({ + identifier: input.type, + }) + + const Sync = SyncEvent.define({ + type: input.type, + version: input.version ?? 1, + aggregate: "sessionID", + schema: Event, + }) + + return Object.assign(Event, { + Sync, + }) } -function defineEvent(identifier: string) { - return (input: { - type: Type - schema: Fields - version?: number - }) => { - const RawEvent = Schema.Class(identifier)({ - id: ID, - sessionID: SessionID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - timestamp: Schema.DateTimeUtc, - type: Schema.Literal(input.type), - ...input.schema, - }) - const Event = RawEvent as Exclude - - const Sync = SyncEvent.define({ - type: input.type, - version: input.version ?? 1, - aggregate: "sessionID", - schema: Event, - }) - - return Object.assign(Event, { - Sync, - create(value: BaseInput & Record) { - return new (Event as unknown as new (value: Record) => Self)({ - ...value, - id: value.id ?? ID.create(), - sessionID: value.sessionID, - timestamp: value.timestamp ?? DateTime.makeUnsafe(Date.now()), - type: input.type, - }) - }, - }) - } -} - -export class Source extends Schema.Class("Session.Event.Source")({ +export const Source = Schema.Struct({ start: Schema.Number, end: Schema.Number, text: Schema.String, -}) {} +}).annotate({ + identifier: "session.event.source", +}) +export type Source = Schema.Schema.Type -export class FileAttachment extends Schema.Class("Session.Event.FileAttachment")({ - uri: Schema.String, - mime: Schema.String, - name: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - source: Source.pipe(Schema.optional), -}) { - static create(input: FileAttachment) { - return new FileAttachment({ - uri: input.uri, - mime: input.mime, - name: input.name, - description: input.description, - source: input.source, - }) - } -} - -export class AgentAttachment extends Schema.Class("Session.Event.AgentAttachment")({ - name: Schema.String, - source: Source.pipe(Schema.optional), -}) {} - -export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ - message: Schema.String, - statusCode: Schema.Number.pipe(Schema.optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - responseBody: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), -}) {} - -export class Prompt extends defineEvent("Session.Event.Prompt")({ - type: "prompt", +export const Prompted = defineEvent({ + type: "session.prompted", schema: { - text: Schema.String, - files: Schema.Array(FileAttachment).pipe(Schema.optional), - agents: Schema.Array(AgentAttachment).pipe(Schema.optional), + prompt: Prompt, }, -}) {} +}) +export type Prompted = Schema.Schema.Type -export class Synthetic extends defineEvent("Session.Event.Synthetic")({ - type: "synthetic", +export const Synthetic = defineEvent({ + type: "session.synthetic", schema: { text: Schema.String, }, -}) {} +}) +export type Synthetic = Schema.Schema.Type export namespace Step { - export class Started extends defineEvent("Session.Event.Step.Started")({ - type: "step.started", + export const Started = defineEvent({ + type: "session.step.started", schema: { model: Schema.Struct({ id: Schema.String, @@ -122,10 +78,11 @@ export namespace Step { variant: Schema.String.pipe(Schema.optional), }), }, - }) {} + }) + export type Started = Schema.Schema.Type - export class Ended extends defineEvent("Session.Event.Step.Ended")({ - type: "step.ended", + export const Ended = defineEvent({ + type: "session.step.ended", schema: { reason: Schema.String, cost: Schema.Number, @@ -139,80 +96,90 @@ export namespace Step { }), }), }, - }) {} + }) + export type Ended = Schema.Schema.Type } export namespace Text { - export class Started extends defineEvent("Session.Event.Text.Started")({ - type: "text.started", + export const Started = defineEvent({ + type: "session.text.started", schema: {}, - }) {} + }) + export type Started = Schema.Schema.Type - export class Delta extends defineEvent("Session.Event.Text.Delta")({ - type: "text.delta", + export const Delta = defineEvent({ + type: "session.text.delta", schema: { delta: Schema.String, }, - }) {} + }) + export type Delta = Schema.Schema.Type - export class Ended extends defineEvent("Session.Event.Text.Ended")({ - type: "text.ended", + export const Ended = defineEvent({ + type: "session.text.ended", schema: { text: Schema.String, }, - }) {} + }) + export type Ended = Schema.Schema.Type } export namespace Reasoning { - export class Started extends defineEvent("Session.Event.Reasoning.Started")({ - type: "reasoning.started", + export const Started = defineEvent({ + type: "session.reasoning.started", schema: {}, - }) {} + }) + export type Started = Schema.Schema.Type - export class Delta extends defineEvent("Session.Event.Reasoning.Delta")({ - type: "reasoning.delta", + export const Delta = defineEvent({ + type: "session.reasoning.delta", schema: { delta: Schema.String, }, - }) {} + }) + export type Delta = Schema.Schema.Type - export class Ended extends defineEvent("Session.Event.Reasoning.Ended")({ - type: "reasoning.ended", + export const Ended = defineEvent({ + type: "session.reasoning.ended", schema: { text: Schema.String, }, - }) {} + }) + export type Ended = Schema.Schema.Type } export namespace Tool { export namespace Input { - export class Started extends defineEvent("Session.Event.Tool.Input.Started")({ - type: "tool.input.started", + export const Started = defineEvent({ + type: "session.tool.input.started", schema: { callID: Schema.String, name: Schema.String, }, - }) {} + }) + export type Started = Schema.Schema.Type - export class Delta extends defineEvent("Session.Event.Tool.Input.Delta")({ - type: "tool.input.delta", + export const Delta = defineEvent({ + type: "session.tool.input.delta", schema: { callID: Schema.String, delta: Schema.String, }, - }) {} + }) + export type Delta = Schema.Schema.Type - export class Ended extends defineEvent("Session.Event.Tool.Input.Ended")({ - type: "tool.input.ended", + export const Ended = defineEvent({ + type: "session.tool.input.ended", schema: { callID: Schema.String, text: Schema.String, }, - }) {} + }) + export type Ended = Schema.Schema.Type } - export class Called extends defineEvent("Session.Event.Tool.Called")({ - type: "tool.called", + export const Called = defineEvent({ + type: "session.tool.called", schema: { callID: Schema.String, tool: Schema.String, @@ -222,10 +189,11 @@ export namespace Tool { metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), }, - }) {} + }) + export type Called = Schema.Schema.Type - export class Success extends defineEvent("Session.Event.Tool.Success")({ - type: "tool.success", + export const Success = defineEvent({ + type: "session.tool.success", schema: { callID: Schema.String, title: Schema.String, @@ -236,10 +204,11 @@ export namespace Tool { metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), }, - }) {} + }) + export type Success = Schema.Schema.Type - export class Error extends defineEvent("Session.Event.Tool.Error")({ - type: "tool.error", + export const Error = defineEvent({ + type: "session.tool.error", schema: { callID: Schema.String, error: Schema.String, @@ -248,28 +217,43 @@ export namespace Tool { metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), }, - }) {} + }) + export type Error = Schema.Schema.Type } -export class Retried extends defineEvent("Session.Event.Retried")({ - type: "retried", +export const RetryError = Schema.Struct({ + message: Schema.String, + statusCode: Schema.Number.pipe(Schema.optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + responseBody: Schema.String.pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), +}).annotate({ + identifier: "session.retry_error", +}) +export type RetryError = Schema.Schema.Type + +export const Retried = defineEvent({ + type: "session.retried", schema: { attempt: Schema.Number, error: RetryError, }, -}) {} +}) +export type Retried = Schema.Schema.Type -export class Compacted extends defineEvent("Session.Event.Compacted")({ - type: "compacted", +export const Compacted = defineEvent({ + type: "session.compacted", schema: { auto: Schema.Boolean, overflow: Schema.Boolean.pipe(Schema.optional), }, -}) {} +}) +export type Compacted = Schema.Schema.Type export const Event = Schema.Union( [ - Prompt, + Prompted, Synthetic, Step.Started, Step.Ended, diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/opencode/src/v2/session-prompt.ts new file mode 100644 index 0000000000..e7068e4092 --- /dev/null +++ b/packages/opencode/src/v2/session-prompt.ts @@ -0,0 +1,36 @@ +import * as Schema from "effect/Schema" + +export class Source extends Schema.Class("Prompt.Source")({ + start: Schema.Number, + end: Schema.Number, + text: Schema.String, +}) {} + +export class FileAttachment extends Schema.Class("Prompt.FileAttachment")({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}) { + static create(input: FileAttachment) { + return new FileAttachment({ + uri: input.uri, + mime: input.mime, + name: input.name, + description: input.description, + source: input.source, + }) + } +} + +export class AgentAttachment extends Schema.Class("Prompt.AgentAttachment")({ + name: Schema.String, + source: Source.pipe(Schema.optional), +}) {} + +export class Prompt extends Schema.Class("Prompt")({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(Schema.optional), + agents: Schema.Array(AgentAttachment).pipe(Schema.optional), +}) {} diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts index defce40c14..0feb00759a 100644 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ b/packages/opencode/test/session/session-entry-stepper.test.ts @@ -1,24 +1,48 @@ import { describe, expect, test } from "bun:test" import * as DateTime from "effect/DateTime" -import * as FastCheck from "effect/testing/FastCheck" +import { SessionID } from "../../src/session/schema" import { SessionEntry } from "../../src/v2/session-entry" import { SessionEntryStepper } from "../../src/v2/session-entry-stepper" import { SessionEvent } from "../../src/v2/session-event" +const sessionID = SessionID.descending() const time = (n: number) => DateTime.makeUnsafe(n) +const tokens = { + input: 1, + output: 2, + reasoning: 3, + cache: { + read: 4, + write: 5, + }, +} -const word = FastCheck.string({ minLength: 1, maxLength: 8 }) -const text = FastCheck.string({ maxLength: 16 }) -const texts = FastCheck.array(text, { maxLength: 8 }) -const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 })) -const dict = FastCheck.dictionary(word, val, { maxKeys: 4 }) -const files = FastCheck.array( - word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })), - { maxLength: 2 }, -) +function base(type: Type, timestamp: number) { + return { + id: SessionEvent.ID.create(), + type, + sessionID, + timestamp: time(timestamp), + } +} -function maybe(arb: FastCheck.Arbitrary) { - return FastCheck.oneof(FastCheck.constant(undefined), arb) +function stepStarted(timestamp = 1) { + return ({ + ...base("session.step.started", timestamp), + model: { + id: "model", + providerID: "provider", + }, + }) +} + +function stepEnded(timestamp = 1) { + return ({ + ...base("session.step.ended", timestamp), + reason: "stop", + cost: 1, + tokens, + }) } function assistant() { @@ -32,12 +56,20 @@ function assistant() { } function retryError(message: string) { - return new SessionEvent.RetryError({ + return ({ message, isRetryable: true, }) } +function retried(attempt: number, message: string, timestamp = 1) { + return ({ + ...base("session.retried", timestamp), + attempt, + error: retryError(message), + }) +} + function retry(attempt: number, message: string, created: number) { return new SessionEntry.AssistantRetry({ attempt, @@ -74,7 +106,7 @@ function last(state: SessionEntryStepper.MemoryState) { return entry?.type === "assistant" ? entry : undefined } -function texts_of(state: SessionEntryStepper.MemoryState) { +function textsOf(state: SessionEntryStepper.MemoryState) { const entry = last(state) if (!entry) return [] return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text") @@ -102,122 +134,15 @@ function retriesOf(state: SessionEntryStepper.MemoryState) { return entry.retries ?? [] } -function adapterStore() { - return { - committed: [] as SessionEntry.Entry[], - deferred: [] as SessionEntry.Entry[], - } -} - -function adapterFor(store: ReturnType): SessionEntryStepper.Adapter { - const activeAssistantIndex = () => - store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) - - const getCurrentAssistant = () => { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = store.committed[index] - return assistant?.type === "assistant" ? assistant : undefined - } - - return { - getCurrentAssistant, - updateAssistant(assistant) { - const index = activeAssistantIndex() - if (index < 0) return - const current = store.committed[index] - if (current?.type !== "assistant") return - store.committed[index] = assistant - }, - appendEntry(entry) { - store.committed.push(entry) - }, - appendPending(entry) { - store.deferred.push(entry) - }, - finish() { - return store - }, - } -} - describe("session-entry-stepper", () => { describe("stepWith", () => { - test("reduces through a custom adapter", () => { - const store = adapterStore() - store.committed.push(assistant()) - - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) })) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }), - ) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(7), - }), - ) - - expect(store.deferred).toHaveLength(1) - expect(store.deferred[0]?.type).toBe("user") - expect(store.committed).toHaveLength(1) - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].content).toEqual([ - { type: "reasoning", text: "thought" }, - { type: "text", text: "world" }, - ]) - expect(store.committed[0].time.completed).toEqual(time(7)) - }) - test("aggregates retry events onto the current assistant", () => { - const store = adapterStore() - store.committed.push(assistant()) + const state = active() - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 2, - error: retryError("provider overloaded"), - timestamp: time(2), - }), - ) + SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), retried(1, "rate limited", 1)) + SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), retried(2, "provider overloaded", 2)) - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)]) + expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)]) }) }) @@ -253,9 +178,9 @@ describe("session-entry-stepper", () => { const state = memoryState() const adapter = SessionEntryStepper.memory(state) const committed = SessionEntry.User.fromEvent( - SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }), + ({ ...base("session.prompted", 1), prompt: { text: "committed" } }), ) - const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) })) + const pending = SessionEntry.User.fromEvent(({ ...base("session.prompted", 2), prompt: { text: "pending" } })) adapter.appendEntry(committed) adapter.appendPending(pending) @@ -267,17 +192,14 @@ describe("session-entry-stepper", () => { test("stepWith through memory records reasoning", () => { const state = active() + SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), (base("session.reasoning.started", 1))) SessionEntryStepper.stepWith( SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), + ({ ...base("session.reasoning.delta", 2), delta: "draft" }), ) SessionEntryStepper.stepWith( SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }), + ({ ...base("session.reasoning.ended", 3), text: "final" }), ) expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }]) @@ -286,14 +208,7 @@ describe("session-entry-stepper", () => { test("stepWith through memory records retries", () => { const state = active() - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) + SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), retried(1, "rate limited", 1)) expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)]) }) @@ -302,460 +217,286 @@ describe("session-entry-stepper", () => { describe("step", () => { describe("seeded pending assistant", () => { test("stores prompts in entries when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("user") - if (next.entries[0]?.type !== "user") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) + const next = SessionEntryStepper.step(memoryState(), ({ ...base("session.prompted", 1), prompt: { text: "hello" } })) + expect(next.entries).toHaveLength(1) + expect(next.entries[0]?.type).toBe("user") + if (next.entries[0]?.type !== "user") return + expect(next.entries[0].text).toBe("hello") }) test("stores prompts in pending when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - active(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.pending).toHaveLength(1) - expect(next.pending[0]?.type).toBe("user") - if (next.pending[0]?.type !== "user") return - expect(next.pending[0].text).toBe(body) - }), - { numRuns: 50 }, - ) + const next = SessionEntryStepper.step(active(), ({ ...base("session.prompted", 1), prompt: { text: "hello" } })) + expect(next.pending).toHaveLength(1) + expect(next.pending[0]?.type).toBe("user") + if (next.pending[0]?.type !== "user") return + expect(next.pending[0].text).toBe("hello") }) test("accumulates text deltas on the latest text part", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = parts.reduce( - (state, part, i) => - SessionEntryStepper.step( - state, - SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }), - ), - SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })), - ) - - expect(texts_of(next)).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - }), - { numRuns: 100 }, + const next = run( + [ + (base("session.text.started", 1)), + ({ ...base("session.text.delta", 2), delta: "hel" }), + ({ ...base("session.text.delta", 3), delta: "lo" }), + ], + active(), ) + + expect(textsOf(next)).toEqual([ + { + type: "text", + text: "hello", + }, + ]) }) test("routes later text deltas to the latest text segment", () => { - FastCheck.assert( - FastCheck.property(texts, texts, (a, b) => { - const next = run( - [ - SessionEvent.Text.Started.create({ timestamp: time(1) }), - ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }), - ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })), - ], - active(), - ) - - expect(texts_of(next)).toEqual([ - { type: "text", text: a.join("") }, - { type: "text", text: b.join("") }, - ]) - }), - { numRuns: 50 }, + const next = run( + [ + (base("session.text.started", 1)), + ({ ...base("session.text.delta", 2), delta: "first" }), + (base("session.text.started", 3)), + ({ ...base("session.text.delta", 4), delta: "second" }), + ], + active(), ) + + expect(textsOf(next)).toEqual([ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ]) }) test("reasoning.ended replaces buffered reasoning text", () => { - FastCheck.assert( - FastCheck.property(texts, text, (parts, end) => { - const next = run( - [ - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }), - ], - active(), - ) - - expect(reasons(next)).toEqual([ - { - type: "reasoning", - text: end, - }, - ]) - }), - { numRuns: 100 }, + const next = run( + [ + (base("session.reasoning.started", 1)), + ({ ...base("session.reasoning.delta", 2), delta: "draft" }), + ({ ...base("session.reasoning.ended", 3), text: "final" }), + ], + active(), ) + + expect(reasons(next)).toEqual([ + { + type: "reasoning", + text: "final", + }, + ]) }) test("tool.success completes the latest running tool", () => { - FastCheck.assert( - FastCheck.property( - word, - word, - dict, - maybe(text), - maybe(dict), - maybe(files), - texts, - (callID, title, input, output, metadata, attachments, parts) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - ...parts.map((x, i) => - SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }), - ), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(parts.length + 2), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(parts.length + 3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("completed") - if (match?.state.status !== "completed") return - - expect(match.time.ran).toEqual(time(parts.length + 2)) - expect(match.state.input).toEqual(input) - expect(match.state.output).toBe(output ?? "") - expect(match.state.title).toBe(title) - expect(match.state.metadata).toEqual(metadata ?? {}) - expect(match.state.attachments).toEqual(attachments ?? []) - }, - ), - { numRuns: 50 }, + const input = { command: "ls", limit: 2 } + const metadata = { cwd: "/tmp" } + const attachments = [SessionEvent.FileAttachment.create({ uri: "file:///tmp/out.txt", mime: "text/plain" })] + const next = run( + [ + ({ ...base("session.tool.input.started", 1), callID: "call", name: "bash" }), + ({ ...base("session.tool.input.delta", 2), callID: "call", delta: "{\"command\":" }), + ({ ...base("session.tool.input.delta", 3), callID: "call", delta: "\"ls\"}" }), + ({ + ...base("session.tool.called", 4), + callID: "call", + tool: "bash", + input, + provider: { executed: true }, + }), + ({ + ...base("session.tool.success", 5), + callID: "call", + title: "Listed files", + output: "ok", + metadata, + attachments, + provider: { executed: true }, + }), + ], + active(), ) + + const match = tool(next, "call") + expect(match?.state.status).toBe("completed") + if (match?.state.status !== "completed") return + + expect(match.time.ran).toEqual(time(4)) + expect(match.state.input).toEqual(input) + expect(match.state.output).toBe("ok") + expect(match.state.title).toBe("Listed files") + expect(match.state.metadata).toEqual(metadata) + expect(match.state.attachments).toEqual(attachments) }) test("tool.error completes the latest running tool with an error", () => { - FastCheck.assert( - FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Error.create({ - callID, - error, - metadata, - provider: { executed: true }, - timestamp: time(3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("error") - if (match?.state.status !== "error") return - - expect(match.time.ran).toEqual(time(2)) - expect(match.state.input).toEqual(input) - expect(match.state.error).toBe(error) - expect(match.state.metadata).toEqual(metadata ?? {}) - }), - { numRuns: 50 }, + const input = { command: "ls" } + const metadata = { cwd: "/tmp" } + const next = run( + [ + ({ ...base("session.tool.input.started", 1), callID: "call", name: "bash" }), + ({ + ...base("session.tool.called", 2), + callID: "call", + tool: "bash", + input, + provider: { executed: true }, + }), + ({ + ...base("session.tool.error", 3), + callID: "call", + error: "permission denied", + metadata, + provider: { executed: true }, + }), + ], + active(), ) + + const match = tool(next, "call") + expect(match?.state.status).toBe("error") + if (match?.state.status !== "error") return + + expect(match.time.ran).toEqual(time(2)) + expect(match.state.input).toEqual(input) + expect(match.state.error).toBe("permission denied") + expect(match.state.metadata).toEqual(metadata) }) test("tool.success is ignored before tool.called promotes the tool to running", () => { - FastCheck.assert( - FastCheck.property(word, word, (callID, title) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Success.create({ - callID, - title, - provider: { executed: true }, - timestamp: time(2), - }), - ], - active(), - ) - const match = tool(next, callID) - expect(match?.state).toEqual({ - status: "pending", - input: "", - }) - }), - { numRuns: 50 }, + const next = run( + [ + ({ ...base("session.tool.input.started", 1), callID: "call", name: "bash" }), + ({ + ...base("session.tool.success", 2), + callID: "call", + title: "Done", + provider: { executed: true }, + }), + ], + active(), ) + const match = tool(next, "call") + expect(match?.state).toEqual({ + status: "pending", + input: "", + }) }) test("step.ended copies completion fields onto the pending assistant", () => { - FastCheck.assert( - FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => { - const event = SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(n), - }) - const next = SessionEntryStepper.step(active(), event) - const entry = last(next) - expect(entry).toBeDefined() - if (!entry) return + const event = stepEnded(9) + const next = SessionEntryStepper.step(active(), event) + const entry = last(next) + expect(entry).toBeDefined() + if (!entry) return - expect(entry.time.completed).toEqual(event.timestamp) - expect(entry.cost).toBe(event.cost) - expect(entry.tokens).toEqual(event.tokens) - }), - { numRuns: 50 }, - ) + expect(entry.time.completed).toEqual(event.timestamp) + expect(entry.cost).toBe(event.cost) + expect(entry.tokens).toEqual(event.tokens) }) }) describe("known reducer gaps", () => { test("prompt appends immutably when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = memoryState() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.entries).toHaveLength(0) - expect(next.entries).toHaveLength(1) - }), - { numRuns: 50 }, - ) + const old = memoryState() + const next = SessionEntryStepper.step(old, ({ ...base("session.prompted", 1), prompt: { text: "hello" } })) + expect(old).not.toBe(next) + expect(old.entries).toHaveLength(0) + expect(next.entries).toHaveLength(1) }) test("prompt appends immutably when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = active() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.pending).toHaveLength(0) - expect(next.pending).toHaveLength(1) - }), - { numRuns: 50 }, - ) + const old = active() + const next = SessionEntryStepper.step(old, ({ ...base("session.prompted", 1), prompt: { text: "hello" } })) + expect(old).not.toBe(next) + expect(old.pending).toHaveLength(0) + expect(next.pending).toHaveLength(1) }) test("step.started creates an assistant consumed by follow-up events", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = run([ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - const entry = last(next) + const next = run([ + stepStarted(1), + (base("session.text.started", 2)), + ({ ...base("session.text.delta", 3), delta: "hello" }), + stepEnded(4), + ]) + const entry = last(next) - expect(entry).toBeDefined() - if (!entry) return + expect(entry).toBeDefined() + if (!entry) return - expect(entry.content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(entry.time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 100 }, - ) + expect(entry.content).toEqual([ + { + type: "text", + text: "hello", + }, + ]) + expect(entry.time.completed).toEqual(time(4)) }) test("replays prompt -> step -> text -> step.ended", () => { - FastCheck.assert( - FastCheck.property(word, texts, (body, parts) => { - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) + const next = run([ + ({ ...base("session.prompted", 0), prompt: { text: "hello" } }), + stepStarted(1), + (base("session.text.started", 2)), + ({ ...base("session.text.delta", 3), delta: "world" }), + stepEnded(4), + ]) - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("user") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[1]?.type !== "assistant") return + expect(next.entries).toHaveLength(2) + expect(next.entries[0]?.type).toBe("user") + expect(next.entries[1]?.type).toBe("assistant") + if (next.entries[1]?.type !== "assistant") return - expect(next.entries[1].content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(next.entries[1].time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 50 }, - ) + expect(next.entries[1].content).toEqual([ + { + type: "text", + text: "world", + }, + ]) + expect(next.entries[1].time.completed).toEqual(time(4)) }) test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => { - FastCheck.assert( - FastCheck.property( - word, - texts, - text, - dict, - word, - maybe(text), - maybe(dict), - maybe(files), - (body, reason, end, input, title, output, metadata, attachments) => { - const callID = "call" - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Reasoning.Started.create({ timestamp: time(2) }), - ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }), - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(reason.length + 5), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(reason.length + 6), - }), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(reason.length + 7), - }), - ]) + const input = { command: "ls" } + const next = run([ + ({ ...base("session.prompted", 0), prompt: { text: "hello" } }), + stepStarted(1), + (base("session.reasoning.started", 2)), + ({ ...base("session.reasoning.delta", 3), delta: "draft" }), + ({ ...base("session.reasoning.ended", 4), text: "final" }), + ({ ...base("session.tool.input.started", 5), callID: "call", name: "bash" }), + ({ + ...base("session.tool.called", 6), + callID: "call", + tool: "bash", + input, + provider: { executed: true }, + }), + ({ + ...base("session.tool.success", 7), + callID: "call", + title: "Listed files", + output: "ok", + provider: { executed: true }, + }), + stepEnded(8), + ]) - expect(next.entries.at(-1)?.type).toBe("assistant") - const entry = next.entries.at(-1) - if (entry?.type !== "assistant") return + expect(next.entries.at(-1)?.type).toBe("assistant") + const entry = next.entries.at(-1) + if (entry?.type !== "assistant") return - expect(entry.content).toHaveLength(2) - expect(entry.content[0]).toEqual({ - type: "reasoning", - text: end, - }) - expect(entry.content[1]?.type).toBe("tool") - if (entry.content[1]?.type !== "tool") return - expect(entry.content[1].state.status).toBe("completed") - expect(entry.time.completed).toEqual(time(reason.length + 7)) - }, - ), - { numRuns: 50 }, - ) + expect(entry.content).toHaveLength(2) + expect(entry.content[0]).toEqual({ + type: "reasoning", + text: "final", + }) + expect(entry.content[1]?.type).toBe("tool") + if (entry.content[1]?.type !== "tool") return + expect(entry.content[1].state.status).toBe("completed") + expect(entry.time.completed).toEqual(time(8)) }) test("starting a new step completes the old assistant and appends a new active assistant", () => { - const next = run( - [ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - ], - active(), - ) + const next = run([stepStarted(1)], active()) expect(next.entries).toHaveLength(2) expect(next.entries[0]?.type).toBe("assistant") expect(next.entries[1]?.type).toBe("assistant") @@ -767,149 +508,133 @@ describe("session-entry-stepper", () => { }) test("handles sequential tools independently", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, (a, b, title, error) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title, - output: "done", - provider: { executed: true }, - timestamp: time(3), - }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "bash", - input: b, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Error.create({ - callID: "b", - error, - provider: { executed: true }, - timestamp: time(6), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - if (first?.state.status !== "completed") return - expect(first.state.input).toEqual(a) - expect(first.state.output).toBe("done") - expect(first.state.title).toBe(title) - - expect(second?.state.status).toBe("error") - if (second?.state.status !== "error") return - expect(second.state.input).toEqual(b) - expect(second.state.error).toBe(error) - }), - { numRuns: 50 }, + const firstInput = { command: "ls" } + const secondInput = { pattern: "TODO" } + const next = run( + [ + ({ ...base("session.tool.input.started", 1), callID: "a", name: "bash" }), + ({ + ...base("session.tool.called", 2), + callID: "a", + tool: "bash", + input: firstInput, + provider: { executed: true }, + }), + ({ + ...base("session.tool.success", 3), + callID: "a", + title: "Listed", + output: "done", + provider: { executed: true }, + }), + ({ ...base("session.tool.input.started", 4), callID: "b", name: "bash" }), + ({ + ...base("session.tool.called", 5), + callID: "b", + tool: "bash", + input: secondInput, + provider: { executed: true }, + }), + ({ + ...base("session.tool.error", 6), + callID: "b", + error: "not found", + provider: { executed: true }, + }), + ], + active(), ) + + const first = tool(next, "a") + const second = tool(next, "b") + + expect(first?.state.status).toBe("completed") + if (first?.state.status !== "completed") return + expect(first.state.input).toEqual(firstInput) + expect(first.state.output).toBe("done") + expect(first.state.title).toBe("Listed") + + expect(second?.state.status).toBe("error") + if (second?.state.status !== "error") return + expect(second.state.input).toEqual(secondInput) + expect(second.state.error).toBe("not found") }) test("routes tool events by callID when tool streams interleave", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }), - SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }), - SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "grep", - input: b, - provider: { executed: true }, - timestamp: time(6), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title: titleA, - output: "done-a", - provider: { executed: true }, - timestamp: time(7), - }), - SessionEvent.Tool.Success.create({ - callID: "b", - title: titleB, - output: "done-b", - provider: { executed: true }, - timestamp: time(8), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - expect(second?.state.status).toBe("completed") - if (first?.state.status !== "completed" || second?.state.status !== "completed") return - - expect(first.state.input).toEqual(a) - expect(second.state.input).toEqual(b) - expect(first.state.title).toBe(titleA) - expect(second.state.title).toBe(titleB) - }), - { numRuns: 50 }, + const firstInput = { command: "ls" } + const secondInput = { pattern: "TODO" } + const next = run( + [ + ({ ...base("session.tool.input.started", 1), callID: "a", name: "bash" }), + ({ ...base("session.tool.input.started", 2), callID: "b", name: "grep" }), + ({ ...base("session.tool.input.delta", 3), callID: "a", delta: "first" }), + ({ ...base("session.tool.input.delta", 4), callID: "b", delta: "second" }), + ({ + ...base("session.tool.called", 5), + callID: "a", + tool: "bash", + input: firstInput, + provider: { executed: true }, + }), + ({ + ...base("session.tool.called", 6), + callID: "b", + tool: "grep", + input: secondInput, + provider: { executed: true }, + }), + ({ + ...base("session.tool.success", 7), + callID: "a", + title: "Listed", + output: "done-a", + provider: { executed: true }, + }), + ({ + ...base("session.tool.success", 8), + callID: "b", + title: "Grep", + output: "done-b", + provider: { executed: true }, + }), + ], + active(), ) + + const first = tool(next, "a") + const second = tool(next, "b") + + expect(first?.state.status).toBe("completed") + expect(second?.state.status).toBe("completed") + if (first?.state.status !== "completed" || second?.state.status !== "completed") return + + expect(first.state.input).toEqual(firstInput) + expect(second.state.input).toEqual(secondInput) + expect(first.state.title).toBe("Listed") + expect(second.state.title).toBe("Grep") }) test("records synthetic events", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("synthetic") - if (next.entries[0]?.type !== "synthetic") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, + const next = SessionEntryStepper.step( + memoryState(), + ({ ...base("session.synthetic", 1), text: "generated" }), ) + expect(next.entries).toHaveLength(1) + expect(next.entries[0]?.type).toBe("synthetic") + if (next.entries[0]?.type !== "synthetic") return + expect(next.entries[0].text).toBe("generated") }) test("records compaction events", () => { - FastCheck.assert( - FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("compaction") - if (next.entries[0]?.type !== "compaction") return - expect(next.entries[0].auto).toBe(auto) - expect(next.entries[0].overflow).toBe(overflow) - }), - { numRuns: 50 }, + const next = SessionEntryStepper.step( + memoryState(), + ({ ...base("session.compacted", 1), auto: true, overflow: false }), ) + expect(next.entries).toHaveLength(1) + expect(next.entries[0]?.type).toBe("compaction") + if (next.entries[0]?.type !== "compaction") return + expect(next.entries[0].auto).toBe(true) + expect(next.entries[0].overflow).toBe(false) }) }) }) diff --git a/specs/v2/session-concepts-gap.md b/specs/v2/session-concepts-gap.md new file mode 100644 index 0000000000..20d84c8f47 --- /dev/null +++ b/specs/v2/session-concepts-gap.md @@ -0,0 +1,131 @@ +# Session V2 Concept Gaps + +Compared with `packages/opencode/src/session/message-v2.ts` and `packages/opencode/src/session/processor.ts`, `packages/opencode/src/v2` currently captures the rough event stream for prompts, assistant steps, text, reasoning, tools, retries, and compaction, but it does not yet capture several persisted-message and processor concepts. + +## Message Metadata + +- User messages are missing selected `agent`, `model`, `system`, enabled `tools`, output `format`, and summary metadata. +- Assistant messages are missing `parentID`, `agent`, `providerID`, `modelID`, `variant`, `path.cwd`, `path.root`, deprecated `mode`, `summary`, `structured`, `finish`, and typed `error`. + +## Output Format + +- Text output format. +- JSON-schema output format. +- Structured-output retry count. +- Structured assistant result payload. +- Structured-output error classification. + +## Errors + +- Aborted error. +- Provider auth error. +- API error with status, retryability, headers, body, and metadata. +- Context-overflow error. +- Output-length error. +- Unknown error. +- V2 mostly reduces assistant errors to strings, except retry errors. + +## Part Identity + +- V1 has stable `MessageID`, `PartID`, `sessionID`, and `messageID` on every part. +- V2 assistant content does not preserve stable per-content IDs. +- Stable content IDs matter for deltas, updates, removals, sync events, and UI reconciliation. + +## Part Timing And Metadata + +- V1 text, reasoning, and tool states carry timing and provider metadata. +- V2 assistant text and reasoning content only store text. +- V2 events include metadata, but `SessionEntry` currently drops most provider metadata. + +## Snapshots And Patches + +- Snapshot parts. +- Patch parts. +- Step-start snapshot references. +- Step-finish snapshot references. +- Processor behavior that tracks a snapshot before the stream and emits patches after step finish or cleanup. + +## Step Boundaries + +- V1 stores `step-start` and `step-finish` as first-class parts. +- V2 has `step.started` and `step.ended` events, but the assistant entry only stores aggregate cost and tokens. +- V2 does not preserve step boundary parts, finish reason, or snapshot details in the entry model. + +## Compaction + +- V1 compaction parts have `auto`, `overflow`, and `tail_start_id`. +- V2 compacted events have `auto` and optional `overflow`, but no retained-tail marker. +- V1 also has history filtering semantics around completed summary messages and retained tails. + +## Files And Sources + +- V1 file parts have `mime`, `filename`, `url`, and typed source information. +- V1 source variants include file, symbol, and resource sources. +- Symbol sources include LSP range, name, and kind. +- Resource sources include client name and URI. +- V2 file attachments have `uri`, `mime`, `name`, `description`, and a generic text source, but lose source type, LSP metadata, and resource metadata. + +## Agents And Subtasks + +- Agent parts. +- Subtask parts. +- Subtask prompt, description, agent, model, and command. +- V2 has agent attachments on prompts, but no assistant/session content equivalent for subtask execution. + +## Text Flags + +- Synthetic text flag. +- Ignored text flag. +- V2 has a separate synthetic entry, but no ignored text concept. + +## Tool Calls + +- V1 pending tool state stores parsed input and raw input text separately. +- V2 pending tool state stores a string input but does not preserve a separate raw field. +- V1 completed tool state has `time.start`, `time.end`, and optional `time.compacted`. +- V2 tool time has `created`, `ran`, `completed`, and `pruned`, but the stepper currently does not set `completed` or `pruned`. +- V1 error tool state has `time.start` and `time.end`. +- V1 supports interrupted tool errors with `metadata.interrupted` and preserved partial output. +- V1 tracks provider execution and provider call metadata. +- V2 events include provider info, but `SessionEntryStepper` drops it from entries. +- V1 has tool-output compaction and truncation behavior via `time.compacted`. + +## Media Handling + +- V1 models tool attachments as file parts and has provider-specific handling for media in tool results. +- V1 can strip media, inject synthetic user messages for unsupported providers, and uses a synthetic attachment prompt. +- V2 has attachments but not these model-message conversion semantics. + +## Retries + +- V1 stores retries as independently addressable retry parts. +- V2 stores retries as an assistant aggregate. +- V2 captures some retry information, but not the independent part identity/update model. + +## Processor Control Flow + +- Session status transitions: busy, retry, and idle. +- Retry policy integration. +- Context-overflow-driven compaction. +- Abort and interrupt handling. +- Permission-denied blocking. +- Doom-loop detection. +- Plugin hook for `experimental.text.complete`. +- Background summary generation after steps. +- Cleanup semantics for open text, reasoning, and tool calls. + +## Sync And Bus Events + +- Message updated. +- Message removed. +- Message part updated. +- Message part delta. +- Message part removed. +- V2 has domain events, but not the sync/bus event model for persisted message and part updates/removals. + +## History Retrieval + +- Cursor encoding and decoding. +- Paged message retrieval. +- Reverse streaming through history. +- Compaction-aware history filtering. From 3253da034fc1b19d86769d095daecae1c36e701a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 26 Apr 2026 13:04:50 -0400 Subject: [PATCH 03/14] fix(session): include session id on v2 events --- packages/opencode/src/v2/event.ts | 41 +++++++ packages/opencode/src/v2/session-entry.ts | 5 +- packages/opencode/src/v2/session-event.ts | 124 ++++++++++++---------- 3 files changed, 109 insertions(+), 61 deletions(-) create mode 100644 packages/opencode/src/v2/event.ts diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts new file mode 100644 index 0000000000..f7e9860c5e --- /dev/null +++ b/packages/opencode/src/v2/event.ts @@ -0,0 +1,41 @@ +import { Identifier } from "@/id/id" +import { SyncEvent } from "@/sync" +import { withStatics } from "@/util/schema" +import * as Schema from "effect/Schema" + +export const ID = Schema.String.pipe( + Schema.brand("Event.ID"), + withStatics((s) => ({ + create: () => s.make(Identifier.create("evt", "ascending")), + })), +) +export type ID = Schema.Schema.Type + +export function define(input: { + type: Type + schema: Fields + aggregate: string + version?: number +}) { + const Event = Schema.Struct({ + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + timestamp: Schema.DateTimeUtc, + type: Schema.Literal(input.type), + version: Schema.Number.pipe(Schema.optional), + ...input.schema, + }).annotate({ + identifier: input.type, + }) + + const Sync = SyncEvent.define({ + type: input.type, + version: input.version ?? 1, + aggregate: input.aggregate, + schema: Event, + }) + + return Object.assign(Event, { Sync }) +} + +export * as Event from "./event" diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index 398ec14cc7..f36a0815ff 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -1,12 +1,13 @@ import { Schema } from "effect" import { Prompt } from "./session-prompt" import { SessionEvent } from "./session-event" +import { Event } from "./event" -export const ID = SessionEvent.ID +export const ID = Event.ID export type ID = Schema.Schema.Type const Base = { - id: SessionEvent.ID, + id: ID, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), time: Schema.Struct({ created: Schema.DateTimeUtc, diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 5623f1c485..c573a36400 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,48 +1,12 @@ -import { Identifier } from "@/id/id" -import { FileAttachment, Prompt } from "./session-prompt" import { SessionID } from "@/session/schema" -import { SyncEvent } from "@/sync" -import { withStatics } from "@/util/schema" +import { Event as BaseEvent } from "./event" +import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" export { FileAttachment } -export const ID = Schema.String.pipe( - Schema.brand("Session.Event.ID"), - withStatics((s) => ({ - create: () => s.make(Identifier.create("evt", "ascending")), - })), -) +export const ID = BaseEvent.ID export type ID = Schema.Schema.Type -function defineEvent(input: { - type: Type - schema: Fields - version?: number -}) { - const Event = Schema.Struct({ - id: ID, - sessionID: SessionID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - timestamp: Schema.DateTimeUtc, - type: Schema.Literal(input.type), - version: Schema.Number.pipe(Schema.optional), - ...input.schema, - }).annotate({ - identifier: input.type, - }) - - const Sync = SyncEvent.define({ - type: input.type, - version: input.version ?? 1, - aggregate: "sessionID", - schema: Event, - }) - - return Object.assign(Event, { - Sync, - }) -} - export const Source = Schema.Struct({ start: Schema.Number, end: Schema.Number, @@ -52,26 +16,36 @@ export const Source = Schema.Struct({ }) export type Source = Schema.Schema.Type -export const Prompted = defineEvent({ +const Base = { + sessionID: SessionID, +} + +export const Prompted = BaseEvent.define({ type: "session.prompted", + aggregate: "sessionID", schema: { + ...Base, prompt: Prompt, }, }) export type Prompted = Schema.Schema.Type -export const Synthetic = defineEvent({ +export const Synthetic = BaseEvent.define({ type: "session.synthetic", + aggregate: "sessionID", schema: { + ...Base, text: Schema.String, }, }) export type Synthetic = Schema.Schema.Type export namespace Step { - export const Started = defineEvent({ + export const Started = BaseEvent.define({ type: "session.step.started", + aggregate: "sessionID", schema: { + ...Base, model: Schema.Struct({ id: Schema.String, providerID: Schema.String, @@ -81,9 +55,11 @@ export namespace Step { }) export type Started = Schema.Schema.Type - export const Ended = defineEvent({ + export const Ended = BaseEvent.define({ type: "session.step.ended", + aggregate: "sessionID", schema: { + ...Base, reason: Schema.String, cost: Schema.Number, tokens: Schema.Struct({ @@ -101,23 +77,30 @@ export namespace Step { } export namespace Text { - export const Started = defineEvent({ + export const Started = BaseEvent.define({ type: "session.text.started", - schema: {}, + aggregate: "sessionID", + schema: { + ...Base, + }, }) export type Started = Schema.Schema.Type - export const Delta = defineEvent({ + export const Delta = BaseEvent.define({ type: "session.text.delta", + aggregate: "sessionID", schema: { + ...Base, delta: Schema.String, }, }) export type Delta = Schema.Schema.Type - export const Ended = defineEvent({ + export const Ended = BaseEvent.define({ type: "session.text.ended", + aggregate: "sessionID", schema: { + ...Base, text: Schema.String, }, }) @@ -125,23 +108,30 @@ export namespace Text { } export namespace Reasoning { - export const Started = defineEvent({ + export const Started = BaseEvent.define({ type: "session.reasoning.started", - schema: {}, + aggregate: "sessionID", + schema: { + ...Base, + }, }) export type Started = Schema.Schema.Type - export const Delta = defineEvent({ + export const Delta = BaseEvent.define({ type: "session.reasoning.delta", + aggregate: "sessionID", schema: { + ...Base, delta: Schema.String, }, }) export type Delta = Schema.Schema.Type - export const Ended = defineEvent({ + export const Ended = BaseEvent.define({ type: "session.reasoning.ended", + aggregate: "sessionID", schema: { + ...Base, text: Schema.String, }, }) @@ -150,27 +140,33 @@ export namespace Reasoning { export namespace Tool { export namespace Input { - export const Started = defineEvent({ + export const Started = BaseEvent.define({ type: "session.tool.input.started", + aggregate: "sessionID", schema: { + ...Base, callID: Schema.String, name: Schema.String, }, }) export type Started = Schema.Schema.Type - export const Delta = defineEvent({ + export const Delta = BaseEvent.define({ type: "session.tool.input.delta", + aggregate: "sessionID", schema: { + ...Base, callID: Schema.String, delta: Schema.String, }, }) export type Delta = Schema.Schema.Type - export const Ended = defineEvent({ + export const Ended = BaseEvent.define({ type: "session.tool.input.ended", + aggregate: "sessionID", schema: { + ...Base, callID: Schema.String, text: Schema.String, }, @@ -178,9 +174,11 @@ export namespace Tool { export type Ended = Schema.Schema.Type } - export const Called = defineEvent({ + export const Called = BaseEvent.define({ type: "session.tool.called", + aggregate: "sessionID", schema: { + ...Base, callID: Schema.String, tool: Schema.String, input: Schema.Record(Schema.String, Schema.Unknown), @@ -192,9 +190,11 @@ export namespace Tool { }) export type Called = Schema.Schema.Type - export const Success = defineEvent({ + export const Success = BaseEvent.define({ type: "session.tool.success", + aggregate: "sessionID", schema: { + ...Base, callID: Schema.String, title: Schema.String, output: Schema.String.pipe(Schema.optional), @@ -207,9 +207,11 @@ export namespace Tool { }) export type Success = Schema.Schema.Type - export const Error = defineEvent({ + export const Error = BaseEvent.define({ type: "session.tool.error", + aggregate: "sessionID", schema: { + ...Base, callID: Schema.String, error: Schema.String, provider: Schema.Struct({ @@ -233,18 +235,22 @@ export const RetryError = Schema.Struct({ }) export type RetryError = Schema.Schema.Type -export const Retried = defineEvent({ +export const Retried = BaseEvent.define({ type: "session.retried", + aggregate: "sessionID", schema: { + ...Base, attempt: Schema.Number, error: RetryError, }, }) export type Retried = Schema.Schema.Type -export const Compacted = defineEvent({ +export const Compacted = BaseEvent.define({ type: "session.compacted", + aggregate: "sessionID", schema: { + ...Base, auto: Schema.Boolean, overflow: Schema.Boolean.pipe(Schema.optional), }, From ccfe2ac4da1be2171ab686500324f5f09fbbbd68 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 26 Apr 2026 13:17:46 -0400 Subject: [PATCH 04/14] style(session): inline session id event fields --- packages/opencode/src/v2/session-event.ts | 40 ++++++++++------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index c573a36400..3573fa988c 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -16,15 +16,11 @@ export const Source = Schema.Struct({ }) export type Source = Schema.Schema.Type -const Base = { - sessionID: SessionID, -} - export const Prompted = BaseEvent.define({ type: "session.prompted", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, prompt: Prompt, }, }) @@ -34,7 +30,7 @@ export const Synthetic = BaseEvent.define({ type: "session.synthetic", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, text: Schema.String, }, }) @@ -45,7 +41,7 @@ export namespace Step { type: "session.step.started", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, model: Schema.Struct({ id: Schema.String, providerID: Schema.String, @@ -59,7 +55,7 @@ export namespace Step { type: "session.step.ended", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, reason: Schema.String, cost: Schema.Number, tokens: Schema.Struct({ @@ -81,7 +77,7 @@ export namespace Text { type: "session.text.started", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, }, }) export type Started = Schema.Schema.Type @@ -90,7 +86,7 @@ export namespace Text { type: "session.text.delta", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, delta: Schema.String, }, }) @@ -100,7 +96,7 @@ export namespace Text { type: "session.text.ended", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, text: Schema.String, }, }) @@ -112,7 +108,7 @@ export namespace Reasoning { type: "session.reasoning.started", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, }, }) export type Started = Schema.Schema.Type @@ -121,7 +117,7 @@ export namespace Reasoning { type: "session.reasoning.delta", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, delta: Schema.String, }, }) @@ -131,7 +127,7 @@ export namespace Reasoning { type: "session.reasoning.ended", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, text: Schema.String, }, }) @@ -144,7 +140,7 @@ export namespace Tool { type: "session.tool.input.started", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, callID: Schema.String, name: Schema.String, }, @@ -155,7 +151,7 @@ export namespace Tool { type: "session.tool.input.delta", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, callID: Schema.String, delta: Schema.String, }, @@ -166,7 +162,7 @@ export namespace Tool { type: "session.tool.input.ended", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, callID: Schema.String, text: Schema.String, }, @@ -178,7 +174,7 @@ export namespace Tool { type: "session.tool.called", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, callID: Schema.String, tool: Schema.String, input: Schema.Record(Schema.String, Schema.Unknown), @@ -194,7 +190,7 @@ export namespace Tool { type: "session.tool.success", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, callID: Schema.String, title: Schema.String, output: Schema.String.pipe(Schema.optional), @@ -211,7 +207,7 @@ export namespace Tool { type: "session.tool.error", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, callID: Schema.String, error: Schema.String, provider: Schema.Struct({ @@ -239,7 +235,7 @@ export const Retried = BaseEvent.define({ type: "session.retried", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, attempt: Schema.Number, error: RetryError, }, @@ -250,7 +246,7 @@ export const Compacted = BaseEvent.define({ type: "session.compacted", aggregate: "sessionID", schema: { - ...Base, + sessionID: SessionID, auto: Schema.Boolean, overflow: Schema.Boolean.pipe(Schema.optional), }, From b80b1f4e2f2b418f0d32951b9ab54ef9f47b2c50 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 26 Apr 2026 14:59:23 -0400 Subject: [PATCH 05/14] feat(session): project next session events --- packages/core/src/util/log.ts | 2 + packages/opencode/src/session/processor.ts | 116 +++++++++++++++++- .../opencode/src/session/projectors-next.ts | 100 +++++++++++++++ packages/opencode/src/session/projectors.ts | 5 +- 4 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/session/projectors-next.ts diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index a61c15f7a7..e1962aed4c 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -1,3 +1,5 @@ +export * as Log from "./log" + import path from "path" import fs from "fs/promises" import { createWriteStream } from "fs" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b475ec1c59..245f9063db 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -20,6 +20,9 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" +import { SyncEvent } from "@/sync" +import { SessionEvent } from "@/v2/session-event" +import * as DateTime from "effect/DateTime" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -221,6 +224,10 @@ export const layer: Layer.Layer< case "reasoning-start": if (value.id in ctx.reasoningMap) return + SyncEvent.run(SessionEvent.Reasoning.Started.Sync, { + sessionID: ctx.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -248,6 +255,11 @@ export const layer: Layer.Layer< case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return + SyncEvent.run(SessionEvent.Reasoning.Ended.Sync, { + sessionID: ctx.sessionID, + text: ctx.reasoningMap[value.id].text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } @@ -260,6 +272,12 @@ export const layer: Layer.Layer< if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + SyncEvent.run(SessionEvent.Tool.Input.Started.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + name: value.toolName, + timestamp: DateTime.makeUnsafe(Date.now()), + }) const part = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -281,13 +299,32 @@ export const layer: Layer.Layer< case "tool-input-delta": return - case "tool-input-end": + case "tool-input-end": { + SyncEvent.run(SessionEvent.Tool.Input.Ended.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + text: "", + timestamp: DateTime.makeUnsafe(Date.now()), + }) return + } case "tool-call": { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + const toolCall = yield* readToolCall(value.toolCallId) + SyncEvent.run(SessionEvent.Tool.Called.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + tool: value.toolName, + input: value.input, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, @@ -331,11 +368,47 @@ export const layer: Layer.Layer< } case "tool-result": { + const toolCall = yield* readToolCall(value.toolCallId) + SyncEvent.run(SessionEvent.Tool.Success.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + title: value.output.title, + output: value.output.output, + attachments: value.output.attachments?.map((item: MessageV2.FilePart) => ({ + uri: item.url, + mime: item.mime, + ...(item.filename ? { name: item.filename } : {}), + ...(item.source + ? { + source: { + start: item.source.text.start, + end: item.source.text.end, + text: item.source.text.value, + }, + } + : {}), + })), + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + metadata: value.output.metadata, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* completeToolCall(value.toolCallId, value.output) return } case "tool-error": { + const toolCall = yield* readToolCall(value.toolCallId) + SyncEvent.run(SessionEvent.Tool.Error.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + error: errorMessage(value.error), + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* failToolCall(value.toolCallId, value.error) return } @@ -345,6 +418,15 @@ export const layer: Layer.Layer< case "start-step": if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() + SyncEvent.run(SessionEvent.Step.Started.Sync, { + sessionID: ctx.sessionID, + model: { + id: ctx.model.id, + providerID: ctx.model.providerID, + variant: input.assistantMessage.variant, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* session.updatePart({ id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -360,6 +442,13 @@ export const layer: Layer.Layer< usage: value.usage, metadata: value.providerMetadata, }) + SyncEvent.run(SessionEvent.Step.Ended.Sync, { + sessionID: ctx.sessionID, + reason: value.finishReason, + cost: usage.cost, + tokens: usage.tokens, + timestamp: DateTime.makeUnsafe(Date.now()), + }) ctx.assistantMessage.finish = value.finishReason ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens @@ -404,6 +493,10 @@ export const layer: Layer.Layer< } case "text-start": + SyncEvent.run(SessionEvent.Text.Started.Sync, { + sessionID: ctx.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) ctx.currentText = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -442,6 +535,11 @@ export const layer: Layer.Layer< }, { text: ctx.currentText.text }, )).text + SyncEvent.run(SessionEvent.Text.Ended.Sync, { + sessionID: ctx.sessionID, + text: ctx.currentText.text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } @@ -568,13 +666,23 @@ export const layer: Layer.Layer< Effect.retry( SessionRetry.policy({ parse, - set: (info) => - status.set(ctx.sessionID, { + set: (info) => { + SyncEvent.run(SessionEvent.Retried.Sync, { + sessionID: ctx.sessionID, + attempt: info.attempt, + error: { + message: info.message, + isRetryable: true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + return status.set(ctx.sessionID, { type: "retry", attempt: info.attempt, message: info.message, next: info.next, - }), + }) + }, }), ), Effect.catch(halt), diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts new file mode 100644 index 0000000000..3298edfd6d --- /dev/null +++ b/packages/opencode/src/session/projectors-next.ts @@ -0,0 +1,100 @@ +import { and, desc, eq } from "@/storage" +import type { Database } from "@/storage" +import { SessionEntry } from "@/v2/session-entry" +import { SessionEntryStepper } from "@/v2/session-entry-stepper" +import { SessionEvent } from "@/v2/session-event" +import * as DateTime from "effect/DateTime" +import { SyncEvent } from "@/sync" +import { SessionEntryTable } from "./session.sql" +import type { SessionID } from "./schema" + +function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionEntryStepper.Adapter { + return { + getCurrentAssistant() { + return db + .select() + .from(SessionEntryTable) + .where(and(eq(SessionEntryTable.session_id, sessionID), eq(SessionEntryTable.type, "assistant"))) + .orderBy(desc(SessionEntryTable.id)) + .all() + .map((row) => ({ id: row.id, type: row.type, ...row.data }) as SessionEntry.Entry) + .find((entry): entry is SessionEntry.Assistant => entry.type === "assistant" && !entry.time.completed) + }, + updateAssistant(assistant) { + const { id, type, ...data } = assistant + db.update(SessionEntryTable) + .set({ data }) + .where(and(eq(SessionEntryTable.id, id), eq(SessionEntryTable.session_id, sessionID), eq(SessionEntryTable.type, type))) + .run() + }, + appendEntry(entry) { + const { id, type, ...data } = entry + db.insert(SessionEntryTable) + .values({ + id, + session_id: sessionID, + type, + time_created: DateTime.toEpochMillis(entry.time.created), + data, + }) + .run() + }, + appendPending() {}, + finish() {}, + } +} + +function step(db: Database.TxOrDb, event: SessionEvent.Event) { + SessionEntryStepper.stepWith(sqlite(db, event.data.sessionID), event) +} + +export default [ + SyncEvent.project(SessionEvent.Prompted.Sync, (db, data) => { + step(db, { type: "session.next.prompted", data }) + }), + SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data) => { + step(db, { type: "session.next.synthetic", data }) + }), + SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data) => { + step(db, { type: "session.next.step.started", data }) + }), + SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data) => { + step(db, { type: "session.next.step.ended", data }) + }), + SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data) => { + step(db, { type: "session.next.text.started", data }) + }), + SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data) => { + step(db, { type: "session.next.text.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data) => { + step(db, { type: "session.next.tool.input.started", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data) => { + step(db, { type: "session.next.tool.input.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data) => { + step(db, { type: "session.next.tool.called", data }) + }), + SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data) => { + step(db, { type: "session.next.tool.success", data }) + }), + SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data) => { + step(db, { type: "session.next.tool.error", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data) => { + step(db, { type: "session.next.reasoning.started", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data) => { + step(db, { type: "session.next.reasoning.ended", data }) + }), + SyncEvent.project(SessionEvent.Retried.Sync, (db, data) => { + step(db, { type: "session.next.retried", data }) + }), + SyncEvent.project(SessionEvent.Compacted.Sync, (db, data) => { + step(db, { type: "session.next.compacted", data }) + }), +] diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 35c8473809..d5448d91d2 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -5,7 +5,8 @@ import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" +import nextProjectors from "./projectors-next" const log = Log.create({ service: "session.projector" }) @@ -135,4 +136,6 @@ export default [ log.warn("ignored late part update", { partID: id, messageID, sessionID }) } }), + + ...nextProjectors, ] From 51b0b6fda9ecd70a596a00d794992ecf07159266 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 26 Apr 2026 15:03:04 -0400 Subject: [PATCH 06/14] core: restructure v2 event system with session.next namespace and data encapsulation - Rename all event types from session.* to session.next.* for clearer namespacing - Wrap event payload in data field for better schema organization - Add timestamp to all event schemas for consistent event tracking - Fix effect-zod handling for Declaration ASTs with type parameters - Remove obsolete session-entry-stepper tests This provides a cleaner event structure that separates metadata from payload data, making the event system more maintainable and easier to extend. --- packages/opencode/src/util/effect-zod.ts | 2 +- packages/opencode/src/v2/event.ts | 15 +- .../opencode/src/v2/session-entry-stepper.ts | 80 +-- packages/opencode/src/v2/session-entry.ts | 40 +- packages/opencode/src/v2/session-event.ts | 103 +-- packages/opencode/test/preload.ts | 2 +- .../session/session-entry-stepper.test.ts | 641 ------------------ 7 files changed, 135 insertions(+), 748 deletions(-) delete mode 100644 packages/opencode/test/session/session-entry-stepper.test.ts diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 332a5c76eb..1c88712d7d 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -90,7 +90,7 @@ function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` // on the inner Zod rather than a transform wrapper — so optional ASTs whose // encoding resolves a default from Option.none() route through body()/opt(). - const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" + const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0) const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts index f7e9860c5e..a5c1c41824 100644 --- a/packages/opencode/src/v2/event.ts +++ b/packages/opencode/src/v2/event.ts @@ -17,13 +17,10 @@ export function define(adapter: Adapter, event: SessionEvent.E const latestReasoning = (assistant: DraftAssistant | undefined) => assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning") - SessionEvent.Event.match(event, { - "session.prompted": (event) => { + SessionEvent.All.match(event, { + "session.next.prompted": (event) => { const entry = SessionEntry.User.fromEvent(event) if (currentAssistant) { adapter.appendPending(entry) @@ -72,31 +72,31 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E } adapter.appendEntry(entry) }, - "session.synthetic": (event) => { + "session.next.synthetic": (event) => { adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event)) }, - "session.step.started": (event) => { + "session.next.step.started": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp + draft.time.completed = event.data.timestamp }), ) } adapter.appendEntry(SessionEntry.Assistant.fromEvent(event)) }, - "session.step.ended": (event) => { + "session.next.step.ended": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp - draft.cost = event.cost - draft.tokens = event.tokens + draft.time.completed = event.data.timestamp + draft.cost = event.data.cost + draft.tokens = event.data.tokens }), ) } }, - "session.text.started": () => { + "session.next.text.started": () => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -108,27 +108,27 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "session.text.delta": (event) => { + "session.next.text.delta": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestText(draft) - if (match) match.text += event.delta + if (match) match.text += event.data.delta }), ) } }, - "session.text.ended": () => {}, - "session.tool.input.started": (event) => { + "session.next.text.ended": () => {}, + "session.next.tool.input.started": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { draft.content.push({ type: "tool", - callID: event.callID, - name: event.name, + callID: event.data.callID, + name: event.data.name, time: { - created: event.timestamp, + created: event.data.timestamp, }, state: { status: "pending", @@ -139,62 +139,62 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "session.tool.input.delta": (event) => { + "session.next.tool.input.delta": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) + const match = latestTool(draft, event.data.callID) // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) - if (match && match.state.status === "pending") match.state.input += event.delta + if (match && match.state.status === "pending") match.state.input += event.data.delta }), ) } }, - "session.tool.input.ended": () => {}, - "session.tool.called": (event) => { + "session.next.tool.input.ended": () => {}, + "session.next.tool.called": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) + const match = latestTool(draft, event.data.callID) if (match) { - match.time.ran = event.timestamp + match.time.ran = event.data.timestamp match.state = { status: "running", - input: event.input, + input: event.data.input, } } }), ) } }, - "session.tool.success": (event) => { + "session.next.tool.success": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) + const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { match.state = { status: "completed", input: match.state.input, - output: event.output ?? "", - title: event.title, + output: event.data.output ?? "", + title: event.data.title, metadata: event.metadata ?? {}, - attachments: [...(event.attachments ?? [])], + attachments: [...(event.data.attachments ?? [])], } } }), ) } }, - "session.tool.error": (event) => { + "session.next.tool.error": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) + const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { match.state = { status: "error", - error: event.error, + error: event.data.error, input: match.state.input, metadata: event.metadata ?? {}, } @@ -203,7 +203,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "session.reasoning.started": () => { + "session.next.reasoning.started": () => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -215,27 +215,27 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "session.reasoning.delta": (event) => { + "session.next.reasoning.delta": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestReasoning(draft) - if (match) match.text += event.delta + if (match) match.text += event.data.delta }), ) } }, - "session.reasoning.ended": (event) => { + "session.next.reasoning.ended": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestReasoning(draft) - if (match) match.text = event.text + if (match) match.text = event.data.text }), ) } }, - "session.retried": (event) => { + "session.next.retried": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { @@ -244,7 +244,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "session.compacted": (event) => { + "session.next.compacted": (event) => { adapter.appendEntry(SessionEntry.Compaction.fromEvent(event)) }, }) diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index f36a0815ff..ece1ad8255 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -26,27 +26,30 @@ export class User extends Schema.Class("Session.Entry.User")({ }) { static fromEvent(event: SessionEvent.Prompted) { return new User({ - id: event.id, + id: ID.create(), type: "user", metadata: event.metadata, - text: event.prompt.text, - files: event.prompt.files, - agents: event.prompt.agents, - time: { created: event.timestamp }, + text: event.data.prompt.text, + files: event.data.prompt.files, + agents: event.data.prompt.agents, + time: { created: event.data.timestamp }, }) } } export class Synthetic extends Schema.Class("Session.Entry.Synthetic")({ - ...SessionEvent.Synthetic.fields, ...Base, + sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID, + text: SessionEvent.Synthetic.fields.data.fields.text, type: Schema.Literal("synthetic"), }) { static fromEvent(event: SessionEvent.Synthetic) { return new Synthetic({ - ...event, + sessionID: event.data.sessionID, + text: event.data.text, + id: ID.create(), type: "synthetic", - time: { created: event.timestamp }, + time: { created: event.data.timestamp }, }) } } @@ -116,10 +119,10 @@ export class AssistantRetry extends Schema.Class("Session.Entry. }) { static fromEvent(event: SessionEvent.Retried) { return new AssistantRetry({ - attempt: event.attempt, - error: event.error, + attempt: event.data.attempt, + error: event.data.error, time: { - created: event.timestamp, + created: event.data.timestamp, }, }) } @@ -153,10 +156,10 @@ export class Assistant extends Schema.Class("Session.Entry.Assistant" }) { static fromEvent(event: SessionEvent.Step.Started) { return new Assistant({ - id: event.id, + id: ID.create(), type: "assistant", time: { - created: event.timestamp, + created: event.data.timestamp, }, content: [], retries: [], @@ -165,15 +168,20 @@ export class Assistant extends Schema.Class("Session.Entry.Assistant" } export class Compaction extends Schema.Class("Session.Entry.Compaction")({ - ...SessionEvent.Compacted.fields, type: Schema.Literal("compaction"), + sessionID: SessionEvent.Compacted.fields.data.fields.sessionID, + auto: SessionEvent.Compacted.fields.data.fields.auto, + overflow: SessionEvent.Compacted.fields.data.fields.overflow, ...Base, }) { static fromEvent(event: SessionEvent.Compacted) { return new Compaction({ - ...event, + sessionID: event.data.sessionID, + auto: event.data.auto, + overflow: event.data.overflow, + id: ID.create(), type: "compaction", - time: { created: event.timestamp }, + time: { created: event.data.timestamp }, }) } } diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 3573fa988c..548de924f3 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,10 +1,10 @@ import { SessionID } from "@/session/schema" -import { Event as BaseEvent } from "./event" +import { Event } from "./event" import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" export { FileAttachment } -export const ID = BaseEvent.ID +export const ID = Event.ID export type ID = Schema.Schema.Type export const Source = Schema.Struct({ @@ -12,24 +12,26 @@ export const Source = Schema.Struct({ end: Schema.Number, text: Schema.String, }).annotate({ - identifier: "session.event.source", + identifier: "session.next.event.source", }) export type Source = Schema.Schema.Type -export const Prompted = BaseEvent.define({ - type: "session.prompted", +export const Prompted = Event.define({ + type: "session.next.prompted", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, prompt: Prompt, }, }) export type Prompted = Schema.Schema.Type -export const Synthetic = BaseEvent.define({ - type: "session.synthetic", +export const Synthetic = Event.define({ + type: "session.next.synthetic", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, text: Schema.String, }, @@ -37,10 +39,11 @@ export const Synthetic = BaseEvent.define({ export type Synthetic = Schema.Schema.Type export namespace Step { - export const Started = BaseEvent.define({ - type: "session.step.started", + export const Started = Event.define({ + type: "session.next.step.started", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, model: Schema.Struct({ id: Schema.String, @@ -51,10 +54,11 @@ export namespace Step { }) export type Started = Schema.Schema.Type - export const Ended = BaseEvent.define({ - type: "session.step.ended", + export const Ended = Event.define({ + type: "session.next.step.ended", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, reason: Schema.String, cost: Schema.Number, @@ -73,29 +77,32 @@ export namespace Step { } export namespace Text { - export const Started = BaseEvent.define({ - type: "session.text.started", + export const Started = Event.define({ + type: "session.next.text.started", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, }, }) export type Started = Schema.Schema.Type - export const Delta = BaseEvent.define({ - type: "session.text.delta", + export const Delta = Event.define({ + type: "session.next.text.delta", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, delta: Schema.String, }, }) export type Delta = Schema.Schema.Type - export const Ended = BaseEvent.define({ - type: "session.text.ended", + export const Ended = Event.define({ + type: "session.next.text.ended", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, text: Schema.String, }, @@ -104,29 +111,32 @@ export namespace Text { } export namespace Reasoning { - export const Started = BaseEvent.define({ - type: "session.reasoning.started", + export const Started = Event.define({ + type: "session.next.reasoning.started", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, }, }) export type Started = Schema.Schema.Type - export const Delta = BaseEvent.define({ - type: "session.reasoning.delta", + export const Delta = Event.define({ + type: "session.next.reasoning.delta", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, delta: Schema.String, }, }) export type Delta = Schema.Schema.Type - export const Ended = BaseEvent.define({ - type: "session.reasoning.ended", + export const Ended = Event.define({ + type: "session.next.reasoning.ended", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, text: Schema.String, }, @@ -136,10 +146,11 @@ export namespace Reasoning { export namespace Tool { export namespace Input { - export const Started = BaseEvent.define({ - type: "session.tool.input.started", + export const Started = Event.define({ + type: "session.next.tool.input.started", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, callID: Schema.String, name: Schema.String, @@ -147,10 +158,11 @@ export namespace Tool { }) export type Started = Schema.Schema.Type - export const Delta = BaseEvent.define({ - type: "session.tool.input.delta", + export const Delta = Event.define({ + type: "session.next.tool.input.delta", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, callID: Schema.String, delta: Schema.String, @@ -158,10 +170,11 @@ export namespace Tool { }) export type Delta = Schema.Schema.Type - export const Ended = BaseEvent.define({ - type: "session.tool.input.ended", + export const Ended = Event.define({ + type: "session.next.tool.input.ended", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, callID: Schema.String, text: Schema.String, @@ -170,10 +183,11 @@ export namespace Tool { export type Ended = Schema.Schema.Type } - export const Called = BaseEvent.define({ - type: "session.tool.called", + export const Called = Event.define({ + type: "session.next.tool.called", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, callID: Schema.String, tool: Schema.String, @@ -186,10 +200,11 @@ export namespace Tool { }) export type Called = Schema.Schema.Type - export const Success = BaseEvent.define({ - type: "session.tool.success", + export const Success = Event.define({ + type: "session.next.tool.success", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, callID: Schema.String, title: Schema.String, @@ -203,10 +218,11 @@ export namespace Tool { }) export type Success = Schema.Schema.Type - export const Error = BaseEvent.define({ - type: "session.tool.error", + export const Error = Event.define({ + type: "session.next.tool.error", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, callID: Schema.String, error: Schema.String, @@ -227,14 +243,15 @@ export const RetryError = Schema.Struct({ responseBody: Schema.String.pipe(Schema.optional), metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), }).annotate({ - identifier: "session.retry_error", + identifier: "session.next.retry_error", }) export type RetryError = Schema.Schema.Type -export const Retried = BaseEvent.define({ - type: "session.retried", +export const Retried = Event.define({ + type: "session.next.retried", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, attempt: Schema.Number, error: RetryError, @@ -242,10 +259,11 @@ export const Retried = BaseEvent.define({ }) export type Retried = Schema.Schema.Type -export const Compacted = BaseEvent.define({ - type: "session.compacted", +export const Compacted = Event.define({ + type: "session.next.compacted", aggregate: "sessionID", schema: { + timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, auto: Schema.Boolean, overflow: Schema.Boolean.pipe(Schema.optional), @@ -253,7 +271,7 @@ export const Compacted = BaseEvent.define({ }) export type Compacted = Schema.Schema.Type -export const Event = Schema.Union( +export const All = Schema.Union( [ Prompted, Synthetic, @@ -278,7 +296,8 @@ export const Event = Schema.Union( mode: "oneOf", }, ).pipe(Schema.toTaggedUnion("type")) -export type Event = Schema.Schema.Type + +export type Event = Schema.Schema.Type export type Type = Event["type"] export * as SessionEvent from "./session-event" diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 479da7f518..aca0170bd8 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -79,7 +79,7 @@ delete process.env["OPENCODE_SERVER_USERNAME"] process.env["OPENCODE_DB"] = ":memory:" // Now safe to import from src/ -const Log = await import("@opencode-ai/core/util/log") +const { Log } = await import("@opencode-ai/core/util/log") const { initProjectors } = await import("../src/server/projectors") void Log.init({ diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts deleted file mode 100644 index 0feb00759a..0000000000 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ /dev/null @@ -1,641 +0,0 @@ -import { describe, expect, test } from "bun:test" -import * as DateTime from "effect/DateTime" -import { SessionID } from "../../src/session/schema" -import { SessionEntry } from "../../src/v2/session-entry" -import { SessionEntryStepper } from "../../src/v2/session-entry-stepper" -import { SessionEvent } from "../../src/v2/session-event" - -const sessionID = SessionID.descending() -const time = (n: number) => DateTime.makeUnsafe(n) -const tokens = { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, -} - -function base(type: Type, timestamp: number) { - return { - id: SessionEvent.ID.create(), - type, - sessionID, - timestamp: time(timestamp), - } -} - -function stepStarted(timestamp = 1) { - return ({ - ...base("session.step.started", timestamp), - model: { - id: "model", - providerID: "provider", - }, - }) -} - -function stepEnded(timestamp = 1) { - return ({ - ...base("session.step.ended", timestamp), - reason: "stop", - cost: 1, - tokens, - }) -} - -function assistant() { - return new SessionEntry.Assistant({ - id: SessionEvent.ID.create(), - type: "assistant", - time: { created: time(0) }, - content: [], - retries: [], - }) -} - -function retryError(message: string) { - return ({ - message, - isRetryable: true, - }) -} - -function retried(attempt: number, message: string, timestamp = 1) { - return ({ - ...base("session.retried", timestamp), - attempt, - error: retryError(message), - }) -} - -function retry(attempt: number, message: string, created: number) { - return new SessionEntry.AssistantRetry({ - attempt, - error: retryError(message), - time: { - created: time(created), - }, - }) -} - -function memoryState() { - const state: SessionEntryStepper.MemoryState = { - entries: [], - pending: [], - } - return state -} - -function active() { - const state: SessionEntryStepper.MemoryState = { - entries: [assistant()], - pending: [], - } - return state -} - -function run(events: SessionEvent.Event[], state = memoryState()) { - return events.reduce((state, event) => SessionEntryStepper.step(state, event), state) -} - -function last(state: SessionEntryStepper.MemoryState) { - const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant") - expect(entry?.type).toBe("assistant") - return entry?.type === "assistant" ? entry : undefined -} - -function textsOf(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text") -} - -function reasons(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning") -} - -function tools(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool") -} - -function tool(state: SessionEntryStepper.MemoryState, callID: string) { - return tools(state).find((x) => x.callID === callID) -} - -function retriesOf(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.retries ?? [] -} - -describe("session-entry-stepper", () => { - describe("stepWith", () => { - test("aggregates retry events onto the current assistant", () => { - const state = active() - - SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), retried(1, "rate limited", 1)) - SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), retried(2, "provider overloaded", 2)) - - expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)]) - }) - }) - - describe("memory", () => { - test("tracks and replaces the current assistant", () => { - const state = active() - const adapter = SessionEntryStepper.memory(state) - const current = adapter.getCurrentAssistant() - - expect(current?.type).toBe("assistant") - if (!current) return - - adapter.updateAssistant( - new SessionEntry.Assistant({ - ...current, - content: [new SessionEntry.AssistantText({ type: "text", text: "done" })], - time: { - ...current.time, - completed: time(1), - }, - }), - ) - - expect(adapter.getCurrentAssistant()).toBeUndefined() - expect(state.entries[0]?.type).toBe("assistant") - if (state.entries[0]?.type !== "assistant") return - - expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }]) - expect(state.entries[0].time.completed).toEqual(time(1)) - }) - - test("appends committed and pending entries", () => { - const state = memoryState() - const adapter = SessionEntryStepper.memory(state) - const committed = SessionEntry.User.fromEvent( - ({ ...base("session.prompted", 1), prompt: { text: "committed" } }), - ) - const pending = SessionEntry.User.fromEvent(({ ...base("session.prompted", 2), prompt: { text: "pending" } })) - - adapter.appendEntry(committed) - adapter.appendPending(pending) - - expect(state.entries).toEqual([committed]) - expect(state.pending).toEqual([pending]) - }) - - test("stepWith through memory records reasoning", () => { - const state = active() - - SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), (base("session.reasoning.started", 1))) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - ({ ...base("session.reasoning.delta", 2), delta: "draft" }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - ({ ...base("session.reasoning.ended", 3), text: "final" }), - ) - - expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }]) - }) - - test("stepWith through memory records retries", () => { - const state = active() - - SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), retried(1, "rate limited", 1)) - - expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)]) - }) - }) - - describe("step", () => { - describe("seeded pending assistant", () => { - test("stores prompts in entries when no assistant is pending", () => { - const next = SessionEntryStepper.step(memoryState(), ({ ...base("session.prompted", 1), prompt: { text: "hello" } })) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("user") - if (next.entries[0]?.type !== "user") return - expect(next.entries[0].text).toBe("hello") - }) - - test("stores prompts in pending when an assistant is pending", () => { - const next = SessionEntryStepper.step(active(), ({ ...base("session.prompted", 1), prompt: { text: "hello" } })) - expect(next.pending).toHaveLength(1) - expect(next.pending[0]?.type).toBe("user") - if (next.pending[0]?.type !== "user") return - expect(next.pending[0].text).toBe("hello") - }) - - test("accumulates text deltas on the latest text part", () => { - const next = run( - [ - (base("session.text.started", 1)), - ({ ...base("session.text.delta", 2), delta: "hel" }), - ({ ...base("session.text.delta", 3), delta: "lo" }), - ], - active(), - ) - - expect(textsOf(next)).toEqual([ - { - type: "text", - text: "hello", - }, - ]) - }) - - test("routes later text deltas to the latest text segment", () => { - const next = run( - [ - (base("session.text.started", 1)), - ({ ...base("session.text.delta", 2), delta: "first" }), - (base("session.text.started", 3)), - ({ ...base("session.text.delta", 4), delta: "second" }), - ], - active(), - ) - - expect(textsOf(next)).toEqual([ - { type: "text", text: "first" }, - { type: "text", text: "second" }, - ]) - }) - - test("reasoning.ended replaces buffered reasoning text", () => { - const next = run( - [ - (base("session.reasoning.started", 1)), - ({ ...base("session.reasoning.delta", 2), delta: "draft" }), - ({ ...base("session.reasoning.ended", 3), text: "final" }), - ], - active(), - ) - - expect(reasons(next)).toEqual([ - { - type: "reasoning", - text: "final", - }, - ]) - }) - - test("tool.success completes the latest running tool", () => { - const input = { command: "ls", limit: 2 } - const metadata = { cwd: "/tmp" } - const attachments = [SessionEvent.FileAttachment.create({ uri: "file:///tmp/out.txt", mime: "text/plain" })] - const next = run( - [ - ({ ...base("session.tool.input.started", 1), callID: "call", name: "bash" }), - ({ ...base("session.tool.input.delta", 2), callID: "call", delta: "{\"command\":" }), - ({ ...base("session.tool.input.delta", 3), callID: "call", delta: "\"ls\"}" }), - ({ - ...base("session.tool.called", 4), - callID: "call", - tool: "bash", - input, - provider: { executed: true }, - }), - ({ - ...base("session.tool.success", 5), - callID: "call", - title: "Listed files", - output: "ok", - metadata, - attachments, - provider: { executed: true }, - }), - ], - active(), - ) - - const match = tool(next, "call") - expect(match?.state.status).toBe("completed") - if (match?.state.status !== "completed") return - - expect(match.time.ran).toEqual(time(4)) - expect(match.state.input).toEqual(input) - expect(match.state.output).toBe("ok") - expect(match.state.title).toBe("Listed files") - expect(match.state.metadata).toEqual(metadata) - expect(match.state.attachments).toEqual(attachments) - }) - - test("tool.error completes the latest running tool with an error", () => { - const input = { command: "ls" } - const metadata = { cwd: "/tmp" } - const next = run( - [ - ({ ...base("session.tool.input.started", 1), callID: "call", name: "bash" }), - ({ - ...base("session.tool.called", 2), - callID: "call", - tool: "bash", - input, - provider: { executed: true }, - }), - ({ - ...base("session.tool.error", 3), - callID: "call", - error: "permission denied", - metadata, - provider: { executed: true }, - }), - ], - active(), - ) - - const match = tool(next, "call") - expect(match?.state.status).toBe("error") - if (match?.state.status !== "error") return - - expect(match.time.ran).toEqual(time(2)) - expect(match.state.input).toEqual(input) - expect(match.state.error).toBe("permission denied") - expect(match.state.metadata).toEqual(metadata) - }) - - test("tool.success is ignored before tool.called promotes the tool to running", () => { - const next = run( - [ - ({ ...base("session.tool.input.started", 1), callID: "call", name: "bash" }), - ({ - ...base("session.tool.success", 2), - callID: "call", - title: "Done", - provider: { executed: true }, - }), - ], - active(), - ) - const match = tool(next, "call") - expect(match?.state).toEqual({ - status: "pending", - input: "", - }) - }) - - test("step.ended copies completion fields onto the pending assistant", () => { - const event = stepEnded(9) - const next = SessionEntryStepper.step(active(), event) - const entry = last(next) - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.time.completed).toEqual(event.timestamp) - expect(entry.cost).toBe(event.cost) - expect(entry.tokens).toEqual(event.tokens) - }) - }) - - describe("known reducer gaps", () => { - test("prompt appends immutably when no assistant is pending", () => { - const old = memoryState() - const next = SessionEntryStepper.step(old, ({ ...base("session.prompted", 1), prompt: { text: "hello" } })) - expect(old).not.toBe(next) - expect(old.entries).toHaveLength(0) - expect(next.entries).toHaveLength(1) - }) - - test("prompt appends immutably when an assistant is pending", () => { - const old = active() - const next = SessionEntryStepper.step(old, ({ ...base("session.prompted", 1), prompt: { text: "hello" } })) - expect(old).not.toBe(next) - expect(old.pending).toHaveLength(0) - expect(next.pending).toHaveLength(1) - }) - - test("step.started creates an assistant consumed by follow-up events", () => { - const next = run([ - stepStarted(1), - (base("session.text.started", 2)), - ({ ...base("session.text.delta", 3), delta: "hello" }), - stepEnded(4), - ]) - const entry = last(next) - - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.content).toEqual([ - { - type: "text", - text: "hello", - }, - ]) - expect(entry.time.completed).toEqual(time(4)) - }) - - test("replays prompt -> step -> text -> step.ended", () => { - const next = run([ - ({ ...base("session.prompted", 0), prompt: { text: "hello" } }), - stepStarted(1), - (base("session.text.started", 2)), - ({ ...base("session.text.delta", 3), delta: "world" }), - stepEnded(4), - ]) - - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("user") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[1]?.type !== "assistant") return - - expect(next.entries[1].content).toEqual([ - { - type: "text", - text: "world", - }, - ]) - expect(next.entries[1].time.completed).toEqual(time(4)) - }) - - test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => { - const input = { command: "ls" } - const next = run([ - ({ ...base("session.prompted", 0), prompt: { text: "hello" } }), - stepStarted(1), - (base("session.reasoning.started", 2)), - ({ ...base("session.reasoning.delta", 3), delta: "draft" }), - ({ ...base("session.reasoning.ended", 4), text: "final" }), - ({ ...base("session.tool.input.started", 5), callID: "call", name: "bash" }), - ({ - ...base("session.tool.called", 6), - callID: "call", - tool: "bash", - input, - provider: { executed: true }, - }), - ({ - ...base("session.tool.success", 7), - callID: "call", - title: "Listed files", - output: "ok", - provider: { executed: true }, - }), - stepEnded(8), - ]) - - expect(next.entries.at(-1)?.type).toBe("assistant") - const entry = next.entries.at(-1) - if (entry?.type !== "assistant") return - - expect(entry.content).toHaveLength(2) - expect(entry.content[0]).toEqual({ - type: "reasoning", - text: "final", - }) - expect(entry.content[1]?.type).toBe("tool") - if (entry.content[1]?.type !== "tool") return - expect(entry.content[1].state.status).toBe("completed") - expect(entry.time.completed).toEqual(time(8)) - }) - - test("starting a new step completes the old assistant and appends a new active assistant", () => { - const next = run([stepStarted(1)], active()) - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("assistant") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return - - expect(next.entries[0].time.completed).toEqual(time(1)) - expect(next.entries[1].time.created).toEqual(time(1)) - expect(next.entries[1].time.completed).toBeUndefined() - }) - - test("handles sequential tools independently", () => { - const firstInput = { command: "ls" } - const secondInput = { pattern: "TODO" } - const next = run( - [ - ({ ...base("session.tool.input.started", 1), callID: "a", name: "bash" }), - ({ - ...base("session.tool.called", 2), - callID: "a", - tool: "bash", - input: firstInput, - provider: { executed: true }, - }), - ({ - ...base("session.tool.success", 3), - callID: "a", - title: "Listed", - output: "done", - provider: { executed: true }, - }), - ({ ...base("session.tool.input.started", 4), callID: "b", name: "bash" }), - ({ - ...base("session.tool.called", 5), - callID: "b", - tool: "bash", - input: secondInput, - provider: { executed: true }, - }), - ({ - ...base("session.tool.error", 6), - callID: "b", - error: "not found", - provider: { executed: true }, - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - if (first?.state.status !== "completed") return - expect(first.state.input).toEqual(firstInput) - expect(first.state.output).toBe("done") - expect(first.state.title).toBe("Listed") - - expect(second?.state.status).toBe("error") - if (second?.state.status !== "error") return - expect(second.state.input).toEqual(secondInput) - expect(second.state.error).toBe("not found") - }) - - test("routes tool events by callID when tool streams interleave", () => { - const firstInput = { command: "ls" } - const secondInput = { pattern: "TODO" } - const next = run( - [ - ({ ...base("session.tool.input.started", 1), callID: "a", name: "bash" }), - ({ ...base("session.tool.input.started", 2), callID: "b", name: "grep" }), - ({ ...base("session.tool.input.delta", 3), callID: "a", delta: "first" }), - ({ ...base("session.tool.input.delta", 4), callID: "b", delta: "second" }), - ({ - ...base("session.tool.called", 5), - callID: "a", - tool: "bash", - input: firstInput, - provider: { executed: true }, - }), - ({ - ...base("session.tool.called", 6), - callID: "b", - tool: "grep", - input: secondInput, - provider: { executed: true }, - }), - ({ - ...base("session.tool.success", 7), - callID: "a", - title: "Listed", - output: "done-a", - provider: { executed: true }, - }), - ({ - ...base("session.tool.success", 8), - callID: "b", - title: "Grep", - output: "done-b", - provider: { executed: true }, - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - expect(second?.state.status).toBe("completed") - if (first?.state.status !== "completed" || second?.state.status !== "completed") return - - expect(first.state.input).toEqual(firstInput) - expect(second.state.input).toEqual(secondInput) - expect(first.state.title).toBe("Listed") - expect(second.state.title).toBe("Grep") - }) - - test("records synthetic events", () => { - const next = SessionEntryStepper.step( - memoryState(), - ({ ...base("session.synthetic", 1), text: "generated" }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("synthetic") - if (next.entries[0]?.type !== "synthetic") return - expect(next.entries[0].text).toBe("generated") - }) - - test("records compaction events", () => { - const next = SessionEntryStepper.step( - memoryState(), - ({ ...base("session.compacted", 1), auto: true, overflow: false }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("compaction") - if (next.entries[0]?.type !== "compaction") return - expect(next.entries[0].auto).toBe(true) - expect(next.entries[0].overflow).toBe(false) - }) - }) - }) -}) From 91938e2934f5a2d6652de9f5d9563b0833ad493e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 26 Apr 2026 17:27:37 -0400 Subject: [PATCH 07/14] core: fix reasoning tracking for multiple blocks per response Enable proper handling of multiple reasoning blocks within a single assistant response by assigning unique reasoningIDs to each block. Previously, reasoning blocks could get mixed up when multiple reasoning steps occurred in one turn, causing deltas and completion events to apply to the wrong block. --- packages/opencode/src/session/processor.ts | 8 ++++++++ packages/opencode/src/v2/session-entry-stepper.ts | 13 ++++++++----- packages/opencode/src/v2/session-entry.ts | 1 + packages/opencode/src/v2/session-event.ts | 3 +++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 245f9063db..0ec6400e26 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -226,6 +226,7 @@ export const layer: Layer.Layer< if (value.id in ctx.reasoningMap) return SyncEvent.run(SessionEvent.Reasoning.Started.Sync, { sessionID: ctx.sessionID, + reasoningID: value.id, timestamp: DateTime.makeUnsafe(Date.now()), }) ctx.reasoningMap[value.id] = { @@ -242,6 +243,12 @@ export const layer: Layer.Layer< case "reasoning-delta": if (!(value.id in ctx.reasoningMap)) return + SyncEvent.run(SessionEvent.Reasoning.Delta.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + delta: value.text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) ctx.reasoningMap[value.id].text += value.text if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata yield* session.updatePartDelta({ @@ -257,6 +264,7 @@ export const layer: Layer.Layer< if (!(value.id in ctx.reasoningMap)) return SyncEvent.run(SessionEvent.Reasoning.Ended.Sync, { sessionID: ctx.sessionID, + reasoningID: value.id, text: ctx.reasoningMap[value.id].text, timestamp: DateTime.makeUnsafe(Date.now()), }) diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts index 9535d95c90..5abefabe30 100644 --- a/packages/opencode/src/v2/session-entry-stepper.ts +++ b/packages/opencode/src/v2/session-entry-stepper.ts @@ -60,8 +60,10 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E const latestText = (assistant: DraftAssistant | undefined) => assistant?.content.findLast((item): item is DraftText => item.type === "text") - const latestReasoning = (assistant: DraftAssistant | undefined) => - assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning") + const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => + assistant?.content.findLast( + (item): item is DraftReasoning => item.type === "reasoning" && item.reasoningID === reasoningID, + ) SessionEvent.All.match(event, { "session.next.prompted": (event) => { @@ -203,12 +205,13 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - "session.next.reasoning.started": () => { + "session.next.reasoning.started": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { draft.content.push({ type: "reasoning", + reasoningID: event.data.reasoningID, text: "", }) }), @@ -219,7 +222,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) + const match = latestReasoning(draft, event.data.reasoningID) if (match) match.text += event.data.delta }), ) @@ -229,7 +232,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) + const match = latestReasoning(draft, event.data.reasoningID) if (match) match.text = event.data.text }), ) diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index ece1ad8255..42a020e05a 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -107,6 +107,7 @@ export class AssistantText extends Schema.Class("Session.Entry.As export class AssistantReasoning extends Schema.Class("Session.Entry.Assistant.Reasoning")({ type: Schema.Literal("reasoning"), + reasoningID: Schema.String, text: Schema.String, }) {} diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 548de924f3..0ea69c68f7 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -117,6 +117,7 @@ export namespace Reasoning { schema: { timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, + reasoningID: Schema.String, }, }) export type Started = Schema.Schema.Type @@ -127,6 +128,7 @@ export namespace Reasoning { schema: { timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, + reasoningID: Schema.String, delta: Schema.String, }, }) @@ -138,6 +140,7 @@ export namespace Reasoning { schema: { timestamp: Schema.DateTimeUtcFromMillis, sessionID: SessionID, + reasoningID: Schema.String, text: Schema.String, }, }) From 97685d5ed1ee845c1a3c048b3a606800a950cc16 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 26 Apr 2026 18:55:59 -0400 Subject: [PATCH 08/14] core: enable real-time tool progress updates during execution Add session.next.tool.progress event so users can see live status from long-running tools instead of waiting for completion. Consolidate tool state metadata into a unified 'details' field for consistent display. --- packages/opencode/src/session/processor.ts | 3 +- .../opencode/src/v2/session-entry-stepper.ts | 14 +++- packages/opencode/src/v2/session-entry.ts | 8 +- packages/opencode/src/v2/session-event.ts | 73 +++++++++---------- 4 files changed, 51 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 0ec6400e26..0efb9697ea 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -380,7 +380,6 @@ export const layer: Layer.Layer< SyncEvent.run(SessionEvent.Tool.Success.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, - title: value.output.title, output: value.output.output, attachments: value.output.attachments?.map((item: MessageV2.FilePart) => ({ uri: item.url, @@ -396,9 +395,9 @@ export const layer: Layer.Layer< } : {}), })), + details: value.output.metadata, provider: { executed: toolCall?.part.metadata?.providerExecuted === true, - metadata: value.output.metadata, }, timestamp: DateTime.makeUnsafe(Date.now()), }) diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts index 5abefabe30..745024e05f 100644 --- a/packages/opencode/src/v2/session-entry-stepper.ts +++ b/packages/opencode/src/v2/session-entry-stepper.ts @@ -169,6 +169,16 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, + "session.next.tool.progress": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") match.state.details = event.data.details + }), + ) + } + }, "session.next.tool.success": (event) => { if (currentAssistant) { adapter.updateAssistant( @@ -179,8 +189,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E status: "completed", input: match.state.input, output: event.data.output ?? "", - title: event.data.title, - metadata: event.metadata ?? {}, + details: event.data.details, attachments: [...(event.data.attachments ?? [])], } } @@ -198,7 +207,6 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E status: "error", error: event.data.error, input: match.state.input, - metadata: event.metadata ?? {}, } } }), diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index 42a020e05a..e7681f3954 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -62,16 +62,14 @@ export class ToolStatePending extends Schema.Class("Session.En export class ToolStateRunning extends Schema.Class("Session.Entry.ToolState.Running")({ status: Schema.Literal("running"), input: Schema.Record(Schema.String, Schema.Unknown), - title: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + details: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }) {} export class ToolStateCompleted extends Schema.Class("Session.Entry.ToolState.Completed")({ status: Schema.Literal("completed"), input: Schema.Record(Schema.String, Schema.Unknown), output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown), + details: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), }) {} @@ -79,7 +77,7 @@ export class ToolStateError extends Schema.Class("Session.Entry. status: Schema.Literal("error"), input: Schema.Record(Schema.String, Schema.Unknown), error: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + details: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }) {} export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 0ea69c68f7..c0f4003fe5 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -16,12 +16,16 @@ export const Source = Schema.Struct({ }) export type Source = Schema.Schema.Type +const Base = { + timestamp: Schema.DateTimeUtcFromMillis, + sessionID: SessionID, +} + export const Prompted = Event.define({ type: "session.next.prompted", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, prompt: Prompt, }, }) @@ -31,8 +35,7 @@ export const Synthetic = Event.define({ type: "session.next.synthetic", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, text: Schema.String, }, }) @@ -43,8 +46,7 @@ export namespace Step { type: "session.next.step.started", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, model: Schema.Struct({ id: Schema.String, providerID: Schema.String, @@ -58,8 +60,7 @@ export namespace Step { type: "session.next.step.ended", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, reason: Schema.String, cost: Schema.Number, tokens: Schema.Struct({ @@ -81,8 +82,7 @@ export namespace Text { type: "session.next.text.started", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, }, }) export type Started = Schema.Schema.Type @@ -91,8 +91,7 @@ export namespace Text { type: "session.next.text.delta", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, delta: Schema.String, }, }) @@ -102,8 +101,7 @@ export namespace Text { type: "session.next.text.ended", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, text: Schema.String, }, }) @@ -115,8 +113,7 @@ export namespace Reasoning { type: "session.next.reasoning.started", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, reasoningID: Schema.String, }, }) @@ -126,8 +123,7 @@ export namespace Reasoning { type: "session.next.reasoning.delta", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, reasoningID: Schema.String, delta: Schema.String, }, @@ -138,8 +134,7 @@ export namespace Reasoning { type: "session.next.reasoning.ended", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, reasoningID: Schema.String, text: Schema.String, }, @@ -153,8 +148,7 @@ export namespace Tool { type: "session.next.tool.input.started", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, callID: Schema.String, name: Schema.String, }, @@ -165,8 +159,7 @@ export namespace Tool { type: "session.next.tool.input.delta", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, callID: Schema.String, delta: Schema.String, }, @@ -177,8 +170,7 @@ export namespace Tool { type: "session.next.tool.input.ended", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, callID: Schema.String, text: Schema.String, }, @@ -190,8 +182,7 @@ export namespace Tool { type: "session.next.tool.called", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, callID: Schema.String, tool: Schema.String, input: Schema.Record(Schema.String, Schema.Unknown), @@ -203,16 +194,26 @@ export namespace Tool { }) export type Called = Schema.Schema.Type + export const Progress = Event.define({ + type: "session.next.tool.progress", + aggregate: "sessionID", + schema: { + ...Base, + callID: Schema.String, + details: Schema.Record(Schema.String, Schema.Unknown), + }, + }) + export type Progress = Schema.Schema.Type + export const Success = Event.define({ type: "session.next.tool.success", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, callID: Schema.String, - title: Schema.String, output: Schema.String.pipe(Schema.optional), attachments: Schema.Array(FileAttachment).pipe(Schema.optional), + details: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), @@ -225,8 +226,7 @@ export namespace Tool { type: "session.next.tool.error", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, callID: Schema.String, error: Schema.String, provider: Schema.Struct({ @@ -254,8 +254,7 @@ export const Retried = Event.define({ type: "session.next.retried", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, attempt: Schema.Number, error: RetryError, }, @@ -266,8 +265,7 @@ export const Compacted = Event.define({ type: "session.next.compacted", aggregate: "sessionID", schema: { - timestamp: Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + ...Base, auto: Schema.Boolean, overflow: Schema.Boolean.pipe(Schema.optional), }, @@ -287,6 +285,7 @@ export const All = Schema.Union( Tool.Input.Delta, Tool.Input.Ended, Tool.Called, + Tool.Progress, Tool.Success, Tool.Error, Reasoning.Started, From ed91976618c12a2f722c7008e505de91ed28842f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 27 Apr 2026 13:38:14 -0400 Subject: [PATCH 09/14] core: rename session entry to message and expose full session lifecycle events in SDK Renamed SessionEntry to SessionMessage for clearer, more intuitive API terminology that better represents the message-based nature of session interactions. Exposed 22 new session lifecycle event types in the JavaScript SDK including prompted, step started/ended, text/reasoning/tool deltas, tool calls with progress tracking, retries, and compaction events. This enables SDK consumers to build real-time UIs that accurately reflect agent session state as it evolves, providing users with visibility into thinking steps, tool execution, and retries. --- .../migration.sql | 17 + .../snapshot.json | 1481 +++++++++++++++++ .../opencode/src/session/projectors-next.ts | 66 +- packages/opencode/src/session/session.sql.ts | 18 +- packages/opencode/src/v2/session-event.ts | 1 + ...-stepper.ts => session-message-updater.ts} | 62 +- .../{session-entry.ts => session-message.ts} | 59 +- packages/opencode/src/v2/session.ts | 8 +- packages/sdk/js/src/v2/gen/types.gen.ts | 615 +++++++ 9 files changed, 2223 insertions(+), 104 deletions(-) create mode 100644 packages/opencode/migration/20260427172553_slow_nightmare/migration.sql create mode 100644 packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json rename packages/opencode/src/v2/{session-entry-stepper.ts => session-message-updater.ts} (80%) rename packages/opencode/src/v2/{session-entry.ts => session-message.ts} (81%) diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql new file mode 100644 index 0000000000..d5efe5f9e8 --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE `session_message` ( + `id` text PRIMARY KEY, + `session_id` text NOT NULL, + `type` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + `data` text NOT NULL, + CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_type_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_time_created_idx`;--> statement-breakpoint +CREATE INDEX `session_message_session_idx` ON `session_message` (`session_id`);--> statement-breakpoint +CREATE INDEX `session_message_session_type_idx` ON `session_message` (`session_id`,`type`);--> statement-breakpoint +CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);--> statement-breakpoint +DROP TABLE `session_entry`; \ No newline at end of file diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json new file mode 100644 index 0000000000..bb6d06237e --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json @@ -0,0 +1,1481 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "61f807f9-6398-4067-be05-804acc2561bc", + "prevIds": [ + "66cbe0d7-def0-451b-b88a-7608513a9b44" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index 3298edfd6d..f5ea2dd704 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -1,40 +1,42 @@ import { and, desc, eq } from "@/storage" import type { Database } from "@/storage" -import { SessionEntry } from "@/v2/session-entry" -import { SessionEntryStepper } from "@/v2/session-entry-stepper" +import { SessionMessage } from "@/v2/session-message" +import { SessionMessageUpdater } from "@/v2/session-message-updater" import { SessionEvent } from "@/v2/session-event" import * as DateTime from "effect/DateTime" import { SyncEvent } from "@/sync" -import { SessionEntryTable } from "./session.sql" +import { SessionMessageTable } from "./session.sql" import type { SessionID } from "./schema" -function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionEntryStepper.Adapter { +function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdater.Adapter { return { getCurrentAssistant() { return db .select() - .from(SessionEntryTable) - .where(and(eq(SessionEntryTable.session_id, sessionID), eq(SessionEntryTable.type, "assistant"))) - .orderBy(desc(SessionEntryTable.id)) + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant"))) + .orderBy(desc(SessionMessageTable.id)) .all() - .map((row) => ({ id: row.id, type: row.type, ...row.data }) as SessionEntry.Entry) - .find((entry): entry is SessionEntry.Assistant => entry.type === "assistant" && !entry.time.completed) + .map((row) => ({ id: row.id, type: row.type, ...row.data }) as SessionMessage.Message) + .find((message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed) }, updateAssistant(assistant) { const { id, type, ...data } = assistant - db.update(SessionEntryTable) + db.update(SessionMessageTable) .set({ data }) - .where(and(eq(SessionEntryTable.id, id), eq(SessionEntryTable.session_id, sessionID), eq(SessionEntryTable.type, type))) + .where( + and(eq(SessionMessageTable.id, id), eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, type)), + ) .run() }, - appendEntry(entry) { - const { id, type, ...data } = entry - db.insert(SessionEntryTable) + appendMessage(message) { + const { id, type, ...data } = message + db.insert(SessionMessageTable) .values({ id, session_id: sessionID, type, - time_created: DateTime.toEpochMillis(entry.time.created), + time_created: DateTime.toEpochMillis(message.time.created), data, }) .run() @@ -44,57 +46,57 @@ function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionEntryStepper. } } -function step(db: Database.TxOrDb, event: SessionEvent.Event) { - SessionEntryStepper.stepWith(sqlite(db, event.data.sessionID), event) +function update(db: Database.TxOrDb, event: SessionEvent.Event) { + SessionMessageUpdater.update(sqlite(db, event.data.sessionID), event) } export default [ SyncEvent.project(SessionEvent.Prompted.Sync, (db, data) => { - step(db, { type: "session.next.prompted", data }) + update(db, { type: "session.next.prompted", data }) }), SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data) => { - step(db, { type: "session.next.synthetic", data }) + update(db, { type: "session.next.synthetic", data }) }), SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data) => { - step(db, { type: "session.next.step.started", data }) + update(db, { type: "session.next.step.started", data }) }), SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data) => { - step(db, { type: "session.next.step.ended", data }) + update(db, { type: "session.next.step.ended", data }) }), SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data) => { - step(db, { type: "session.next.text.started", data }) + update(db, { type: "session.next.text.started", data }) }), SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}), SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data) => { - step(db, { type: "session.next.text.ended", data }) + update(db, { type: "session.next.text.ended", data }) }), SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data) => { - step(db, { type: "session.next.tool.input.started", data }) + update(db, { type: "session.next.tool.input.started", data }) }), SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}), SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data) => { - step(db, { type: "session.next.tool.input.ended", data }) + update(db, { type: "session.next.tool.input.ended", data }) }), SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data) => { - step(db, { type: "session.next.tool.called", data }) + update(db, { type: "session.next.tool.called", data }) }), SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data) => { - step(db, { type: "session.next.tool.success", data }) + update(db, { type: "session.next.tool.success", data }) }), SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data) => { - step(db, { type: "session.next.tool.error", data }) + update(db, { type: "session.next.tool.error", data }) }), SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data) => { - step(db, { type: "session.next.reasoning.started", data }) + update(db, { type: "session.next.reasoning.started", data }) }), SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}), SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data) => { - step(db, { type: "session.next.reasoning.ended", data }) + update(db, { type: "session.next.reasoning.ended", data }) }), SyncEvent.project(SessionEvent.Retried.Sync, (db, data) => { - step(db, { type: "session.next.retried", data }) + update(db, { type: "session.next.retried", data }) }), SyncEvent.project(SessionEvent.Compacted.Sync, (db, data) => { - step(db, { type: "session.next.compacted", data }) + update(db, { type: "session.next.compacted", data }) }), ] diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 35ed8fdda4..b8a8f12e34 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,7 +1,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" -import type { SessionEntry } from "../v2/session-entry" +import type { SessionMessage } from "../v2/session-message" import type { Snapshot } from "../snapshot" import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" @@ -95,22 +95,22 @@ export const TodoTable = sqliteTable( ], ) -export const SessionEntryTable = sqliteTable( - "session_entry", +export const SessionMessageTable = sqliteTable( + "session_message", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), session_id: text() .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), - type: text().$type().notNull(), + type: text().$type().notNull(), ...Timestamps, - data: text({ mode: "json" }).notNull().$type>(), + data: text({ mode: "json" }).notNull().$type>(), }, (table) => [ - index("session_entry_session_idx").on(table.session_id), - index("session_entry_session_type_idx").on(table.session_id, table.type), - index("session_entry_time_created_idx").on(table.time_created), + index("session_message_session_idx").on(table.session_id), + index("session_message_session_type_idx").on(table.session_id, table.type), + index("session_message_time_created_idx").on(table.time_created), ], ) diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index c0f4003fe5..aa53e0b6dc 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -24,6 +24,7 @@ const Base = { export const Prompted = Event.define({ type: "session.next.prompted", aggregate: "sessionID", + version: 1, schema: { ...Base, prompt: Prompt, diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-message-updater.ts similarity index 80% rename from packages/opencode/src/v2/session-entry-stepper.ts rename to packages/opencode/src/v2/session-message-updater.ts index 745024e05f..2e1735549f 100644 --- a/packages/opencode/src/v2/session-entry-stepper.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -1,43 +1,43 @@ import { produce, type WritableDraft } from "immer" import { SessionEvent } from "./session-event" -import { SessionEntry } from "./session-entry" +import { SessionMessage } from "./session-message" export type MemoryState = { - entries: SessionEntry.Entry[] - pending: SessionEntry.Entry[] + messages: SessionMessage.Message[] + pending: SessionMessage.Message[] } export interface Adapter { - readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined - readonly updateAssistant: (assistant: SessionEntry.Assistant) => void - readonly appendEntry: (entry: SessionEntry.Entry) => void - readonly appendPending: (entry: SessionEntry.Entry) => void + readonly getCurrentAssistant: () => SessionMessage.Assistant | undefined + readonly updateAssistant: (assistant: SessionMessage.Assistant) => void + readonly appendMessage: (message: SessionMessage.Message) => void + readonly appendPending: (message: SessionMessage.Message) => void readonly finish: () => Result } export function memory(state: MemoryState): Adapter { const activeAssistantIndex = () => - state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) + state.messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) return { getCurrentAssistant() { const index = activeAssistantIndex() if (index < 0) return - const assistant = state.entries[index] + const assistant = state.messages[index] return assistant?.type === "assistant" ? assistant : undefined }, updateAssistant(assistant) { const index = activeAssistantIndex() if (index < 0) return - const current = state.entries[index] + const current = state.messages[index] if (current?.type !== "assistant") return - state.entries[index] = assistant + state.messages[index] = assistant }, - appendEntry(entry) { - state.entries.push(entry) + appendMessage(message) { + state.messages.push(message) }, - appendPending(entry) { - state.pending.push(entry) + appendPending(message) { + state.pending.push(message) }, finish() { return state @@ -45,12 +45,12 @@ export function memory(state: MemoryState): Adapter { } } -export function stepWith(adapter: Adapter, event: SessionEvent.Event): Result { +export function update(adapter: Adapter, event: SessionEvent.Event): Result { const currentAssistant = adapter.getCurrentAssistant() - type DraftAssistant = WritableDraft - type DraftTool = WritableDraft - type DraftText = WritableDraft - type DraftReasoning = WritableDraft + type DraftAssistant = WritableDraft + type DraftTool = WritableDraft + type DraftText = WritableDraft + type DraftReasoning = WritableDraft const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => assistant?.content.findLast( @@ -67,15 +67,15 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E SessionEvent.All.match(event, { "session.next.prompted": (event) => { - const entry = SessionEntry.User.fromEvent(event) + const message = SessionMessage.User.fromEvent(event) if (currentAssistant) { - adapter.appendPending(entry) + adapter.appendPending(message) return } - adapter.appendEntry(entry) + adapter.appendMessage(message) }, "session.next.synthetic": (event) => { - adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event)) + adapter.appendMessage(SessionMessage.Synthetic.fromEvent(event)) }, "session.next.step.started": (event) => { if (currentAssistant) { @@ -85,7 +85,7 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E }), ) } - adapter.appendEntry(SessionEntry.Assistant.fromEvent(event)) + adapter.appendMessage(SessionMessage.Assistant.fromEvent(event)) }, "session.next.step.ended": (event) => { if (currentAssistant) { @@ -250,23 +250,17 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { - draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)] + draft.retries = [...(draft.retries ?? []), SessionMessage.AssistantRetry.fromEvent(event)] }), ) } }, "session.next.compacted": (event) => { - adapter.appendEntry(SessionEntry.Compaction.fromEvent(event)) + adapter.appendMessage(SessionMessage.Compaction.fromEvent(event)) }, }) return adapter.finish() } -export function step(old: MemoryState, event: SessionEvent.Event): MemoryState { - return produce(old, (draft) => { - stepWith(memory(draft as MemoryState), event) - }) -} - -export * as SessionEntryStepper from "./session-entry-stepper" +export * as SessionMessageUpdater from "./session-message-updater" diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-message.ts similarity index 81% rename from packages/opencode/src/v2/session-entry.ts rename to packages/opencode/src/v2/session-message.ts index e7681f3954..dea7ee80f1 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -14,7 +14,7 @@ const Base = { }), } -export class User extends Schema.Class("Session.Entry.User")({ +export class User extends Schema.Class("Session.Message.User")({ ...Base, text: Prompt.fields.text, files: Prompt.fields.files, @@ -37,7 +37,7 @@ export class User extends Schema.Class("Session.Entry.User")({ } } -export class Synthetic extends Schema.Class("Session.Entry.Synthetic")({ +export class Synthetic extends Schema.Class("Session.Message.Synthetic")({ ...Base, sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID, text: SessionEvent.Synthetic.fields.data.fields.text, @@ -54,18 +54,18 @@ export class Synthetic extends Schema.Class("Session.Entry.Synthetic" } } -export class ToolStatePending extends Schema.Class("Session.Entry.ToolState.Pending")({ +export class ToolStatePending extends Schema.Class("Session.Message.ToolState.Pending")({ status: Schema.Literal("pending"), input: Schema.String, }) {} -export class ToolStateRunning extends Schema.Class("Session.Entry.ToolState.Running")({ +export class ToolStateRunning extends Schema.Class("Session.Message.ToolState.Running")({ status: Schema.Literal("running"), input: Schema.Record(Schema.String, Schema.Unknown), details: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }) {} -export class ToolStateCompleted extends Schema.Class("Session.Entry.ToolState.Completed")({ +export class ToolStateCompleted extends Schema.Class("Session.Message.ToolState.Completed")({ status: Schema.Literal("completed"), input: Schema.Record(Schema.String, Schema.Unknown), output: Schema.String, @@ -73,7 +73,7 @@ export class ToolStateCompleted extends Schema.Class("Sessio attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), }) {} -export class ToolStateError extends Schema.Class("Session.Entry.ToolState.Error")({ +export class ToolStateError extends Schema.Class("Session.Message.ToolState.Error")({ status: Schema.Literal("error"), input: Schema.Record(Schema.String, Schema.Unknown), error: Schema.String, @@ -85,7 +85,7 @@ export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolS ) export type ToolState = Schema.Schema.Type -export class AssistantTool extends Schema.Class("Session.Entry.Assistant.Tool")({ +export class AssistantTool extends Schema.Class("Session.Message.Assistant.Tool")({ type: Schema.Literal("tool"), callID: Schema.String, name: Schema.String, @@ -98,18 +98,18 @@ export class AssistantTool extends Schema.Class("Session.Entry.As }), }) {} -export class AssistantText extends Schema.Class("Session.Entry.Assistant.Text")({ +export class AssistantText extends Schema.Class("Session.Message.Assistant.Text")({ type: Schema.Literal("text"), text: Schema.String, }) {} -export class AssistantReasoning extends Schema.Class("Session.Entry.Assistant.Reasoning")({ +export class AssistantReasoning extends Schema.Class("Session.Message.Assistant.Reasoning")({ type: Schema.Literal("reasoning"), reasoningID: Schema.String, text: Schema.String, }) {} -export class AssistantRetry extends Schema.Class("Session.Entry.Assistant.Retry")({ +export class AssistantRetry extends Schema.Class("Session.Message.Assistant.Retry")({ attempt: Schema.Number, error: SessionEvent.RetryError, time: Schema.Struct({ @@ -132,7 +132,16 @@ export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, ) export type AssistantContent = Schema.Schema.Type -export class Assistant extends Schema.Class("Session.Entry.Assistant")({ +// GET /v2/session/{sessionID}/message?limit=10 +// user +// synthetic +// synthetic +// assistant HTTP req/retried 5 times/response +// compaction +// assistant +// user + +export class Assistant extends Schema.Class("Session.Message.Assistant")({ ...Base, type: Schema.Literal("assistant"), content: AssistantContent.pipe(Schema.Array), @@ -166,7 +175,7 @@ export class Assistant extends Schema.Class("Session.Entry.Assistant" } } -export class Compaction extends Schema.Class("Session.Entry.Compaction")({ +export class Compaction extends Schema.Class("Session.Message.Compaction")({ type: Schema.Literal("compaction"), sessionID: SessionEvent.Compacted.fields.data.fields.sessionID, auto: SessionEvent.Compacted.fields.data.fields.auto, @@ -185,34 +194,34 @@ export class Compaction extends Schema.Class("Session.Entry.Compacti } } -export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type")) +export const Message = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type")) -export type Entry = Schema.Schema.Type +export type Message = Schema.Schema.Type -export type Type = Entry["type"] +export type Type = Message["type"] /* export interface Interface { - readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry - readonly fromSession: (sessionID: SessionID) => Effect.Effect + readonly decode: (row: typeof SessionMessageTable.$inferSelect) => Message + readonly fromSession: (sessionID: SessionID) => Effect.Effect } -export class Service extends Context.Service()("@opencode/SessionEntry") {} +export class Service extends Context.Service()("@opencode/SessionMessage") {} export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { - const decodeEntry = Schema.decodeUnknownSync(Entry) + const decodeMessage = Schema.decodeUnknownSync(Message) - const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type }) + const decode: (typeof Service.Service)["decode"] = (row) => decodeMessage({ ...row, id: row.id, type: row.type }) - const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) { + const fromSession = Effect.fn("SessionMessage.fromSession")(function* (sessionID: SessionID) { return Database.use((db) => db .select() - .from(SessionEntryTable) - .where(eq(SessionEntryTable.session_id, sessionID)) - .orderBy(SessionEntryTable.id) + .from(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, sessionID)) + .orderBy(SessionMessageTable.id) .all() .map((row) => decode(row)), ) @@ -226,4 +235,4 @@ export const layer: Layer.Layer = Layer.effect( ) */ -export * as SessionEntry from "./session-entry" +export * as SessionMessage from "./session-message" diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 2bac11f4fe..f31e875123 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -1,5 +1,5 @@ import { Context, Layer, Schema, Effect } from "effect" -import { SessionEntry } from "./session-entry" +import { SessionMessage } from "./session-message" import { Struct } from "effect" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" @@ -9,8 +9,8 @@ export const ID = SessionID export type ID = Schema.Schema.Type export class PromptInput extends Schema.Class("Session.PromptInput")({ - ...Struct.omit(SessionEntry.User.fields, ["time", "type"]), - id: Schema.optionalKey(SessionEntry.ID), + ...Struct.omit(SessionMessage.User.fields, ["time", "type"]), + id: Schema.optionalKey(SessionMessage.ID), sessionID: ID, }) {} @@ -30,7 +30,7 @@ export class Info extends Schema.Class("Session.Info")({ export interface Interface { fromID: (id: ID) => Effect.Effect create: (input: CreateInput) => Effect.Effect - prompt: (input: PromptInput) => Effect.Effect + prompt: (input: PromptInput) => Effect.Effect } export class Service extends Context.Service()("Session.Service") {} diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b034777f25..4887c476dd 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -987,6 +987,266 @@ export type EventSessionDeleted = { } } +export type PromptSource = { + start: number + end: number + text: string +} + +export type PromptFileAttachment = { + uri: string + mime: string + name?: string + description?: string + source?: PromptSource +} + +export type PromptAgentAttachment = { + name: string + source?: PromptSource +} + +export type Prompt = { + text: string + files?: Array + agents?: Array +} + +export type EventSessionNextPrompted = { + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type EventSessionNextSynthetic = { + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextStepStarted = { + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + model: { + id: string + providerID: string + variant?: string + } + } +} + +export type EventSessionNextStepEnded = { + type: "session.next.step.ended" + properties: { + timestamp: number + sessionID: string + reason: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + } +} + +export type EventSessionNextTextStarted = { + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + } +} + +export type EventSessionNextTextDelta = { + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + delta: string + } +} + +export type EventSessionNextTextEnded = { + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextReasoningStarted = { + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type EventSessionNextReasoningDelta = { + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type EventSessionNextReasoningEnded = { + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type EventSessionNextToolInputStarted = { + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type EventSessionNextToolInputDelta = { + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type EventSessionNextToolInputEnded = { + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type EventSessionNextToolCalled = { + type: "session.next.tool.called" + properties: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type EventSessionNextToolProgress = { + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + callID: string + details: { + [key: string]: unknown + } + } +} + +export type EventSessionNextToolSuccess = { + type: "session.next.tool.success" + properties: { + timestamp: number + sessionID: string + callID: string + output?: string + attachments?: Array + details?: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type EventSessionNextToolError = { + type: "session.next.tool.error" + properties: { + timestamp: number + sessionID: string + callID: string + error: string + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SessionNextRetryError = { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string + } +} + +export type EventSessionNextRetried = { + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type EventSessionNextCompacted = { + type: "session.next.compacted" + properties: { + timestamp: number + sessionID: string + auto: boolean + overflow?: boolean + } +} + export type SyncEventMessageUpdated = { type: "sync" name: "message.updated.1" @@ -1104,6 +1364,304 @@ export type SyncEventSessionDeleted = { } } +export type SyncEventSessionNextPrompted = { + type: "sync" + name: "session.next.prompted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type SyncEventSessionNextSynthetic = { + type: "sync" + name: "session.next.synthetic.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextStepStarted = { + type: "sync" + name: "session.next.step.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + model: { + id: string + providerID: string + variant?: string + } + } +} + +export type SyncEventSessionNextStepEnded = { + type: "sync" + name: "session.next.step.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reason: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + } +} + +export type SyncEventSessionNextTextStarted = { + type: "sync" + name: "session.next.text.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + } +} + +export type SyncEventSessionNextTextDelta = { + type: "sync" + name: "session.next.text.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + delta: string + } +} + +export type SyncEventSessionNextTextEnded = { + type: "sync" + name: "session.next.text.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextReasoningStarted = { + type: "sync" + name: "session.next.reasoning.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type SyncEventSessionNextReasoningDelta = { + type: "sync" + name: "session.next.reasoning.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type SyncEventSessionNextReasoningEnded = { + type: "sync" + name: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type SyncEventSessionNextToolInputStarted = { + type: "sync" + name: "session.next.tool.input.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type SyncEventSessionNextToolInputDelta = { + type: "sync" + name: "session.next.tool.input.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type SyncEventSessionNextToolInputEnded = { + type: "sync" + name: "session.next.tool.input.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type SyncEventSessionNextToolCalled = { + type: "sync" + name: "session.next.tool.called.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolProgress = { + type: "sync" + name: "session.next.tool.progress.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + details: { + [key: string]: unknown + } + } +} + +export type SyncEventSessionNextToolSuccess = { + type: "sync" + name: "session.next.tool.success.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + output?: string + attachments?: Array + details?: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolError = { + type: "sync" + name: "session.next.tool.error.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + error: string + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextRetried = { + type: "sync" + name: "session.next.retried.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type SyncEventSessionNextCompacted = { + type: "sync" + name: "session.next.compacted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + auto: boolean + overflow?: boolean + } +} + export type GlobalEvent = { directory: string project?: string @@ -1156,6 +1714,25 @@ export type GlobalEvent = { | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolError + | EventSessionNextRetried + | EventSessionNextCompacted | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -1163,6 +1740,25 @@ export type GlobalEvent = { | SyncEventSessionCreated | SyncEventSessionUpdated | SyncEventSessionDeleted + | SyncEventSessionNextPrompted + | SyncEventSessionNextSynthetic + | SyncEventSessionNextStepStarted + | SyncEventSessionNextStepEnded + | SyncEventSessionNextTextStarted + | SyncEventSessionNextTextDelta + | SyncEventSessionNextTextEnded + | SyncEventSessionNextReasoningStarted + | SyncEventSessionNextReasoningDelta + | SyncEventSessionNextReasoningEnded + | SyncEventSessionNextToolInputStarted + | SyncEventSessionNextToolInputDelta + | SyncEventSessionNextToolInputEnded + | SyncEventSessionNextToolCalled + | SyncEventSessionNextToolProgress + | SyncEventSessionNextToolSuccess + | SyncEventSessionNextToolError + | SyncEventSessionNextRetried + | SyncEventSessionNextCompacted } /** @@ -2099,6 +2695,25 @@ export type Event = | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolError + | EventSessionNextRetried + | EventSessionNextCompacted export type McpStatusConnected = { status: "connected" From 8bf098cf471142c196e58f3976eb78a384721d1b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 27 Apr 2026 16:02:17 -0400 Subject: [PATCH 10/14] fix types --- packages/opencode/src/session/projectors-next.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index f5ea2dd704..ca37cea22c 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -1,5 +1,5 @@ -import { and, desc, eq } from "@/storage" -import type { Database } from "@/storage" +import { and, desc, eq } from "@/storage/db" +import type { Database } from "@/storage/db" import { SessionMessage } from "@/v2/session-message" import { SessionMessageUpdater } from "@/v2/session-message-updater" import { SessionEvent } from "@/v2/session-event" @@ -25,7 +25,11 @@ function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdate db.update(SessionMessageTable) .set({ data }) .where( - and(eq(SessionMessageTable.id, id), eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, type)), + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), ) .run() }, From b46b09450a6dd877772a740e67ca5c9672bad807 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 27 Apr 2026 16:35:13 -0400 Subject: [PATCH 11/14] tui: remove @effect/language-service and refactor tool output structure Removed the @effect/language-service dependency from packages/opencode and packages/core to simplify the build and reduce unnecessary complexity. Refactored tool output handling to use a structured content array instead of flat fields. This enables richer tool responses with mixed content types (text, files) and better structured data support for future extensibility. --- bun.lock | 3 -- packages/core/tsconfig.json | 9 +---- packages/opencode/package.json | 2 -- packages/opencode/src/session/processor.ts | 34 +++++++++---------- packages/opencode/src/v2/session-event.ts | 14 +++++--- .../src/v2/session-message-updater.ts | 14 +++++--- packages/opencode/src/v2/session-message.ts | 16 ++++++--- packages/opencode/src/v2/tool-output.ts | 18 ++++++++++ packages/opencode/tsconfig.json | 9 +---- 9 files changed, 67 insertions(+), 52 deletions(-) create mode 100644 packages/opencode/src/v2/tool-output.ts diff --git a/bun.lock b/bun.lock index d98e37cfb4..c0b2c62ffe 100644 --- a/bun.lock +++ b/bun.lock @@ -456,7 +456,6 @@ }, "devDependencies": { "@babel/core": "7.28.4", - "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/core": "workspace:*", "@opencode-ai/script": "workspace:*", @@ -1069,8 +1068,6 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], - "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.48", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.48" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-vHk/X1vgDrviGcOTHQqzm2D81TtyPE/C7Qdksg5eAdbGpnqL4Dm4lk6PzTReQ0pO1/avIvWqpxy315IURV0Ldw=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.48", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.48", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.48", "ioredis": "^5.7.0" } }, "sha512-8J6H0k9rtbp9O1QvKOyOPRcCTJ8WrR7IzZLJtYFTZ4bXVEEMCTo84h0CRpi7ccpA9t7DLqotip0NeFgiBosNKQ=="], diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index d7745d7554..fe5c4d217b 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,13 +2,6 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { - "noUncheckedIndexedAccess": false, - "plugins": [ - { - "name": "@effect/language-service", - "transform": "@effect/language-service/transform", - "namespaceImportPackages": ["effect", "@effect/*"] - } - ] + "noUncheckedIndexedAccess": false } } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c569b9b225..73097674d7 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -6,7 +6,6 @@ "license": "MIT", "private": true, "scripts": { - "prepare": "effect-language-service patch || true", "typecheck": "tsgo --noEmit", "test": "bun test --timeout 30000", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", @@ -42,7 +41,6 @@ }, "devDependencies": { "@babel/core": "7.28.4", - "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", "@opencode-ai/core": "workspace:*", diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 0efb9697ea..890af22e7d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -380,22 +380,19 @@ export const layer: Layer.Layer< SyncEvent.run(SessionEvent.Tool.Success.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, - output: value.output.output, - attachments: value.output.attachments?.map((item: MessageV2.FilePart) => ({ - uri: item.url, - mime: item.mime, - ...(item.filename ? { name: item.filename } : {}), - ...(item.source - ? { - source: { - start: item.source.text.start, - end: item.source.text.end, - text: item.source.text.value, - }, - } - : {}), - })), - details: value.output.metadata, + structured: value.output.metadata, + content: [ + { + type: "text", + text: value.output.output, + }, + ...value.output.attachments?.map((item: MessageV2.FilePart) => ({ + type: "file", + uri: item.url, + mime: item.mime, + name: item.filename, + })), + ], provider: { executed: toolCall?.part.metadata?.providerExecuted === true, }, @@ -410,7 +407,10 @@ export const layer: Layer.Layer< SyncEvent.run(SessionEvent.Tool.Error.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, - error: errorMessage(value.error), + error: { + type: "unknown", + message: errorMessage(value.error), + }, provider: { executed: toolCall?.part.metadata?.providerExecuted === true, }, diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index aa53e0b6dc..5dcc6b7e04 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -3,6 +3,7 @@ import { Event } from "./event" import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" export { FileAttachment } +import { ToolOutput } from "./tool-output" export const ID = Event.ID export type ID = Schema.Schema.Type @@ -201,7 +202,8 @@ export namespace Tool { schema: { ...Base, callID: Schema.String, - details: Schema.Record(Schema.String, Schema.Unknown), + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), }, }) export type Progress = Schema.Schema.Type @@ -212,9 +214,8 @@ export namespace Tool { schema: { ...Base, callID: Schema.String, - output: Schema.String.pipe(Schema.optional), - attachments: Schema.Array(FileAttachment).pipe(Schema.optional), - details: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), @@ -229,7 +230,10 @@ export namespace Tool { schema: { ...Base, callID: Schema.String, - error: Schema.String, + error: Schema.Struct({ + type: Schema.String, + message: Schema.String, + }), provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index 2e1735549f..ed5aac951e 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -163,6 +163,8 @@ export function update(adapter: Adapter, event: SessionEvent.Eve match.state = { status: "running", input: event.data.input, + structured: {}, + content: [], } } }), @@ -174,7 +176,10 @@ export function update(adapter: Adapter, event: SessionEvent.Eve adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestTool(draft, event.data.callID) - if (match && match.state.status === "running") match.state.details = event.data.details + if (match && match.state.status === "running") { + match.state.structured = event.data.structured + match.state.content = [...event.data.content] + } }), ) } @@ -188,9 +193,8 @@ export function update(adapter: Adapter, event: SessionEvent.Eve match.state = { status: "completed", input: match.state.input, - output: event.data.output ?? "", - details: event.data.details, - attachments: [...(event.data.attachments ?? [])], + structured: event.data.structured, + content: [...event.data.content], } } }), @@ -207,6 +211,8 @@ export function update(adapter: Adapter, event: SessionEvent.Eve status: "error", error: event.data.error, input: match.state.input, + structured: match.state.structured, + content: match.state.content, } } }), diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index dea7ee80f1..6d84c2ca8e 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -2,6 +2,7 @@ import { Schema } from "effect" import { Prompt } from "./session-prompt" import { SessionEvent } from "./session-event" import { Event } from "./event" +import { ToolOutput } from "./tool-output" export const ID = Event.ID export type ID = Schema.Schema.Type @@ -62,22 +63,27 @@ export class ToolStatePending extends Schema.Class("Session.Me export class ToolStateRunning extends Schema.Class("Session.Message.ToolState.Running")({ status: Schema.Literal("running"), input: Schema.Record(Schema.String, Schema.Unknown), - details: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + structured: ToolOutput.Structured, + content: ToolOutput.Content.pipe(Schema.Array), }) {} export class ToolStateCompleted extends Schema.Class("Session.Message.ToolState.Completed")({ status: Schema.Literal("completed"), input: Schema.Record(Schema.String, Schema.Unknown), - output: Schema.String, - details: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, }) {} export class ToolStateError extends Schema.Class("Session.Message.ToolState.Error")({ status: Schema.Literal("error"), input: Schema.Record(Schema.String, Schema.Unknown), - error: Schema.String, - details: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, + error: Schema.Struct({ + type: Schema.String, + message: Schema.String, + }), }) {} export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( diff --git a/packages/opencode/src/v2/tool-output.ts b/packages/opencode/src/v2/tool-output.ts new file mode 100644 index 0000000000..dee2bb11ed --- /dev/null +++ b/packages/opencode/src/v2/tool-output.ts @@ -0,0 +1,18 @@ +export * as ToolOutput from "./tool-output" +import { Schema } from "effect" + +export class TextContent extends Schema.Class("Tool.TextContent")({ + type: Schema.Literal("text"), + text: Schema.String, +}) {} + +export class FileContent extends Schema.Class("Tool.FileContent")({ + type: Schema.Literal("file"), + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), +}) {} + +export const Content = Schema.Union([TextContent, FileContent]).pipe(Schema.toTaggedUnion("type")) + +export const Structured = Schema.Record(Schema.String, Schema.Any) diff --git a/packages/opencode/tsconfig.json b/packages/opencode/tsconfig.json index 5cb51012ae..f09fca6878 100644 --- a/packages/opencode/tsconfig.json +++ b/packages/opencode/tsconfig.json @@ -12,13 +12,6 @@ "@/*": ["./src/*"], "@tui/*": ["./src/cli/cmd/tui/*"], "@test/*": ["./test/*"] - }, - "plugins": [ - { - "name": "@effect/language-service", - "transform": "@effect/language-service/transform", - "namespaceImportPackages": ["effect", "@effect/*"] - } - ] + } } } From f5abbfabbf5e7454e0ab622a76c26148e3bcf23d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 27 Apr 2026 16:46:19 -0400 Subject: [PATCH 12/14] core: fix crash when message attachments array is undefined Prevents runtime errors by ensuring undefined attachments are converted to an empty array before spreading into the message parts. This fixes scenarios where tool responses without attachments would cause the session processor to fail. --- packages/opencode/src/session/processor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 890af22e7d..f8ce1243fd 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -386,12 +386,12 @@ export const layer: Layer.Layer< type: "text", text: value.output.output, }, - ...value.output.attachments?.map((item: MessageV2.FilePart) => ({ + ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({ type: "file", uri: item.url, mime: item.mime, name: item.filename, - })), + })) ?? []), ], provider: { executed: toolCall?.part.metadata?.providerExecuted === true, From 0a6bc6067e113351865021968de9d580119de391 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 27 Apr 2026 16:53:43 -0400 Subject: [PATCH 13/14] core: capture codebase snapshots before and after each AI step for review --- packages/opencode/src/session/processor.ts | 5 ++- packages/opencode/src/v2/session-event.ts | 2 + .../src/v2/session-message-updater.ts | 1 + packages/opencode/src/v2/session-message.ts | 5 +++ .../test/v2/session-message-updater.test.ts | 41 +++++++++++++++++++ 5 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/v2/session-message-updater.test.ts diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f8ce1243fd..e388658d4d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -432,6 +432,7 @@ export const layer: Layer.Layer< providerID: ctx.model.providerID, variant: input.assistantMessage.variant, }, + snapshot: ctx.snapshot, timestamp: DateTime.makeUnsafe(Date.now()), }) yield* session.updatePart({ @@ -444,6 +445,7 @@ export const layer: Layer.Layer< return case "finish-step": { + const completedSnapshot = yield* snapshot.track() const usage = Session.getUsage({ model: ctx.model, usage: value.usage, @@ -454,6 +456,7 @@ export const layer: Layer.Layer< reason: value.finishReason, cost: usage.cost, tokens: usage.tokens, + snapshot: completedSnapshot, timestamp: DateTime.makeUnsafe(Date.now()), }) ctx.assistantMessage.finish = value.finishReason @@ -462,7 +465,7 @@ export const layer: Layer.Layer< yield* session.updatePart({ id: PartID.ascending(), reason: value.finishReason, - snapshot: yield* snapshot.track(), + snapshot: completedSnapshot, messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, type: "step-finish", diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 5dcc6b7e04..ad3c81913b 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -54,6 +54,7 @@ export namespace Step { providerID: Schema.String, variant: Schema.String.pipe(Schema.optional), }), + snapshot: Schema.String.pipe(Schema.optional), }, }) export type Started = Schema.Schema.Type @@ -74,6 +75,7 @@ export namespace Step { write: Schema.Number, }), }), + snapshot: Schema.String.pipe(Schema.optional), }, }) export type Ended = Schema.Schema.Type diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index ed5aac951e..36b899a742 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -94,6 +94,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve draft.time.completed = event.data.timestamp draft.cost = event.data.cost draft.tokens = event.data.tokens + if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot } }), ) } diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 6d84c2ca8e..0556818a09 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -152,6 +152,10 @@ export class Assistant extends Schema.Class("Session.Message.Assistan type: Schema.Literal("assistant"), content: AssistantContent.pipe(Schema.Array), retries: AssistantRetry.pipe(Schema.Array, Schema.optional), + snapshot: Schema.Struct({ + start: Schema.String.pipe(Schema.optional), + end: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional), cost: Schema.Number.pipe(Schema.optional), tokens: Schema.Struct({ input: Schema.Number, @@ -177,6 +181,7 @@ export class Assistant extends Schema.Class("Session.Message.Assistan }, content: [], retries: [], + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, }) } } diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts new file mode 100644 index 0000000000..d89c59962b --- /dev/null +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test" +import * as DateTime from "effect/DateTime" +import { SessionID } from "../../src/session/schema" +import { SessionEvent } from "../../src/v2/session-event" +import { SessionMessageUpdater } from "../../src/v2/session-message-updater" + +test("step snapshots carry over to assistant messages", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [], pending: [] } + const sessionID = SessionID.make("session") + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + model: { id: "model", providerID: "provider" }, + snapshot: "before", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + type: "session.next.step.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + reason: "stop", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + snapshot: "after", + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].snapshot).toEqual({ start: "before", end: "after" }) +}) From 865d7aba09850cf38494425f53a95c8a6730563e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 27 Apr 2026 17:09:08 -0400 Subject: [PATCH 14/14] core: remove retry tracking from session messages to prevent noisy retry details from appearing in the conversation history --- .../src/v2/session-message-updater.ts | 10 +------ packages/opencode/src/v2/session-message.ts | 29 ------------------- 2 files changed, 1 insertion(+), 38 deletions(-) diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index 36b899a742..69dd04e959 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -253,15 +253,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) } }, - "session.next.retried": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.retries = [...(draft.retries ?? []), SessionMessage.AssistantRetry.fromEvent(event)] - }), - ) - } - }, + "session.next.retried": () => {}, "session.next.compacted": (event) => { adapter.appendMessage(SessionMessage.Compaction.fromEvent(event)) }, diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 0556818a09..01a02e60dc 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -115,43 +115,15 @@ export class AssistantReasoning extends Schema.Class("Sessio text: Schema.String, }) {} -export class AssistantRetry extends Schema.Class("Session.Message.Assistant.Retry")({ - attempt: Schema.Number, - error: SessionEvent.RetryError, - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -}) { - static fromEvent(event: SessionEvent.Retried) { - return new AssistantRetry({ - attempt: event.data.attempt, - error: event.data.error, - time: { - created: event.data.timestamp, - }, - }) - } -} - export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( Schema.toTaggedUnion("type"), ) export type AssistantContent = Schema.Schema.Type -// GET /v2/session/{sessionID}/message?limit=10 -// user -// synthetic -// synthetic -// assistant HTTP req/retried 5 times/response -// compaction -// assistant -// user - export class Assistant extends Schema.Class("Session.Message.Assistant")({ ...Base, type: Schema.Literal("assistant"), content: AssistantContent.pipe(Schema.Array), - retries: AssistantRetry.pipe(Schema.Array, Schema.optional), snapshot: Schema.Struct({ start: Schema.String.pipe(Schema.optional), end: Schema.String.pipe(Schema.optional), @@ -180,7 +152,6 @@ export class Assistant extends Schema.Class("Session.Message.Assistan created: event.data.timestamp, }, content: [], - retries: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, }) }