Просмотр исходного кода

Improve v2 session message rendering (#25634)

Dax 4 месяцев назад
Родитель
Сommit
39c88f9afb

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

@@ -71,6 +71,8 @@ export const layer = Layer.effect(
   Effect.sync(() => Service.of(make())),
 )
 
+export const defaultLayer = layer
+
 export const layerWith = (input: Partial<Interface>) =>
   Layer.effect(
     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"
 
 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
   const assistant = messages[index]
   return assistant?.type === "assistant" ? assistant : undefined
 }
 
 function activeCompaction(messages: SessionMessage[]) {
-  const index = messages.findLastIndex((message) => message.type === "compaction")
+  const index = messages.findIndex((message) => message.type === "compaction")
   if (index < 0) return
   const compaction = messages[index]
   return compaction?.type === "compaction" ? compaction : undefined
 }
 
 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
   const shell = messages[index]
   return shell?.type === "shell" ? shell : undefined
@@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
       switch (event.type) {
         case "session.next.prompted": {
           update(event.properties.sessionID, (draft) => {
-            draft.push({
+            draft.unshift({
               id: event.id,
               type: "user",
               text: event.properties.prompt.text,
@@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
         }
         case "session.next.synthetic":
           update(event.properties.sessionID, (draft) => {
-            draft.push({
+            draft.unshift({
               id: event.id,
               type: "synthetic",
               sessionID: event.properties.sessionID,
@@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
           break
         case "session.next.shell.started":
           update(event.properties.sessionID, (draft) => {
-            draft.push({
+            draft.unshift({
               id: event.id,
               type: "shell",
               callID: event.properties.callID,
@@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
           update(event.properties.sessionID, (draft) => {
             const currentAssistant = activeAssistant(draft)
             if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
-            draft.push({
+            draft.unshift({
               id: event.id,
               type: "assistant",
               agent: event.properties.agent,
@@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
           break
         case "session.next.compaction.started":
           update(event.properties.sessionID, (draft) => {
-            draft.push({
+            draft.unshift({
               id: event.id,
               type: "compaction",
               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 { useLocal } from "@tui/context/local"
 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 { LANGUAGE_EXTENSIONS } from "@/lsp/language"
 import path from "path"
@@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
   const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
   const renderedMessages = createMemo(() => messages().toReversed())
   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(() => {
     void sync.session.message.sync(props.sessionID)
@@ -83,10 +87,11 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
                       last={lastAssistant()?.id === message.id}
                       syntax={syntax()}
                       subtleSyntax={subtleSyntax()}
+                      start={lastUserCreated(index())}
                     />
                   </Match>
                   <Match when={message.type === "synthetic"}>
-                    <SyntheticMessage message={message as SessionMessageSynthetic} index={index()} />
+                    <></>
                   </Match>
                   <Match when={message.type === "shell"}>
                     <ShellMessage message={message as SessionMessageShell} />
@@ -146,63 +151,36 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
     <box
       id={props.message.id}
       border={["left"]}
-      borderColor={theme.primary}
+      borderColor={theme.secondary}
       customBorderChars={SplitBorder.customBorderChars}
       marginTop={props.index === 0 ? 0 : 1}
       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}
       paddingBottom={1}
+      paddingLeft={2}
       backgroundColor={theme.backgroundPanel}
-      flexShrink={0}
     >
-      <text fg={theme.textMuted}>Synthetic</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>
   )
 }
@@ -237,7 +215,7 @@ function ShellMessage(props: { message: SessionMessageShell }) {
 }
 
 function CompactionMessage(props: { message: SessionMessageCompaction }) {
-  const { theme } = useTheme()
+  const { theme, syntax } = useTheme()
   return (
     <box
       marginTop={1}
@@ -248,7 +226,19 @@ function CompactionMessage(props: { message: SessionMessageCompaction }) {
       flexShrink={0}
     >
       <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>
     </box>
   )
@@ -294,12 +284,13 @@ function AssistantMessage(props: {
   last: boolean
   syntax: SyntaxStyle
   subtleSyntax: SyntaxStyle
+  start?: number
 }) {
   const { theme } = useTheme()
   const local = useLocal()
   const duration = createMemo(() => {
     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 variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
@@ -361,7 +352,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
   const { theme } = useTheme()
   return (
     <Show when={props.part.text.trim()}>
-      <box paddingLeft={3} marginTop={1} flexShrink={0}>
+      <box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
         <code
           filetype="markdown"
           drawUnstyledText={false}
@@ -521,33 +512,93 @@ function InlineTool(props: {
   part: SessionMessageAssistantTool
 }) {
   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 complete = createMemo(() => !!props.complete)
   const denied = createMemo(() => {
     const message = error()
     if (!message) return false
     return (
       message.includes("QuestionRejectedError") ||
       message.includes("rejected permission") ||
+      message.includes("specified a rule") ||
       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 (
-    <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>
   )
 }

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

@@ -13,6 +13,7 @@ const prefixes = {
   tool: "tool",
   workspace: "wrk",
   entry: "ent",
+  account: "act",
 } as const
 
 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 { EventV2 } from "@/v2/event"
 import { SessionEvent } from "@/v2/session-event"
+import { Modelv2 } from "@/v2/model"
 import * as DateTime from "effect/DateTime"
 
 const DOOM_LOOP_THRESHOLD = 3
@@ -432,9 +433,9 @@ export const layer: Layer.Layer<
                 sessionID: ctx.sessionID,
                 agent: input.assistantMessage.agent,
                 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,
                 timestamp: DateTime.makeUnsafe(Date.now()),
@@ -655,7 +656,7 @@ export const layer: Layer.Layer<
           EventV2.run(SessionEvent.Step.Failed.Sync, {
             sessionID: ctx.sessionID,
             error: {
-              type: error.name,
+              type: "unknown",
               message: errorMessage(e),
             },
             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) => {
     db.update(SessionTable)
       .set({
-        model: {
-          id: data.id,
-          providerID: data.providerID,
-          variant: data.variant,
-        },
+        model: data.model,
         time_updated: DateTime.toEpochMillis(data.timestamp),
       })
       .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 { EventV2 } from "@/v2/event"
 import { SessionEvent } from "@/v2/session-event"
+import { Modelv2 } from "@/v2/model"
 import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
 import * as DateTime from "effect/DateTime"
 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, {
           sessionID: input.sessionID,
           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"
 export { FileAttachment }
 import { ToolOutput } from "./tool-output"
-import { ModelID, ProviderID } from "@/provider/schema"
 import { V2Schema } from "./schema"
+import { Modelv2 } from "./model"
 
 export const Source = Schema.Struct({
   start: NonNegativeInt,
@@ -22,10 +22,13 @@ const Base = {
   sessionID: SessionID,
 }
 
-const Error = Schema.Struct({
-  type: Schema.String,
+export const UnknownError = Schema.Struct({
+  type: Schema.Literal("unknown"),
   message: Schema.String,
+}).annotate({
+  identifier: "Session.Error.Unknown",
 })
+export type UnknownError = Schema.Schema.Type<typeof UnknownError>
 
 export const AgentSwitched = EventV2.define({
   type: "session.next.agent.switched",
@@ -44,9 +47,7 @@ export const ModelSwitched = EventV2.define({
   version: 1,
   schema: {
     ...Base,
-    id: ModelID,
-    providerID: ProviderID,
-    variant: Schema.String.pipe(Schema.optional),
+    model: Modelv2.Ref,
   },
 })
 export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched>
@@ -103,11 +104,7 @@ export namespace Step {
     schema: {
       ...Base,
       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),
     },
   })
@@ -139,7 +136,7 @@ export namespace Step {
     aggregate: "sessionID",
     schema: {
       ...Base,
-      error: Error,
+      error: UnknownError,
     },
   })
   export type Failed = Schema.Schema.Type<typeof Failed>
@@ -296,7 +293,7 @@ export namespace Tool {
     schema: {
       ...Base,
       callID: Schema.String,
-      error: Error,
+      error: UnknownError,
       provider: Schema.Struct({
         executed: Schema.Boolean,
         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,
           type: "model-switched",
           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 },
         }),
       )

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

@@ -4,6 +4,7 @@ import { SessionEvent } from "./session-event"
 import { EventV2 } from "./event"
 import { ToolOutput } from "./tool-output"
 import { V2Schema } from "./schema"
+import { Modelv2 } from "./model"
 
 export const ID = EventV2.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")({
   ...Base,
   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")({
@@ -87,10 +84,7 @@ export class ToolStateError extends Schema.Class<ToolStateError>("Session.Messag
   input: Schema.Record(Schema.String, Schema.Unknown),
   content: ToolOutput.Content.pipe(Schema.Array),
   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(

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

@@ -3,17 +3,17 @@ import { SessionID } from "@/session/schema"
 import { WorkspaceID } from "@/control-plane/schema"
 import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } 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 type { Prompt } from "./session-prompt"
 import { EventV2 } from "./event"
 import { ProjectID } from "@/project/schema"
-import { ModelID, ProviderID } from "@/provider/schema"
 import { SessionEvent } from "./session-event"
 import { V2Schema } from "./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",
 })
 export type Delivery = Schema.Schema.Type<typeof Delivery>
@@ -27,11 +27,7 @@ export class Info extends Schema.Class<Info>("Session.Info")({
   workspaceID: optionalOmitUndefined(WorkspaceID),
   path: 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({
     created: 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 {
+  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: {
     limit?: number
     order?: "asc" | "desc"
@@ -88,13 +95,15 @@ export interface Interface {
   }) => Effect.Effect<SessionMessage.User, 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 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 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 wait: (sessionID: SessionID) => Effect.Effect<void, never>
 }
@@ -120,9 +129,9 @@ export const layer = Layer.effect(
         agent: row.agent ?? undefined,
         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,
         time: {
@@ -134,6 +143,14 @@ export const layer = Layer.effect(
     }
 
     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) {
         const direction = input.cursor?.direction ?? "next"
         let order = input.order ?? "desc"
@@ -262,10 +279,29 @@ export const layer = Layer.effect(
         EventV2.run(SessionEvent.ModelSwitched.Sync, {
           sessionID: input.sessionID,
           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) {}),
       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 { SessionMessageTable, SessionTable } from "@/session/session.sql"
 import { SessionMessage } from "../../src/v2/session-message"
+import { Modelv2 } from "../../src/v2/model"
 import * as DateTime from "effect/DateTime"
 import * as Log from "@opencode-ai/core/util/log"
 import { eq } from "drizzle-orm"
@@ -214,7 +215,11 @@ describe("session HttpApi", () => {
                 id: SessionMessage.ID.create(),
                 type: "assistant",
                 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) },
                 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 { SessionID } from "../../src/session/schema"
 import { EventV2 } from "../../src/v2/event"
+import { Modelv2 } from "../../src/v2/model"
 import { SessionEvent } from "../../src/v2/session-event"
 import { SessionMessageUpdater } from "../../src/v2/session-message-updater"
 
@@ -16,7 +17,11 @@ test("step snapshots carry over to assistant messages", () => {
       sessionID,
       timestamp: DateTime.makeUnsafe(1),
       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",
     },
   } satisfies SessionEvent.Event)
@@ -56,7 +61,11 @@ test("text ended populates assistant text content", () => {
       sessionID,
       timestamp: DateTime.makeUnsafe(1),
       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)
 
@@ -96,7 +105,11 @@ test("tool completion stores completed timestamp", () => {
       sessionID,
       timestamp: DateTime.makeUnsafe(1),
       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)
 

+ 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
 
-## Plugin API design - ???
+## Plugin API design - James?
 
 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
 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
 old bus system