Improve v2 session message rendering (#25634)

This commit is contained in:
Dax
2026-05-04 22:35:21 -04:00
committed by GitHub
parent 0df2bb0f3b
commit 39c88f9afb
17 changed files with 677 additions and 275 deletions

View File

@@ -71,6 +71,8 @@ export const layer = Layer.effect(
Effect.sync(() => Service.of(make())), Effect.sync(() => Service.of(make())),
) )
export const defaultLayer = layer
export const layerWith = (input: Partial<Interface>) => export const layerWith = (input: Partial<Interface>) =>
Layer.effect( Layer.effect(
Service, Service,

View File

@@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
function activeAssistant(messages: SessionMessage[]) { function activeAssistant(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
if (index < 0) return if (index < 0) return
const assistant = messages[index] const assistant = messages[index]
return assistant?.type === "assistant" ? assistant : undefined return assistant?.type === "assistant" ? assistant : undefined
} }
function activeCompaction(messages: SessionMessage[]) { function activeCompaction(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "compaction") const index = messages.findIndex((message) => message.type === "compaction")
if (index < 0) return if (index < 0) return
const compaction = messages[index] const compaction = messages[index]
return compaction?.type === "compaction" ? compaction : undefined return compaction?.type === "compaction" ? compaction : undefined
} }
function activeShell(messages: SessionMessage[], callID: string) { function activeShell(messages: SessionMessage[], callID: string) {
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
if (index < 0) return if (index < 0) return
const shell = messages[index] const shell = messages[index]
return shell?.type === "shell" ? shell : undefined return shell?.type === "shell" ? shell : undefined
@@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
switch (event.type) { switch (event.type) {
case "session.next.prompted": { case "session.next.prompted": {
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.push({ draft.unshift({
id: event.id, id: event.id,
type: "user", type: "user",
text: event.properties.prompt.text, text: event.properties.prompt.text,
@@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
} }
case "session.next.synthetic": case "session.next.synthetic":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.push({ draft.unshift({
id: event.id, id: event.id,
type: "synthetic", type: "synthetic",
sessionID: event.properties.sessionID, sessionID: event.properties.sessionID,
@@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.shell.started": case "session.next.shell.started":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.push({ draft.unshift({
id: event.id, id: event.id,
type: "shell", type: "shell",
callID: event.properties.callID, callID: event.properties.callID,
@@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
const currentAssistant = activeAssistant(draft) const currentAssistant = activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
draft.push({ draft.unshift({
id: event.id, id: event.id,
type: "assistant", type: "assistant",
agent: event.properties.agent, agent: event.properties.agent,
@@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.compaction.started": case "session.next.compaction.started":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.push({ draft.unshift({
id: event.id, id: event.id,
type: "compaction", type: "compaction",
reason: event.properties.reason, reason: event.properties.reason,

View File

@@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { SyntaxStyle } from "@opentui/core" import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import path from "path" import path from "path"
@@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
const renderedMessages = createMemo(() => messages().toReversed()) const renderedMessages = createMemo(() => messages().toReversed())
const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
const lastUserCreated = (index: number) =>
renderedMessages()
.slice(0, index)
.findLast((message) => message.type === "user")?.time.created
createEffect(() => { createEffect(() => {
void sync.session.message.sync(props.sessionID) void sync.session.message.sync(props.sessionID)
@@ -83,10 +87,11 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
last={lastAssistant()?.id === message.id} last={lastAssistant()?.id === message.id}
syntax={syntax()} syntax={syntax()}
subtleSyntax={subtleSyntax()} subtleSyntax={subtleSyntax()}
start={lastUserCreated(index())}
/> />
</Match> </Match>
<Match when={message.type === "synthetic"}> <Match when={message.type === "synthetic"}>
<SyntheticMessage message={message as SessionMessageSynthetic} index={index()} /> <></>
</Match> </Match>
<Match when={message.type === "shell"}> <Match when={message.type === "shell"}>
<ShellMessage message={message as SessionMessageShell} /> <ShellMessage message={message as SessionMessageShell} />
@@ -146,63 +151,36 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
<box <box
id={props.message.id} id={props.message.id}
border={["left"]} border={["left"]}
borderColor={theme.primary} borderColor={theme.secondary}
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1} marginTop={props.index === 0 ? 0 : 1}
flexShrink={0} flexShrink={0}
>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={theme.backgroundPanel}>
<Show
when={props.message.text.trim()}
fallback={
<MissingData label="User message text" detail={`Message ${props.message.id} has no text field content.`} />
}
>
<text fg={theme.text}>{props.message.text}</text>
</Show>
<Show when={attachments().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={props.message.files ?? []}>
{(file) => (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
</text>
)}
</For>
<For each={props.message.agents ?? []}>
{(agent) => (
<text fg={theme.text}>
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
</text>
)}
</For>
</box>
</Show>
<text fg={theme.textMuted}>{Locale.todayTimeOrDateTime(props.message.time.created)}</text>
</box>
</box>
)
}
function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) {
const { theme } = useTheme()
return (
<box
id={props.message.id}
border={["left"]}
borderColor={theme.backgroundElement}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
paddingLeft={2}
paddingTop={1} paddingTop={1}
paddingBottom={1} paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.backgroundPanel} backgroundColor={theme.backgroundPanel}
flexShrink={0}
> >
<text fg={theme.textMuted}>Synthetic</text>
<text fg={theme.text}>{props.message.text}</text> <text fg={theme.text}>{props.message.text}</text>
<Show when={attachments().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={props.message.files ?? []}>
{(file) => (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
</text>
)}
</For>
<For each={props.message.agents ?? []}>
{(agent) => (
<text fg={theme.text}>
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
</text>
)}
</For>
</box>
</Show>
</box> </box>
) )
} }
@@ -237,7 +215,7 @@ function ShellMessage(props: { message: SessionMessageShell }) {
} }
function CompactionMessage(props: { message: SessionMessageCompaction }) { function CompactionMessage(props: { message: SessionMessageCompaction }) {
const { theme } = useTheme() const { theme, syntax } = useTheme()
return ( return (
<box <box
marginTop={1} marginTop={1}
@@ -248,7 +226,19 @@ function CompactionMessage(props: { message: SessionMessageCompaction }) {
flexShrink={0} flexShrink={0}
> >
<Show when={props.message.summary}> <Show when={props.message.summary}>
<text fg={theme.textMuted}>{props.message.summary}</text> {(summary) => (
<box paddingLeft={3} paddingTop={1}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={false}
syntaxStyle={syntax()}
content={summary().trim()}
conceal={true}
fg={theme.text}
/>
</box>
)}
</Show> </Show>
</box> </box>
) )
@@ -294,12 +284,13 @@ function AssistantMessage(props: {
last: boolean last: boolean
syntax: SyntaxStyle syntax: SyntaxStyle
subtleSyntax: SyntaxStyle subtleSyntax: SyntaxStyle
start?: number
}) { }) {
const { theme } = useTheme() const { theme } = useTheme()
const local = useLocal() const local = useLocal()
const duration = createMemo(() => { const duration = createMemo(() => {
if (!props.message.time.completed) return 0 if (!props.message.time.completed) return 0
return props.message.time.completed - props.message.time.created return props.message.time.completed - (props.start ?? props.message.time.created)
}) })
const model = createMemo(() => { const model = createMemo(() => {
const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
@@ -361,7 +352,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
const { theme } = useTheme() const { theme } = useTheme()
return ( return (
<Show when={props.part.text.trim()}> <Show when={props.part.text.trim()}>
<box paddingLeft={3} marginTop={1} flexShrink={0}> <box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
<code <code
filetype="markdown" filetype="markdown"
drawUnstyledText={false} drawUnstyledText={false}
@@ -521,33 +512,93 @@ function InlineTool(props: {
part: SessionMessageAssistantTool part: SessionMessageAssistantTool
}) { }) {
const { theme } = useTheme() const { theme } = useTheme()
const renderer = useRenderer()
const [margin, setMargin] = createSignal(0)
const [hover, setHover] = createSignal(false)
const [showError, setShowError] = createSignal(false)
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
const complete = createMemo(() => !!props.complete)
const denied = createMemo(() => { const denied = createMemo(() => {
const message = error() const message = error()
if (!message) return false if (!message) return false
return ( return (
message.includes("QuestionRejectedError") || message.includes("QuestionRejectedError") ||
message.includes("rejected permission") || message.includes("rejected permission") ||
message.includes("specified a rule") ||
message.includes("user dismissed") message.includes("user dismissed")
) )
}) })
const fg = createMemo(() => {
if (error()) return theme.error
if (complete()) return theme.textMuted
return theme.text
})
const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
return ( return (
<box marginTop={1} paddingLeft={3} flexShrink={0}> <box
<Switch> marginTop={margin()}
<Match when={props.spinner}> paddingLeft={3}
<Spinner color={theme.text}>{props.children}</Spinner> flexShrink={0}
</Match> flexDirection="row"
<Match when={true}> gap={1}
<text paddingLeft={3} fg={props.complete ? theme.textMuted : theme.text}> backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
<Show fallback={<>~ {props.pending}</>} when={props.complete}> onMouseOver={() => error() && setHover(true)}
{props.icon} {props.children} onMouseOut={() => setHover(false)}
</Show> onMouseUp={() => {
</text> if (!error()) return
</Match> if (renderer.getSelection()?.getSelectedText()) return
</Switch> setShowError((prev) => !prev)
<Show when={error() && !denied()}> }}
<text fg={theme.error}>{error()}</text> renderBefore={function () {
</Show> const el = this as BoxRenderable
const parent = el.parent
if (!parent) return
const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
if (!previous) {
setMargin(0)
return
}
if (previous.id.startsWith("text")) setMargin(1)
}}
>
<box flexShrink={0}>
<Switch>
<Match when={props.spinner}>
<Spinner color={theme.text} />
</Match>
<Match when={complete()}>
<text fg={fg()} attributes={attributes()}>
{props.icon}
</text>
</Match>
<Match when={true}>
<text fg={fg()} attributes={attributes()}>
~
</text>
</Match>
</Switch>
</box>
<box flexGrow={1}>
<box>
<Switch>
<Match when={complete()}>
<text fg={fg()} attributes={attributes()}>
{props.children}
</text>
</Match>
<Match when={true}>
<text fg={fg()} attributes={attributes()}>
{props.pending}
</text>
</Match>
</Switch>
</box>
<Show when={showError() && error()}>
<box>
<text fg={theme.error}>{error()}</text>
</box>
</Show>
</box>
</box> </box>
) )
} }

View File

@@ -13,6 +13,7 @@ const prefixes = {
tool: "tool", tool: "tool",
workspace: "wrk", workspace: "wrk",
entry: "ent", entry: "ent",
account: "act",
} as const } as const
export function schema(prefix: keyof typeof prefixes) { export function schema(prefix: keyof typeof prefixes) {

View File

@@ -22,6 +22,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { EventV2 } from "@/v2/event" import { EventV2 } from "@/v2/event"
import { SessionEvent } from "@/v2/session-event" import { SessionEvent } from "@/v2/session-event"
import { Modelv2 } from "@/v2/model"
import * as DateTime from "effect/DateTime" import * as DateTime from "effect/DateTime"
const DOOM_LOOP_THRESHOLD = 3 const DOOM_LOOP_THRESHOLD = 3
@@ -432,9 +433,9 @@ export const layer: Layer.Layer<
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
agent: input.assistantMessage.agent, agent: input.assistantMessage.agent,
model: { model: {
id: ctx.model.id, id: Modelv2.ID.make(ctx.model.id),
providerID: ctx.model.providerID, providerID: Modelv2.ProviderID.make(ctx.model.providerID),
variant: input.assistantMessage.variant, variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"),
}, },
snapshot: ctx.snapshot, snapshot: ctx.snapshot,
timestamp: DateTime.makeUnsafe(Date.now()), timestamp: DateTime.makeUnsafe(Date.now()),
@@ -655,7 +656,7 @@ export const layer: Layer.Layer<
EventV2.run(SessionEvent.Step.Failed.Sync, { EventV2.run(SessionEvent.Step.Failed.Sync, {
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
error: { error: {
type: error.name, type: "unknown",
message: errorMessage(e), message: errorMessage(e),
}, },
timestamp: DateTime.makeUnsafe(Date.now()), timestamp: DateTime.makeUnsafe(Date.now()),

View File

@@ -132,11 +132,7 @@ export default [
SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => { SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => {
db.update(SessionTable) db.update(SessionTable)
.set({ .set({
model: { model: data.model,
id: data.id,
providerID: data.providerID,
variant: data.variant,
},
time_updated: DateTime.toEpochMillis(data.timestamp), time_updated: DateTime.toEpochMillis(data.timestamp),
}) })
.where(eq(SessionTable.id, data.sessionID)) .where(eq(SessionTable.id, data.sessionID))

View File

@@ -56,6 +56,7 @@ import { SessionRunState } from "./run-state"
import { EffectBridge } from "@/effect/bridge" import { EffectBridge } from "@/effect/bridge"
import { EventV2 } from "@/v2/event" import { EventV2 } from "@/v2/event"
import { SessionEvent } from "@/v2/session-event" import { SessionEvent } from "@/v2/session-event"
import { Modelv2 } from "@/v2/model"
import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
import * as DateTime from "effect/DateTime" import * as DateTime from "effect/DateTime"
import { eq } from "@/storage/db" import { eq } from "@/storage/db"
@@ -978,9 +979,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
EventV2.run(SessionEvent.ModelSwitched.Sync, { EventV2.run(SessionEvent.ModelSwitched.Sync, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(info.time.created), timestamp: DateTime.makeUnsafe(info.time.created),
id: info.model.modelID, model: {
providerID: info.model.providerID, id: Modelv2.ID.make(info.model.modelID),
variant: info.model.variant, providerID: Modelv2.ProviderID.make(info.model.providerID),
variant: Modelv2.VariantID.make(info.model.variant ?? "default"),
},
}) })
} }

View File

@@ -0,0 +1,246 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "@opencode-ai/core/util/identifier"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const AccountID = Schema.String.pipe(
Schema.brand("AccountID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type AccountID = typeof AccountID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AuthV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AuthV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AuthV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Account extends Schema.Class<Account>("AuthV2.Account")({
id: AccountID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class AuthFileWriteError extends Schema.TaggedErrorClass<AuthFileWriteError>()("AuthV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type AuthError = AuthFileWriteError
interface Writable {
version: 2
accounts: Record<string, Account>
active: Record<string, AccountID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Account> = {}
const active: Record<string, AccountID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const accountID = AccountID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Account({
id: accountID,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = accountID
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (accountID: AccountID) => Effect.Effect<Account | undefined, AuthError>
readonly all: () => Effect.Effect<Account[], AuthError>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
active?: boolean
}) => Effect.Effect<Account, AuthError>
readonly update: (
accountID: AccountID,
updates: Partial<Pick<Account, "description" | "credential">>,
) => Effect.Effect<void, AuthError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly activate: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly active: (serviceID: ServiceID) => Effect.Effect<Account | undefined, AuthError>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Account[], AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const file = path.join(global.data, "auth-v2.json")
const load: () => Effect.Effect<Writable, AuthError> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
} catch {}
}
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} }
if ("version" in raw && raw.version === 2) return raw as Writable
const migrated = migrate(raw as Record<string, unknown>)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
return migrated
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(yield* load())
const result: Interface = {
get: Effect.fn("AuthV2.get")(function* (accountID) {
return (yield* SynchronizedRef.get(state)).accounts[accountID]
}),
all: Effect.fn("AuthV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AuthV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AuthV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AuthV2.add")(function* (input) {
return yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = new Account({
id: AccountID.make(Identifier.ascending()),
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active:
(input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID))
? { ...data.active, [input.serviceID]: account.id }
: data.active,
}
yield* write(next)
return [account, next] as const
}),
)
}),
update: Effect.fn("AuthV2.update")(function* (accountID, updates) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const existing = data.accounts[accountID]
if (!existing) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[accountID]: new Account({
id: accountID,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AuthV2.remove")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID)
delete active[accounts[accountID].serviceID]
delete accounts[accountID]
const next = { ...data, accounts, active }
yield* write(next)
return [undefined, next] as const
}),
)
}),
activate: Effect.fn("AuthV2.activate")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = data.accounts[accountID]
if (!account) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } }
yield* write(next)
return [undefined, next] as const
}),
)
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
export * as AuthV2 from "./auth"

View File

@@ -0,0 +1,192 @@
import { withStatics } from "@/util/schema"
import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
export const ID = Schema.String.pipe(Schema.brand("Model.ID"))
export type ID = typeof ID.Type
export const ProviderID = Schema.String.pipe(
Schema.brand("Model.ProviderID"),
withStatics((schema) => ({
// Well-known providers
opencode: schema.make("opencode"),
anthropic: schema.make("anthropic"),
openai: schema.make("openai"),
google: schema.make("google"),
googleVertex: schema.make("google-vertex"),
githubCopilot: schema.make("github-copilot"),
amazonBedrock: schema.make("amazon-bedrock"),
azure: schema.make("azure"),
openrouter: schema.make("openrouter"),
mistral: schema.make("mistral"),
gitlab: schema.make("gitlab"),
})),
)
export type ProviderID = typeof ProviderID.Type
export const VariantID = Schema.String.pipe(Schema.brand("VariantID"))
export type VariantID = typeof VariantID.Type
// Grouping of models, eg claude opus, claude sonnet
export const Family = Schema.String.pipe(Schema.brand("Family"))
export type Family = typeof Family.Type
const OpenAIResponses = Schema.Struct({
type: Schema.Literal("openai/responses"),
url: Schema.String,
websocket: Schema.optional(Schema.Boolean),
})
const OpenAICompletions = Schema.Struct({
type: Schema.Literal("openai/completions"),
url: Schema.String,
reasoning: Schema.Union([
Schema.Struct({
type: Schema.Literal("reasoning_content"),
}),
Schema.Struct({
type: Schema.Literal("reasoning_details"),
}),
]).pipe(Schema.optional),
})
export type OpenAICompletions = typeof OpenAICompletions.Type
const AnthropicMessages = Schema.Struct({
type: Schema.Literal("anthropic/messages"),
url: Schema.String,
})
export const Endpoint = Schema.Union([OpenAIResponses, OpenAICompletions, AnthropicMessages]).pipe(
Schema.toTaggedUnion("type"),
)
export type Endpoint = typeof Endpoint.Type
export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
// mime patterns, image, audio, video/*, text/*
input: Schema.String.pipe(Schema.Array),
output: Schema.String.pipe(Schema.Array),
})
export type Capabilities = typeof Capabilities.Type
export const Options = Schema.Struct({
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.Record(Schema.String, Schema.Any),
})
export type Options = typeof Options.Type
export const Cost = Schema.Struct({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(Schema.optional),
input: Schema.Finite,
output: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
export const Ref = Schema.Struct({
id: ID,
providerID: ProviderID,
variant: VariantID,
})
export type Ref = typeof Ref.Type
export class Info extends Schema.Class<Info>("Model.Info")({
id: ID,
providerID: ProviderID,
family: Family.pipe(Schema.optional),
name: Schema.String,
endpoint: Endpoint,
capabilities: Capabilities,
options: Schema.Struct({
...Options.fields,
variant: Schema.String.pipe(Schema.optional),
}),
variants: Schema.Struct({
id: VariantID,
...Options.fields,
}).pipe(Schema.Array),
time: Schema.Struct({
released: DateTimeUtcFromMillis,
}),
cost: Cost.pipe(Schema.Array),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
limit: Schema.Struct({
context: Schema.Int,
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int,
}),
}) {}
export function parse(input: string): { providerID: ProviderID; modelID: ID } {
const [providerID, ...modelID] = input.split("/")
return {
providerID: ProviderID.make(providerID),
modelID: ID.make(modelID.join("/")),
}
}
export interface Interface {
readonly get: (providerID: ProviderID, modelID: ID) => Effect.Effect<Option.Option<Info>>
readonly add: (model: Info) => Effect.Effect<void>
readonly remove: (providerID: ProviderID, modelID: ID) => Effect.Effect<void>
readonly all: () => Effect.Effect<Info[]>
readonly default: () => Effect.Effect<Option.Option<Info>>
readonly small: (provider: ProviderID) => Effect.Effect<Option.Option<Info>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Model") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
let models = HashMap.empty<string, Info>()
function key(providerID: ProviderID, modelID: ID) {
return `${providerID}/${modelID}`
}
const result: Interface = {
get: Effect.fn("V2Model.get")(function* (providerID, modelID) {
return HashMap.get(models, key(providerID, modelID))
}),
add: Effect.fn("V2Model.add")(function* (model) {
models = HashMap.set(models, key(model.providerID, model.id), model)
}),
remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) {
models = HashMap.remove(models, key(providerID, modelID))
}),
all: Effect.fn("V2Model.all")(function* () {
return pipe(
models,
HashMap.toValues,
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
}),
default: Effect.fn("V2Model.default")(function* () {
const all = yield* result.all()
return Option.fromUndefinedOr(all[0])
}),
small: Effect.fn("V2Model.small")(function* (providerID) {
const all = yield* result.all()
const match = all.find((model) => model.providerID === providerID && model.id.toLowerCase().includes("small"))
return Option.fromUndefinedOr(match)
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer
export * as Modelv2 from "./model"

View File

@@ -5,8 +5,8 @@ import { FileAttachment, Prompt } from "./session-prompt"
import { Schema } from "effect" import { Schema } from "effect"
export { FileAttachment } export { FileAttachment }
import { ToolOutput } from "./tool-output" import { ToolOutput } from "./tool-output"
import { ModelID, ProviderID } from "@/provider/schema"
import { V2Schema } from "./schema" import { V2Schema } from "./schema"
import { Modelv2 } from "./model"
export const Source = Schema.Struct({ export const Source = Schema.Struct({
start: NonNegativeInt, start: NonNegativeInt,
@@ -22,10 +22,13 @@ const Base = {
sessionID: SessionID, sessionID: SessionID,
} }
const Error = Schema.Struct({ export const UnknownError = Schema.Struct({
type: Schema.String, type: Schema.Literal("unknown"),
message: Schema.String, message: Schema.String,
}).annotate({
identifier: "Session.Error.Unknown",
}) })
export type UnknownError = Schema.Schema.Type<typeof UnknownError>
export const AgentSwitched = EventV2.define({ export const AgentSwitched = EventV2.define({
type: "session.next.agent.switched", type: "session.next.agent.switched",
@@ -44,9 +47,7 @@ export const ModelSwitched = EventV2.define({
version: 1, version: 1,
schema: { schema: {
...Base, ...Base,
id: ModelID, model: Modelv2.Ref,
providerID: ProviderID,
variant: Schema.String.pipe(Schema.optional),
}, },
}) })
export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched> export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched>
@@ -103,11 +104,7 @@ export namespace Step {
schema: { schema: {
...Base, ...Base,
agent: Schema.String, agent: Schema.String,
model: Schema.Struct({ model: Modelv2.Ref,
id: Schema.String,
providerID: Schema.String,
variant: Schema.String.pipe(Schema.optional),
}),
snapshot: Schema.String.pipe(Schema.optional), snapshot: Schema.String.pipe(Schema.optional),
}, },
}) })
@@ -139,7 +136,7 @@ export namespace Step {
aggregate: "sessionID", aggregate: "sessionID",
schema: { schema: {
...Base, ...Base,
error: Error, error: UnknownError,
}, },
}) })
export type Failed = Schema.Schema.Type<typeof Failed> export type Failed = Schema.Schema.Type<typeof Failed>
@@ -296,7 +293,7 @@ export namespace Tool {
schema: { schema: {
...Base, ...Base,
callID: Schema.String, callID: Schema.String,
error: Error, error: UnknownError,
provider: Schema.Struct({ provider: Schema.Struct({
executed: Schema.Boolean, executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),

View File

@@ -109,11 +109,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
id: event.id, id: event.id,
type: "model-switched", type: "model-switched",
metadata: event.metadata, metadata: event.metadata,
model: { model: event.data.model,
id: event.data.id,
providerID: event.data.providerID,
variant: event.data.variant,
},
time: { created: event.data.timestamp }, time: { created: event.data.timestamp },
}), }),
) )

View File

@@ -4,6 +4,7 @@ import { SessionEvent } from "./session-event"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { ToolOutput } from "./tool-output" import { ToolOutput } from "./tool-output"
import { V2Schema } from "./schema" import { V2Schema } from "./schema"
import { Modelv2 } from "./model"
export const ID = EventV2.ID export const ID = EventV2.ID
export type ID = Schema.Schema.Type<typeof ID> export type ID = Schema.Schema.Type<typeof ID>
@@ -25,11 +26,7 @@ export class AgentSwitched extends Schema.Class<AgentSwitched>("Session.Message.
export class ModelSwitched extends Schema.Class<ModelSwitched>("Session.Message.ModelSwitched")({ export class ModelSwitched extends Schema.Class<ModelSwitched>("Session.Message.ModelSwitched")({
...Base, ...Base,
type: Schema.Literal("model-switched"), type: Schema.Literal("model-switched"),
model: Schema.Struct({ model: Modelv2.Ref,
id: SessionEvent.ModelSwitched.fields.data.fields.id,
providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID,
variant: SessionEvent.ModelSwitched.fields.data.fields.variant,
}),
}) {} }) {}
export class User extends Schema.Class<User>("Session.Message.User")({ export class User extends Schema.Class<User>("Session.Message.User")({
@@ -87,10 +84,7 @@ export class ToolStateError extends Schema.Class<ToolStateError>("Session.Messag
input: Schema.Record(Schema.String, Schema.Unknown), input: Schema.Record(Schema.String, Schema.Unknown),
content: ToolOutput.Content.pipe(Schema.Array), content: ToolOutput.Content.pipe(Schema.Array),
structured: ToolOutput.Structured, structured: ToolOutput.Structured,
error: Schema.Struct({ error: SessionEvent.UnknownError,
type: Schema.String,
message: Schema.String,
}),
}) {} }) {}
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(

View File

@@ -3,17 +3,17 @@ import { SessionID } from "@/session/schema"
import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceID } from "@/control-plane/schema"
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
import * as Database from "@/storage/db" import * as Database from "@/storage/db"
import { Context, DateTime, Effect, Layer, Schema } from "effect" import { Context, DateTime, Effect, Layer, Option, Schema } from "effect"
import { SessionMessage } from "./session-message" import { SessionMessage } from "./session-message"
import type { Prompt } from "./session-prompt" import type { Prompt } from "./session-prompt"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionEvent } from "./session-event" import { SessionEvent } from "./session-event"
import { V2Schema } from "./schema" import { V2Schema } from "./schema"
import { optionalOmitUndefined } from "@/util/schema" import { optionalOmitUndefined } from "@/util/schema"
import { Modelv2 } from "./model"
export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
identifier: "Session.Delivery", identifier: "Session.Delivery",
}) })
export type Delivery = Schema.Schema.Type<typeof Delivery> export type Delivery = Schema.Schema.Type<typeof Delivery>
@@ -27,11 +27,7 @@ export class Info extends Schema.Class<Info>("Session.Info")({
workspaceID: optionalOmitUndefined(WorkspaceID), workspaceID: optionalOmitUndefined(WorkspaceID),
path: optionalOmitUndefined(Schema.String), path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String),
model: Schema.Struct({ model: Modelv2.Ref.pipe(optionalOmitUndefined),
id: ModelID,
providerID: ProviderID,
variant: optionalOmitUndefined(Schema.String),
}).pipe(optionalOmitUndefined),
time: Schema.Struct({ time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis, created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis,
@@ -53,7 +49,18 @@ export class Info extends Schema.Class<Info>("Session.Info")({
*/ */
}) {} }) {}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
sessionID: SessionID,
}) {}
export interface Interface { export interface Interface {
readonly create: (input?: {
agent?: string
model?: Modelv2.Ref
parentID?: SessionID
workspaceID?: WorkspaceID
}) => Effect.Effect<Info>
readonly get: (sessionID: SessionID) => Effect.Effect<Info, NotFoundError>
readonly list: (input: { readonly list: (input: {
limit?: number limit?: number
order?: "asc" | "desc" order?: "asc" | "desc"
@@ -88,13 +95,15 @@ export interface Interface {
}) => Effect.Effect<SessionMessage.User, never> }) => Effect.Effect<SessionMessage.User, never>
readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never> readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never>
readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never> readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
readonly subagent: (input: {
id?: EventV2.ID
parentID: SessionID
prompt: Prompt
agent: string
model?: Modelv2.Ref
}) => Effect.Effect<void, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never> readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
readonly switchModel: (input: { readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => Effect.Effect<void, never>
sessionID: SessionID
id: ModelID
providerID: ProviderID
variant?: string
}) => Effect.Effect<void, never>
readonly compact: (sessionID: SessionID) => Effect.Effect<void, never> readonly compact: (sessionID: SessionID) => Effect.Effect<void, never>
readonly wait: (sessionID: SessionID) => Effect.Effect<void, never> readonly wait: (sessionID: SessionID) => Effect.Effect<void, never>
} }
@@ -120,9 +129,9 @@ export const layer = Layer.effect(
agent: row.agent ?? undefined, agent: row.agent ?? undefined,
model: row.model model: row.model
? { ? {
id: ModelID.make(row.model.id), id: Modelv2.ID.make(row.model.id),
providerID: ProviderID.make(row.model.providerID), providerID: Modelv2.ProviderID.make(row.model.providerID),
variant: row.model.variant, variant: Modelv2.VariantID.make(row.model.variant ?? "default"),
} }
: undefined, : undefined,
time: { time: {
@@ -134,6 +143,14 @@ export const layer = Layer.effect(
} }
const result: Interface = { const result: Interface = {
create: Effect.fn("V2Session.create")(function* (_input) {
return {} as any
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())
if (!row) return yield* new NotFoundError({ sessionID })
return fromRow(row)
}),
list: Effect.fn("V2Session.list")(function* (input) { list: Effect.fn("V2Session.list")(function* (input) {
const direction = input.cursor?.direction ?? "next" const direction = input.cursor?.direction ?? "next"
let order = input.order ?? "desc" let order = input.order ?? "desc"
@@ -262,11 +279,30 @@ export const layer = Layer.effect(
EventV2.run(SessionEvent.ModelSwitched.Sync, { EventV2.run(SessionEvent.ModelSwitched.Sync, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()), timestamp: DateTime.makeUnsafe(Date.now()),
id: input.id, model: input.model,
providerID: input.providerID,
variant: input.variant,
}) })
}), }),
subagent: Effect.fn("V2Session.subagent")(function* (input) {
const parent = yield* result.get(input.parentID)
const session = yield* result.create({
agent: input.agent,
model: input.model,
parentID: input.parentID,
workspaceID: parent.workspaceID,
})
yield* result.prompt({
prompt: input.prompt,
sessionID: session.id,
})
yield* Effect.gen(function* () {
yield* result.wait(session.id)
const messages = yield* result.messages({ sessionID: session.id, order: "desc" })
const assistant = messages.find((msg) => msg.type === "assistant")
if (!assistant) return
const text = assistant.content.findLast((part) => part.type === "text")
if (!text) return
}).pipe(Effect.forkChild())
}),
compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}),
wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}),
} }

View File

@@ -19,6 +19,7 @@ import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionMessageTable, SessionTable } from "@/session/session.sql"
import { SessionMessage } from "../../src/v2/session-message" import { SessionMessage } from "../../src/v2/session-message"
import { Modelv2 } from "../../src/v2/model"
import * as DateTime from "effect/DateTime" import * as DateTime from "effect/DateTime"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
@@ -214,7 +215,11 @@ describe("session HttpApi", () => {
id: SessionMessage.ID.create(), id: SessionMessage.ID.create(),
type: "assistant", type: "assistant",
agent: "build", agent: "build",
model: { id: "model", providerID: "provider" }, model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
time: { created: DateTime.makeUnsafe(1) }, time: { created: DateTime.makeUnsafe(1) },
content: [], content: [],
}) })

View File

@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
import * as DateTime from "effect/DateTime" import * as DateTime from "effect/DateTime"
import { SessionID } from "../../src/session/schema" import { SessionID } from "../../src/session/schema"
import { EventV2 } from "../../src/v2/event" import { EventV2 } from "../../src/v2/event"
import { Modelv2 } from "../../src/v2/model"
import { SessionEvent } from "../../src/v2/session-event" import { SessionEvent } from "../../src/v2/session-event"
import { SessionMessageUpdater } from "../../src/v2/session-message-updater" import { SessionMessageUpdater } from "../../src/v2/session-message-updater"
@@ -16,7 +17,11 @@ test("step snapshots carry over to assistant messages", () => {
sessionID, sessionID,
timestamp: DateTime.makeUnsafe(1), timestamp: DateTime.makeUnsafe(1),
agent: "build", agent: "build",
model: { id: "model", providerID: "provider" }, model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
snapshot: "before", snapshot: "before",
}, },
} satisfies SessionEvent.Event) } satisfies SessionEvent.Event)
@@ -56,7 +61,11 @@ test("text ended populates assistant text content", () => {
sessionID, sessionID,
timestamp: DateTime.makeUnsafe(1), timestamp: DateTime.makeUnsafe(1),
agent: "build", agent: "build",
model: { id: "model", providerID: "provider" }, model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
}, },
} satisfies SessionEvent.Event) } satisfies SessionEvent.Event)
@@ -96,7 +105,11 @@ test("tool completion stores completed timestamp", () => {
sessionID, sessionID,
timestamp: DateTime.makeUnsafe(1), timestamp: DateTime.makeUnsafe(1),
agent: "build", agent: "build",
model: { id: "model", providerID: "provider" }, model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
}, },
} satisfies SessionEvent.Event) } satisfies SessionEvent.Event)

View File

@@ -1,131 +0,0 @@
# 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.

View File

@@ -20,7 +20,7 @@ model. It can stop doing all the
The new agent loop needs to trigger compaction properly The new agent loop needs to trigger compaction properly
## Plugin API design - ??? ## Plugin API design - James?
We need to figure out how we want server plugins to work and what hooks are useful. We need to figure out how we want server plugins to work and what hooks are useful.
@@ -49,7 +49,7 @@ I have a basic model service that allows for models to be registered dynamically
Providers should register as plugins and autoload based on whatever logic they Providers should register as plugins and autoload based on whatever logic they
want / config. They should register models into model database want / config. They should register models into model database
## Event - Kit/James ## Event - Kit
I have this v2/event.ts but it needs to be self contained instead of using the I have this v2/event.ts but it needs to be self contained instead of using the
old bus system old bus system