Procházet zdrojové kódy

Improve v2 session message rendering (#25634)

Dax před 4 měsíci
rodič
revize
39c88f9afb

+ 2 - 0
packages/core/src/global.ts

@@ -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,

+ 8 - 8
packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx

@@ -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,

+ 122 - 71
packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx

@@ -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}>
-      <Switch>
-        <Match when={props.spinner}>
-          <Spinner color={theme.text}>{props.children}</Spinner>
-        </Match>
-        <Match when={true}>
-          <text paddingLeft={3} fg={props.complete ? theme.textMuted : theme.text}>
-            <Show fallback={<>~ {props.pending}</>} when={props.complete}>
-              {props.icon} {props.children}
-            </Show>
-          </text>
-        </Match>
-      </Switch>
-      <Show when={error() && !denied()}>
-        <text fg={theme.error}>{error()}</text>
-      </Show>
+    <box
+      marginTop={margin()}
+      paddingLeft={3}
+      flexShrink={0}
+      flexDirection="row"
+      gap={1}
+      backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
+      onMouseOver={() => error() && setHover(true)}
+      onMouseOut={() => setHover(false)}
+      onMouseUp={() => {
+        if (!error()) return
+        if (renderer.getSelection()?.getSelectedText()) return
+        setShowError((prev) => !prev)
+      }}
+      renderBefore={function () {
+        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>
   )
   )
 }
 }

+ 1 - 0
packages/opencode/src/id/id.ts

@@ -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) {

+ 5 - 4
packages/opencode/src/session/processor.ts

@@ -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,
-                  providerID: ctx.model.providerID,
-                  variant: input.assistantMessage.variant,
+                  id: Modelv2.ID.make(ctx.model.id),
+                  providerID: Modelv2.ProviderID.make(ctx.model.providerID),
+                  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()),

+ 1 - 5
packages/opencode/src/session/projectors-next.ts

@@ -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: {
-          id: data.id,
-          providerID: data.providerID,
-          variant: data.variant,
-        },
+        model: data.model,
         time_updated: DateTime.toEpochMillis(data.timestamp),
         time_updated: DateTime.toEpochMillis(data.timestamp),
       })
       })
       .where(eq(SessionTable.id, data.sessionID))
       .where(eq(SessionTable.id, data.sessionID))

+ 6 - 3
packages/opencode/src/session/prompt.ts

@@ -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,
-          providerID: info.model.providerID,
-          variant: info.model.variant,
+          model: {
+            id: Modelv2.ID.make(info.model.modelID),
+            providerID: Modelv2.ProviderID.make(info.model.providerID),
+            variant: Modelv2.VariantID.make(info.model.variant ?? "default"),
+          },
         })
         })
       }
       }
 
 

+ 246 - 0
packages/opencode/src/v2/auth.ts

@@ -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"

+ 192 - 0
packages/opencode/src/v2/model.ts

@@ -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"

+ 10 - 13
packages/opencode/src/v2/session-event.ts

@@ -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({
-  type: Schema.String,
+export const UnknownError = Schema.Struct({
+  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,
-    providerID: ProviderID,
-    variant: Schema.String.pipe(Schema.optional),
+    model: Modelv2.Ref,
   },
   },
 })
 })
 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({
-        id: Schema.String,
-        providerID: Schema.String,
-        variant: Schema.String.pipe(Schema.optional),
-      }),
+      model: Modelv2.Ref,
       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),

+ 1 - 5
packages/opencode/src/v2/session-message-updater.ts

@@ -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: {
-            id: event.data.id,
-            providerID: event.data.providerID,
-            variant: event.data.variant,
-          },
+          model: event.data.model,
           time: { created: event.data.timestamp },
           time: { created: event.data.timestamp },
         }),
         }),
       )
       )

+ 3 - 9
packages/opencode/src/v2/session-message.ts

@@ -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({
-    id: SessionEvent.ModelSwitched.fields.data.fields.id,
-    providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID,
-    variant: SessionEvent.ModelSwitched.fields.data.fields.variant,
-  }),
+  model: Modelv2.Ref,
 }) {}
 }) {}
 
 
 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({
-    type: Schema.String,
-    message: Schema.String,
-  }),
+  error: SessionEvent.UnknownError,
 }) {}
 }) {}
 
 
 export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
 export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(

+ 56 - 20
packages/opencode/src/v2/session.ts

@@ -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({
-    id: ModelID,
-    providerID: ProviderID,
-    variant: optionalOmitUndefined(Schema.String),
-  }).pipe(optionalOmitUndefined),
+  model: Modelv2.Ref.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: {
-    sessionID: SessionID
-    id: ModelID
-    providerID: ProviderID
-    variant?: string
-  }) => Effect.Effect<void, never>
+  readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => 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),
-              providerID: ProviderID.make(row.model.providerID),
-              variant: row.model.variant,
+              id: Modelv2.ID.make(row.model.id),
+              providerID: Modelv2.ProviderID.make(row.model.providerID),
+              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,10 +279,29 @@ 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,
-          providerID: input.providerID,
-          variant: input.variant,
+          model: input.model,
+        })
+      }),
+      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) {}),

+ 6 - 1
packages/opencode/test/server/httpapi-session.test.ts

@@ -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: [],
               })
               })

+ 16 - 3
packages/opencode/test/v2/session-message-updater.test.ts

@@ -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)
 
 

+ 0 - 131
specs/v2/session-concepts-gap.md

@@ -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.

+ 2 - 2
specs/v2/todo.md

@@ -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