Skip to content

Latest commit

 

History

History
820 lines (631 loc) · 80.7 KB

docs-ui-api-usage.md

File metadata and controls

820 lines (631 loc) · 80.7 KB

UI ↔ API Field-Usage Audit (OpenCode v1 → v2)

Purpose: enumerate every field the UI/rest-of-codebase reads from the API type layer, so a projection layer can keep the UI (and src/types/message.ts) unchanged.

Audited revision: working tree at dev (HEAD dc617b96) with an in-progress v2 rewrite. package.json version 0.6.46; installed @opencode-ai/sdk 1.16.0, @opencode/client 2.0.21.

Type layers in scope:

Layer Path Fate
API types src/types/api/**, src/api/types.ts rewritten / replaced in v2
API functions src/api/** rewritten
UI view model src/types/message.ts KEPT unchanged

0. Method, and two critical caveats

Method

Every field name was searched as \.fieldName\b across all *.ts/*.tsx under src/, then each hit was classified by hand as a read, a type definition, a re-export, or a false positive. Counts are split production / test. Searches run with Select-String (no rg on this machine).

Excluded from read counts: src/types/api/index.ts (pure re-exports) and files under src/types/api/ when the hit is a type declaration. Where a declaration in src/types/api/*.ts diverges from true v2, it is reported separately — that divergence is a projection requirement.

Caveat A — the tree is mid-migration; src/types/api/* is being rewritten concurrently

At audit time src/api/sdk.ts had already been switched from @opencode-ai/sdk/v2/client to the real v2 client @opencode/client/promise (OpenCode.make), while all 15 src/types/api/*.ts files were in the middle of a parallel rewrite. Two different "v2" surfaces are therefore in play:

Surface Version v2 fidelity
@opencode/client/promise (src/api/sdk.ts) 2.0.21 authoritative v2 — matches docs-v2-types.md
@opencode-ai/sdk/v2/client (imported by src/types/api/* at audit start) 1.16.0 stale — still carries share, summary, version, Todo, question.*, session.todo()

The v2 status column below is judged against @opencode/client 2.0.21 (= docs-v2-types.md). Where the stale surface differs materially, it is flagged, because a projection built against the wrong surface will silently keep v1 fields alive. Representative divergences:

  • PermissionRequest — authoritative v2 (docs-v2-types.md §4.1, @opencode/client): {id, sessionID, action, resources, save?, metadata?, source?, message?}. Stale @opencode-ai/sdk/v2: {id, sessionID, permission, patterns, metadata, always, tool?}.
  • Session — authoritative v2 SessionInfo: no share/summary/directory/version/path. Stale surface still exposes all five.
  • Todo / question.* — absent from @opencode/client (0 case-insensitive hits for todo in the whole dist/); still present in the stale surface.

Verified: the string todo does not occur anywhere under node_modules/@opencode/client/dist. This independently confirms docs-v2-types.md: todo was removed, not renamed.

Caveat B — the UI layer has not been touched yet

git diff --stat shows only package.json, package-lock.json, src/api/sdk.ts and src/types/api/* modified. Every UI file is still written against the v1 shape. That is exactly why this inventory is meaningful: the read-sites below are the real v1 contract the projection must satisfy.

At audit time npx tsc --noEmit -p tsconfig.app.json reports 239 errors, 154 of them inside src/types/api/* + src/api/*. The 85 in UI files are the mechanical consequence of the rewrite having landed in the type layer but not the consumer layer; they are itemised in §10.


1. ApiSession (a.k.a. Session)

src/types/api/session.ts → export type Session = SDKSession (v2-native alias while migrating).

1.1 Field read table

Field Read count (prod/test) Example locations v2 status
.id 85 / 14 SidePanel.tsx:983 updateSession(session.id, …), SessionContext.tsx:129 exists
.directory 123 / 0 SessionList.tsx:476, useSessions.ts:109, SessionContext.tsx:80-81, useSessionManager.ts:165, MultiServerFolderList.tsx:257, useChatSession.ts:687 RENAMED → location.directory
.title 22 / 0 SidePanel.tsx:983, useSessionManager.ts:166, useGlobalEvents.ts:558 exists (title?: string, now optional)
.parentID 16 / 0 childSessionStore.ts:65, childSessionStore.ts:68, SessionContext.tsx:157, useSessions.ts:245, useGlobalEvents.ts:543 exists
.time.updated 5 / 0 SessionList.tsx:135, SessionList.tsx:714, SessionList.tsx:842 exists
.time.created 6 / 0 childSessionStore.ts:85, SessionList.tsx:135 exists
.revert 8 / 0 api/session.ts:77 session.revert?.messageID, useRevertState.ts:110, :172-173, useSessionManager.ts:223 exists — only .messageID is read; v2 SessionRevert keeps messageID (adds files)
.summary 15 / 0 SessionList.tsx:441-442, :698, :702-710, :844-852 REMOVED — SessionInfo has no summary
.share 7 / 0 ShareDialog.tsx:34 updatedSession.share?.url, useSessionManager.ts:167, :209, :224 REMOVED — no share on SessionInfo (v2 session.export() replaces it)
.agent 1 / 0 childSessionStore.ts:83 agent: session.agent exists (agent?: string)
.version 0 / 0 — (6 session.version-looking hits are unrelated store counters: childSessionStore.ts:52, todoStore.ts:68) REMOVED — not read, no action needed
.path 0 / 0 — REMOVED — not read
.projectID 0 / 0 — (4 hits are a local projectId field on dialogs) exists — not read
.cost / .tokens (session-level) 0 / 0 — v2 added cost/tokens to SessionInfo; not read
.outcome / .slug / .subpath / .workspaceID / .permissions / .metadata / .fork 0 / 0 — v2 additions, not read

1.2 Direct answers to the critical questions

Question Answer Consequence
Is .directory read? YES — 123 production reads, the single most-read session field. Hard blocker. Projection MUST materialise directory from location.directory.
Is .share read? YES — 7 reads, all .share?.url. Blocker. v2 has no session share. Must project {url} (from session.export()) or stub to undefined and degrade the Share dialog.
Is .summary read? YES — 15 reads, .additions/.deletions/.files on the session row. Blocker. v2 has no session-level summary. Must derive (e.g. from session.diff()) or drop the stats.
Is .revert read? YES — 8 reads, only .messageID. Not a blocker. v2 SessionRevert keeps messageID.
Is .path read? NO — 0 reads. Nothing to do.
Is .version read? NO — 0 reads. Nothing to do.

1.3 Session list params also change

SessionListParams is consumed at api/session.ts:94-104 and callers pass roots:

v2 SessionListInput has no roots; session.children(id) also no longer exists — both fold into session.list({ parentID }). See §9.


2. ApiMessageWithParts / Message — the {info, parts} envelope

// src/types/api/message.ts (at audit start)
export interface MessageWithParts { info: Message; parts: Part[] }

2.1 Read table

Field Read count (prod/test) Example locations v2 status
.info 227 / 30 ChatArea.tsx:236, chatPageModel.ts:683, messageStore.ts:374, MessageRenderer.tsx:140, ContextDetailsDialog.tsx:61 STRUCTURE REMOVED — v2 messages carry their own content
.parts 45 / 20 chatAreaVisibility.ts:28-30, MessageRenderer.tsx:268, messageStore.ts:195, sessionStatsCompute.ts:48, outlineIndexModel.ts:24 FIELD REMOVED — v2 uses content: Array<text|reasoning|tool> on assistant only; user/system/synthetic carry text/files/agents directly
.message (v1 fallback) 1 / 0 messageConversion.ts:8 'info' in apiMessage ? apiMessage.info : apiMessage.message legacy tolerance — the only place the alternate key is read

2.2 Verdict

{info, parts} is read pervasively (272 sites) and is read uniformly through .info/.parts. The only .message reader is the deliberate fallback in messageConversion.ts:5-9, which already accepts either key:

type ApiMessageEnvelope = ApiMessageWithParts | { message: ApiMessageWithParts['info']; parts: ApiMessageWithParts['parts'] }
function getEnvelopeInfo(apiMessage: ApiMessageEnvelope) { return 'info' in apiMessage ? apiMessage.info : apiMessage.message }

This file is the natural projection seam. If the projection emits {info, parts} with a v1-shaped info and v1-shaped parts, the 272 read-sites need no change. The work is entirely in synthesising parts — see §4.4.


3. ApiMessage / message info

Message = UserMessage | AssistantMessage. v1 info shape is mirrored by src/types/message.ts UserMessageInfo / AssistantMessageInfo (KEPT).

3.1 Field read table

Field Read count (prod/test) Example locations v2 status
.id 102 / 18 ChatArea.tsx:140, OutlineIndex.tsx:374-375, messageStore.ts:375 exists (all v2 message variants have id)
.role 58 / 2 outlineIndexModel.ts:21, ChatArea.tsx:141, messageStore.ts:564, chatAreaUtils.ts:11 RENAMED → type ('user'/'assistant'/'system'/'synthetic'/'skill'/'shell'/'idle'/'compaction'/…)
.time.created 9 / 0 chatPageModel.ts:683, MessageRenderer.tsx:592, messageStore.ts:381 exists
.time.completed 9 / 0 chatPageModel.ts:694-695, :821, :937, messageStore.ts:55, :664 exists on assistant (time.completed?); absent on user — messageStore.ts:55 already guards with 'completed' in info.time
.summary 3 / 0 outlineIndexModel.ts:37 msg.info.summary?.title, useChatPageViewModel.ts:43, api/session.ts:82 info.summary?.diffs SHAPE CHANGED — v1 user summary = {title?, body?, diffs}; v2 user summary = {title?, body?, diffs: SnapshotFileDiff[]} (diffs now required, diff objects differ). Assistant summary is v1-only: v2 has no assistant summary (use compaction messages).
.modelID / .providerID 4 / 0 MessageRenderer.tsx:797 assistantInfo?.modelID, ContextDetailsDialog.tsx:102, :106 RESTRUCTURED — v2 assistant carries model: ModelRef = {id, providerID, variant?}. Flat modelID/providerID must be projected (modelID ← model.id)
.model 2 / 0 (via userInfo.model) + 3 store reads ChatPane.tsx:503 userInfo.model?.variant, useChatSession.ts:959-960, messageStore.ts:473-474 v2 user model: ModelRef (id, not modelID) — see §4.5
.agent 1 / 0 messageStore.ts:475 agent: m.info.agent exists on v2 user + assistant (agent: string)
.cost / .tokens 4 / 0 ContextDetailsDialog.tsx:61, :155-156, sessionStatsCompute.ts:112-113 exists on assistant (cost?: number, tokens?: TokenUsageInfo). tokens shape changed: v1 {input,output,reasoning,cache{read,write}}; v2 TokenUsageInfo is the same 4 keys → compatible.
.error 2 / 0 outlineIndexModel.ts:21, messageStore.ts via isAbortedMessage SHAPE CHANGED — v1 error is a tagged union ({name:'APIError', data:{message,statusCode,isRetryable,…}}); v2 error?: SessionStructuredError = {type, message, status?, response?}. See §3.3.
.sessionID 2 / 0 messageStore.ts:374 this.ensureSession(message.info.sessionID), :387 ABSENT on v2 message variants — v2 messages carry id but sessionID lives on the response/event envelope (data.sessionID), not on the message. Must be injected.
.parentID 0 / 0 — v1-only, unread
.mode 0 / 0 — v1-only, unread

3.2 Direct answers

Field Read? v2
.role YES (58) → type
.id YES (102) exists
.sessionID YES (2) removed from the message; inject from envelope
.parentID NO (0) unread
.time.completed YES (9) exists on assistant
.summary YES (3) user only, shape changed
.error YES (2) shape changed
.modelID YES (1) → model.id
.providerID YES (1) → model.providerID
.mode NO (0) unread
.agent YES (1) exists
.path NO (0) unread
.cost YES (1) exists
.tokens YES (3) exists
.finish NO (0) unread

3.3 Error shape

MessageErrorView.tsx and SystemPartViews.tsx read error.data.{message,isRetryable,statusCode,providerID}:

v1 APIError is preserved verbatim in the stale v2 surface (@opencode-ai/sdk/v2 ApiError = {name:"APIError", data:{message, statusCode?, isRetryable, responseHeaders?, responseBody?, metadata?}}) but the authoritative v2 @opencode/client has no ApiError at all — it has SessionStructuredError = {type, message, status?, response?}. isRetryable and statusCode therefore have no source. See blockers.


4. ApiPart — the part union

Part is a 12-member union discriminated by type: text | reasoning | tool | file | agent | step-start | step-finish | snapshot | patch | subtask | retry | compaction.

4.1 part.type values switched/checked (production)

type Checks (prod) Dispatch sites
text 20 MessageRenderer.tsx:872, :595, :1430, chatPageModel.ts:93, chatAreaVisibility.ts:34, sessionStatsCompute.ts:11, types/index.ts:54
reasoning 6 MessageRenderer.tsx:874, chatPageModel.ts:96, chatAreaVisibility.ts:33, sessionStatsCompute.ts:21
tool 8 chatPageModel.ts:99, :823, chatAreaVisibility.ts:35, types/message.ts:328
file 7 MessageRenderer.tsx:597, chatPageModel.ts:104, messageStore.ts:811, useInputHistory.ts:52
agent 11 MessageRenderer.tsx:598, chatPageModel.ts:114, messageStore.ts:835
step-finish 11 MessageRenderer.tsx:884, :799, :201-245, chatPageModel.ts:110
step-start 5 never rendered — only filtered: chatAreaVisibility.ts:31, MessageRenderer.tsx:1428, :1453
snapshot 5 never rendered — only filtered (same sites as step-start)
patch 5 rendered nowhere. PatchPartView defined at SystemPartViews.tsx:96 and exported at parts/index.ts:7, but no case 'patch' in the MessageRenderer switch and no other importer except its own test → effectively dead code
subtask 3 MessageRenderer.tsx:898, chatPageModel.ts:107, chatAreaVisibility.ts:47
retry 11 MessageRenderer.tsx:900 → SystemPartViews.tsx:17
compaction 5 MessageRenderer.tsx:902 → SystemPartViews.tsx:75

Primary dispatch switch: MessageRenderer.tsx:871-906 — handles text, reasoning, step-finish, subtask, retry, compaction; default: return null. A parallel dispatch exists at MessageRenderer.tsx:1428-1460 and a third at chatPageModel.ts:91-116 (weight estimation).

4.2 Per-variant field reads

Variant Field Count Example
text .text many TextPartView.tsx:14, MessageRenderer.tsx:595, sessionStatsCompute.ts:11
.synthetic 11 TextPartView.tsx:20, MessageRenderer.tsx:596, chatPageModel.ts:94, messageStore.ts:802
.time.start / .time.end via ReasoningPartView.tsx:26, :48-49
reasoning .text ✓ ReasoningPartView.tsx:24, sessionStatsCompute.ts:21
.time.start / .time.end ✓ ReasoningPartView.tsx:26, :48-49, :55
tool .callID 7 ToolPartView.tsx:82, :85, BashRenderer.tsx:35, MessageRenderer.tsx:977-978
.tool 11 registry.tsx:361, ToolPartView.tsx:556, MessageRenderer.tsx:1242, :1373, sessionStatsCompute.ts:56
.state.status 24+ ToolPartView.tsx:61, ToolPartView.tsx, BashRenderer.tsx:28, DefaultRenderer.tsx:20, TaskRenderer.tsx:37, MessageRenderer.tsx:994, :1249-1250, :1368, chatPageModel.ts:823, sessionStatsCompute.ts:25-30
.state.input 6 registry.tsx:206, :236, :252, ToolPartView.tsx:564, sessionStatsCompute.ts:24
.state.output 4 registry.tsx:225-226, chatPageModel.ts:100, sessionStatsCompute.ts:27
.state.metadata 7 registry.tsx:66, :207, ToolPartView.tsx:557, MessageRenderer.tsx:1374, QuestionRenderer.tsx:45, TaskRenderer.tsx:53
.state.error 3 chatPageModel.ts:100, sessionStatsCompute.ts:30, MessageRenderer.tsx:994
.state.time.compacted / .time.end 2 sessionStatsCompute.ts:27, chatPageModel.ts:843
.state.raw 1 sessionStatsCompute.ts:28 via :25
.state.title 0 direct (rendered through registry normalisation)
.state.attachments 0 unread
file .mime 19 api/message.ts:93, :100, attachment/utils.ts:23, :29, useInputHistory.ts:54, messageStore.ts:813
.url 57 (mixed) api/message.ts:99, attachment/utils.ts:28, useInputHistory.ts:65, messageStore.ts:825
.filename 5 api/message.ts:98, attachment/utils.ts:27, useInputHistory.ts:64, messageStore.ts:823
.source (+.path) 42 attachment/utils.ts:27 part.source?.path, useInputHistory.ts:57, messageStore.ts:816, api/message.ts:88
.source.text.{value,start,end} ✓ api/message.ts:102-106, attachment/utils.ts:34-38
.source.type/name/kind/range/uri/clientName ✓ AttachmentItem.tsx:235-256 (on originalSource)
.id ✓ api/message.ts:96, attachment/utils.ts:25
agent .name ✓ api/message.ts:114-115, attachment/utils.ts:58-59
.source.{value,start,end} ✓ api/message.ts:116-120, attachment/utils.ts:60-64
step-finish .tokens.{input,output,reasoning,cache.read,cache.write} ✓ StepFinishPartView.tsx:38-39, :59, :67
.cost ✓ StepFinishPartView.tsx:47, :72
.reason 0 declared, unread
.snapshot 0 declared, unread
subtask .prompt ✓ SubtaskPartView.tsx:109
.description ✓ SubtaskPartView.tsx:172
.agent ✓ SubtaskPartView.tsx:155, :185
.model.{providerID,modelID} ✓ SubtaskPartView.tsx:113, :117
.command ✓ SubtaskPartView.tsx:123, :126
snapshot .snapshot 0 never rendered
patch .hash 1 SystemPartViews.tsx:101, #L116
.files 1 SystemPartViews.tsx:101-102, :124
retry .attempt ✓ SystemPartViews.tsx:22, :38
.error.data.{isRetryable,message,statusCode} ✓ SystemPartViews.tsx:25, :53, :55-57
.time.created ✓ SystemPartViews.tsx:22, :24
compaction .auto 0 SystemPartViews.tsx:77 void part — component renders a static divider
.reason / .cost / .tokens 0 unread

4.3 Unnarrowed / risky reads

The union is routinely escaped by casting; these read fields that do not exist on the union member that the compiler sees, and will not be caught by a projection that widens types:

Site Risk
chatPageModel.ts:100 part.state.output?.length ?? part.state.error?.length reads .output and .error without checking status
sessionStatsCompute.ts:24-30 reads .input, .raw, .output, .time.compacted, .error across variants
messageStore.ts:35-45 partHasText / mergePartPreferLiveText 'text' in part structural check on the raw union + spread rebuild
MessageRenderer.tsx:994, :1249-1250, :1368 .state.status on Part narrowed only by .filter(p => p.type === 'tool')
ToolPartView.tsx:557, :564 part.state.metadata as Record<string, unknown> / .input as ...
useSessionManager.ts:64-71 mergePartsForReload uses in operator on part
sessionStatsCompute.ts:12-13 part.source?.text.value.length / part.source?.value.length without checking source.type

4.4 Part types that do NOT exist in v2

v2 has no parts array. Assistant content is content: Array<SessionMessageAssistantText | SessionMessageAssistantReasoning | SessionMessageAssistantTool> — i.e. only text, reasoning, tool survive, and tool state is shaped differently (SessionMessageToolState{Streaming,Running,Completed,Error} with content: [ToolContent, ...] instead of v1's output/raw).

v1 part type v2 equivalent UI impact
text SessionMessageAssistantText direct
reasoning SessionMessageAssistantReasoning direct
tool SessionMessageAssistantTool (state.status ∈ streaming|running|completed|error) output → content[]; raw → state.input: string while streaming; no pending
file / agent SessionMessageUser.files[] / .agents[] (PromptFileAttachment / PromptAgentAttachment) — on the user message, not in a parts array projection must relocate
step-finish none — reconstruct from SessionStepEnded (cost, tokens, finish) rendered, needs synthesis
step-start none — SessionStepStarted event only filtered only
subtask none — sub-sessions via parentID / session.fork rendered, needs synthesis
retry none — move to SessionMessageAssistant.retry: {attempt, at, error} rendered, needs synthesis + shape change
compaction none as a part — v2 has SessionMessageCompaction{Running,Completed,Failed} messages rendered; relocate
snapshot none — SessionMessageAssistant.snapshot: {start?, end?, files?} filtered only
patch none dead code

4.5 ModelRef.modelID vs id

v1 ModelRef = {providerID, modelID, variant?}; v2 ModelRef = {id, providerID, variant?}. Sites reading .modelID off a ModelRef (not off AssistantMessageInfo):

This is a real v2 type error — confirmed by tsc at ChatPane.tsx:440 (Property 'modelID' does not exist on type 'ModelRef') and messageStoreHooks.test.tsx:35. Note src/types/message.ts ModelRef also uses modelID — the UI's own model is v1-shaped, so the projection must map id → modelID at every boundary where a v2 ModelRef reaches the UI.


5. TodoItem — REMOVED in v2, no equivalent

TodoItem is declared in src/types/api/event.ts:53-56 as SDKTodo & { id: string } and re-exported from src/types/api/index.ts:121. Confirmed absent from @opencode/client (0 hits for todo in dist/). docs-v2-types.md §11: "session.todo(id) → NO v2 EQUIVALENT AT ALL… Todo functionality was removed, not renamed."

5.1 Producers / core module

File Lines Role
src/api/todo.ts 1-33 normalizeTodoItems, buildTodoId, status/priority normalisation — entire file is todo-only
src/store/todoStore.ts 1-171 TodoStore, SessionTodos, TodoStats, todoStore, useTodos, useTodoStats, useCurrentTask — entire file is todo-only
src/features/message/tools/renderers/TodoRenderer.tsx 1-~110 TodoRenderer, TodoList, getTodoIcon — entire file
src/features/message/tools/renderers/todoUtils.ts 1-19 extractTodos, hasTodos — entire file

5.2 Production consumers (file:line)

API layer

Event wiring / state

API calls (the v1 method)

Rendering

Tool-name dispatch

i18n

Unrelated (false positives)

5.3 Test consumers

Note: no todoStore.test.ts, todoUtils.test.ts or TodoRenderer.test.tsx exists; the todo module is under-tested, so removal carries little test churn.


6. QuestionRequest / PermissionRequest

6.1 question.* → form.* (v2 replacement)

API calls (v1, all in src/api/permission.ts)

Line Call
80 sdk.question.list({ directory }) → getPendingQuestions
97 sdk.question.reply({ requestID, directory, answers }) → replyQuestion
112 sdk.question.reject({ requestID, directory }) → rejectQuestion

Event subscriptions

QuestionRequest / QuestionInfo / QuestionOption field reads

Field Count Example
request.id 4 InlineQuestion.tsx:109, :124, :168, ChatPane.tsx:1015
request.sessionID ✓ api/permission.ts:83 q.sessionID === target.sessionId
request.questions 10+ InlineQuestion.tsx:30, :100, :142, QuestionDialog.tsx:34, :116, :225
question.header 4 InlineQuestion.tsx:229-230, QuestionDialog.tsx:324, useChatSession.ts:437, useGlobalEvents.ts:693
question.question 2 InlineQuestion.tsx:232, QuestionDialog.tsx:325
question.options 4 InlineQuestion.tsx:237, QuestionDialog.tsx:330
option.label 7 InlineQuestion.tsx:238, :242, :261, QuestionDialog.tsx:331, :336, :344, QuestionRenderer.tsx:91, :111
option.description 2 InlineQuestion.tsx:243, QuestionDialog.tsx:345-346
question.multiple 6 InlineQuestion.tsx:104, :207, QuestionDialog.tsx:121, :144, :293
question.custom 4 InlineQuestion.tsx:105, :208, QuestionDialog.tsx:123, :294
request.tool.callID 1 InlineToolRequestContext.tsx:103 pendingQuestions.find(q => q.tool?.callID === callID)
metadata.answers 1 QuestionRenderer.tsx:214-215

Producers of QuestionAnswer[]: InlineQuestion.tsx:100, QuestionDialog.tsx:116 — both build string[] per question, matching v1 QuestionAnswer = Array<string>.

v2 status: question.* is REMOVED; FormInfo = {id, sessionID, title, metadata?, fields: [FormField, ...]}. The v1 shape (questions: QuestionInfo[] where each has header/question/options[]/multiple?/custom?) has no structural counterpart: v2 fields is a flat discriminated union over type ∈ string | number | integer | boolean | multiselect | external, each with key/title?/description?/required?/hidden?/when?.

Projection feasibility: a choice-only form (multiselect, or string with options) can be projected into one synthetic QuestionInfo (header ← title, question ← title, options ← options, multiple ← type === 'multiselect', custom ← false). Non-choice fields (number, integer, boolean, external, option-less string) have no representation in the v1 UI at all — those are unavoidable UI edits. answers is also a different shape: v1 string[][], v2 FormAnswer = {[key]: FormValue}.

6.2 PermissionRequest

API calls — src/api/permission.ts: :24 permission.list, :46 permission.respond (v1-only), :57 permission.reply.

Event subscriptions — src/api/events.ts:827 PERMISSION_ASKED, :830 PERMISSION_REPLIED.

Field reads

Field Count Example v2 status
.id 6 usePermissionHandler.ts:56, :107, :214-215, InlinePermission.tsx:65 exists
.sessionID 6 api/permission.ts:27, InlinePermission.tsx:64, PermissionDialog.tsx:58-64, :188-189 exists
.metadata (?.diff, ?.filepath, ?.filediff) 2 InlinePermission.tsx:37-43, PermissionDialog.tsx:36-45 exists ({[x]: JsonValue})
.permission 9 InlinePermission.tsx:55, :64, :79, :88, PermissionDialog.tsx:55, :96, :188-189 RENAMED → action
.patterns 5 InlinePermission.tsx:56-57, :61, PermissionDialog.tsx:145-148, :184 RENAMED → resources
.always 4 InlinePermission.tsx:61, PermissionDialog.tsx:156-159, :184 RENAMED → save (semantics differ: save is what to persist)
.tool.callID 1 InlineToolRequestContext.tsx:69 REMOVED from PermissionRequest; v2 PermissionSource = {type:'tool', messageID, id} — project callID ← source.id
.tool.messageID 0 — via PermissionSource.messageID
.action / .resources / .save / .message 0 — v2-only, unread

PermissionReply ('once' | 'always' | 'reject') is read at usePermissionHandler.ts:94 and constructed in the dialogs; v2's reply field is decision with the same three literals (per docs-v2-types.md §4.5).

Local declaration divergence (must be reconciled):

File Divergence
src/types/api/permission.ts (at audit start) aliased PermissionRequest = SDKPermissionRequest from the stale @opencode-ai/sdk/v2/client (with permission/patterns/always/tool), and derived PermissionToolInfo = NonNullable<SDKPermissionRequest['tool']>. True v2 has neither. During the concurrent rewrite this file was changed to define a synthetic PermissionToolInfo {action?, resources?, source?}.
src/types/api/event.ts (at audit start) declared TodoItem, TodoUpdatedPayload, QuestionRepliedPayload, QuestionRejectedPayload against the stale surface.

7. MCPStatus*

Declared in src/types/api/mcp.ts. Consumed in src/components/McpPanel.tsx and src/api/mcp.ts.

7.1 Field reads

Field Count Example v2 status
status.status 6 switches McpPanel.tsx:490, :493, :504, :529, :576 exists
status.error 2 McpPanel.tsx:491 (failed), :494 (needs_client_registration) exists on failed ({status:'failed', error:string}) and needs_auth; needs_client_registration no longer exists
server.name ✓ McpPanel.tsx:485, :612 v2 McpServer {name, status, integrationID?}
server.resources 6 McpPanel.tsx:500, :616-618, :640-647 not on McpServer — resources come from a separate catalog call
resource.description 1 McpPanel.tsx:657-659 v2 McpResource has description?
resource.name ✓ McpPanel.tsx:650 exists
resource.uri ✓ McpPanel.tsx:648, :662 exists
resource.mimeType 0 — exists, unread
resource.server 0 — exists, unread

status.status case labels actually handled: connected, disabled, failed, needs_auth, needs_client_registration (McpPanel.tsx:493, :513, :556, :584). v2 replaced that with McpStatusPending ({status:'pending'}) — see docs-v2-types.md §12: "McpStatus … the five member types are McpStatusConnected, McpStatusPending, McpStatusDisabled, McpStatusFailed, McpStatusNeedsAuth."

7.2 API calls

Line Call v2 status
src/api/mcp.ts:14 sdk.mcp.status({directory}) method renamed → mcp.list() (returns {location, data})
src/api/mcp.ts:22 sdk.mcp.resource(...) renamed/reshaped → resource catalog
src/api/mcp.ts:30 sdk.mcp.add({name, config, directory}) name no longer an input field
src/api/mcp.ts:38 sdk.mcp.connect({name, directory}) name no longer an input field
src/api/mcp.ts:46 sdk.mcp.disconnect({name, directory}) same
src/api/mcp.ts:54-80 sdk.mcp.auth.{start,remove,callback,authenticate} mcp.auth.* restructured

Also McpLocalConfig.timeout changed from number to {startup?, catalog?, execution?} — flagged by tsc at mcp.ts:30.


8. Direct @opencode-ai/sdk type usage in UI code

Result: clean. No UI file (anything outside src/types/api/ and src/api/) imports @opencode-ai/sdk or @opencode/client.

All 27 @opencode* import sites:

Area Files
src/api/ sdk.ts:13-14 OpenCode, OpenCodeClient from @opencode/client/promise; global.ts:5; lsp.ts:5; todo.ts:1
src/types/api/ agent, common, config, event, file, mcp, message, model, permission, project, pty, session, skill, tool, vcs, worktree
tests src/api/sdk.test.ts:10 vi.mock('@opencode-ai/sdk/v2/client')

Consequence: the SDK boundary is already funnelled through src/api/* + src/types/api/*. No hard migration blocker here — the projection layer has a single, well-defined place to live. The only caveat is the stale-vs-authoritative surface split (§0 Caveat A) and the stale mock in sdk.test.ts:10, which must be re-pointed at @opencode/client/promise.


9. v1 method names called as API

v1 name Call sites v2 equivalent
.status( session.ts:31 sdk.session.status → RENAMED session.active() —
file.ts:108 sdk.file.status, lsp.ts:20 sdk.lsp.status, lsp.ts:42 sdk.formatter.status, mcp.ts:14 sdk.mcp.status these are different namespaces; mcp.status → mcp.list
.share( session.ts:220 sdk.session.share REMOVED → session.export()
.unshare( session.ts:229 sdk.session.unshare REMOVED (no equivalent)
.summarize( session.ts:259 sdk.session.summarize RENAMED → session.compact()
.todo( session.ts:289 sdk.session.todo REMOVED
.children( session.ts:274 sdk.session.children REMOVED → session.list({parentID})
.abort( session.ts:179 sdk.session.abort RENAMED → session.interrupt()
.unrevert( session.ts:211 sdk.session.unrevert RENAMED → session.revert.clear()
.revert( session.ts:196 sdk.session.revert SPLIT → session.revert.stage()
.messages( message.ts:63 sdk.session.messages MOVED → client.message.list(); returns {data, cursor}
.promptAsync( message.ts:241 sdk.session.promptAsync REMOVED — v2 session.prompt() enqueues
.prompt( message.ts:232 sdk.session.prompt exists; input has no parts (uses text/files/agents/references) and no directory
permission.respond permission.ts:46 REMOVED → permission.reply({sessionID, requestID, decision})
question.{list,reply,reject} permission.ts:80, :97, :112 REMOVED → form.*
delete session.ts:165 sdk.session.delete RENAMED → session.remove()
roots param SessionContext.tsx:70, SearchResults.tsx:121, useSessions.ts:138 REMOVED → parentID

Downstream callers of these API functions (they take directory as an argument and will keep compiling, hiding the breakage): shareSession/unshareSession (ShareDialog.tsx:33, :48), summarizeSession (useChatSession.ts:35, :1029), getSessionChildren (SessionChildrenSlot.tsx:64), abortSession, revertMessage.


MIGRATION BLOCKERS

UI code reading a field/type that does not exist in v2 (authoritative @opencode/client 2.0.21). Each entry is either projectable (P) or an unavoidable UI edit (U).

B1 — Session.directory (renamed to location.directory) — P, but 123 sites

B2 — Session.share — REMOVED

B3 — Session.summary — REMOVED

B4 — {info, parts} envelope — STRUCTURE REMOVED (272 read sites)

Projection seam already exists: src/utils/messageConversion.ts:5-17.

B5 — info.role → type (58 sites); info.sessionID absent on messages (2 sites)

B6 — info.modelID / info.providerID flat fields — moved under model

B7 — ModelRef.modelID → ModelRef.id — U (type error, 6 files)

B8 — Tool state.output / state.raw — U unless projected

v2 completed tool state carries content: [ToolContent, ...], not output; streaming carries input: string and there is no pending status.

B9 — RetryPart and error.data.{isRetryable,statusCode} — U

B10 — Part types step-finish / subtask / retry / compaction — P (synthesise) or U

Rendered but with no v2 part source: MessageRenderer.tsx:884-903. Sources: SessionStepEnded (step-finish), sub-sessions via parentID/fork (subtask), SessionMessageAssistant.retry (retry), SessionMessageCompaction* messages (compaction).

B11 — question.* API + QuestionRequest field model — U for non-choice fields

B12 — PermissionRequest.{permission,patterns,always,tool} — P (lossy) / U

B13 — roots removed from SessionListInput — U

B14 — MCPStatus.needs_client_registration removed — U (small)

B15 — session.agent — low risk


TODO REMOVAL

Complete deletion/stub plan. v2 has no todo type, no session.todo(), no todo.updated event.

Delete outright (4 files, all todo-only)

File Reason
src/api/todo.ts normalizeTodoItems + SDKTodo import only
src/store/todoStore.ts whole store + hooks are todo-only
src/features/message/tools/renderers/TodoRenderer.tsx todo renderer
src/features/message/tools/renderers/todoUtils.ts extractTodos / hasTodos

Edit

File:line Change
src/api/session.ts:15, :278-291 drop getSessionTodos, ApiTodo, TodoItem import, normalizeTodoItems import (:8)
src/api/events.ts:13, :851-857 delete TODO_UPDATED case
src/types/api/event.ts:15, :20, :53-60, :109, :170 delete TodoItem, TodoUpdatedPayload, TODO_UPDATED, onTodoUpdated
src/types/api/index.ts:121-122 drop re-exports
src/contexts/SessionContext.tsx:10, :199-201 drop onTodoUpdated handler + import
src/utils/sessionLifecycle.ts:5, :13 drop todoStore.clearTodos
src/store/index.ts:56-57 drop exports
src/features/chat/input/InputFooter.tsx:6-9, :42-43, :53-61, :79-181, :229-303, :310-410 remove the whole todo swap-panel state machine + TodoSwapPanel + TodoRow; revert footer to disclaimer only
src/features/message/parts/ToolPartView.tsx:25-27, :541-542 drop TodoRenderer/hasTodos dispatch
src/features/message/tools/renderers/index.ts:4-5 drop exports
src/features/message/tools/index.ts:11 drop from barrel
src/features/message/tools/registry.tsx:279-281 drop the todo match entry
src/features/message/MessageRenderer.tsx:1214, :1331 drop 'todo' from the summary-category union + mapper
src/locales/en/message.json:79-81, :108 and the zh-CN twins drop todoDone/todoActive/todoFailed + todo block

Keep (not todo-domain)


10. Current typecheck breakage (UI side)

Snapshot: npx tsc --noEmit -p tsconfig.app.json → 239 errors (154 in src/types/api + src/api).

UI-file errors grouped by cause:

Cause Errors Files
Session.directory missing ~40 SessionList.tsx (24), SidePanel.tsx (13), SessionChildrenSlot.tsx (6), SearchResults.tsx (4), MultiServerFolderList.tsx (4), useChatSession.ts (8), useSessions.ts (3), useGlobalEvents.ts (2), useSessionManager.ts (3), SessionContext.tsx (3), ActiveSessionItem.tsx (1), NotificationItem.tsx (1)
Session.summary missing 18 SessionList.tsx
Session.share missing 4 useSessionManager.ts:167,209,224, ShareDialog.tsx:34
SessionMessage has no sessionID/role 9 messageStore.ts:549,564,567,572, types/index.ts:42,47, messageConversion.ts:20, useGlobalEvents.ts:512, messageStore.test.ts:8
ModelRef.modelID missing 2 ChatPane.tsx:440, messageStoreHooks.test.tsx:35
roots not in SessionListInput 3 SessionContext.tsx:70, SearchResults.tsx:121, useSessions.ts:138
AgentInfo.permission → permissions 2 InputToolbar.test.tsx:13-14
SkillInfo.location 1 SkillPanel.tsx:166
SkillListOutput shape 1 SkillPanel.tsx:43
implicit any in part merge 3 useSessionManager.ts:64-67
test fixtures cast to SessionInfo 5 SessionList.test.tsx:37, NotificationItem.test.tsx:33, useSessions.test.tsx:141-143

11. Can the UI be kept unchanged?

Short answer

No — not unchanged. A {info, parts} + Session-like projection gets you a long way — it removes roughly 60 of the 85 UI typecheck errors, i.e. everything caused by Session.directory/summary/ share, info.role, info.sessionID, ModelRef.modelID, and tool state.*. But v2 genuinely deleted concepts the UI renders, and no projection can conjure them from nothing.

What the projection layer can fix (UI untouched)

v2 reality Projection
SessionInfo.location.directory set directory = location.directory
no session summary derive from session.diff() or set undefined
no session share set {url} from session.export(), or undefined
message type discriminant map to role
no sessionID on messages inject from the list/event envelope
assistant.model: ModelRef flatten model.id → modelID, model.providerID → providerID
ModelRef.id copy to modelID
content[] (text/reasoning/tool) synthesise parts[] and a v1-shaped info
SessionMessageUser.files/agents relocate to file/agent parts
tool state.content[] join ToolContent[] into state.output
v2 SessionRevert keep messageID; drop files
McpServer.status map pending → needs_client_registration if desired (or fix 4 case labels)
permission.action/resources/save map back to permission/patterns/always
PermissionSource.id map to tool.callID
form.fields (choice-only) map to questions[]

Unavoidable UI edits

# Reason Unavoidable because Files
1 Todo removed from v2 no source data exists at all 4 deletes + ~13 edits (see TODO REMOVAL)
2 question.* → form.* for non-choice fields number/integer/boolean/external/option-less string fields have no v1 UI representation; FormAnswer is an object, not string[][] InlineQuestion.tsx, QuestionDialog.tsx, QuestionRenderer.tsx, usePermissionHandler.ts, api/permission.ts — 5
3 Retry/error shape v2 has no ApiError; isRetryable and statusCode have no source SystemPartViews.tsx, MessageErrorView.tsx — 2
4 Parts that v2 never emits (step-finish, subtask, retry, compaction) must be synthesised from step/compaction events, not from listed messages — projection can fake the shape but only if the event layer also retains the data MessageRenderer.tsx, StepFinishPartView.tsx, SubtaskPartView.tsx, chatPageModel.ts, chatAreaVisibility.ts, sessionStatsCompute.ts — 6
5 Tool state model changed (pending gone, output/raw → content[]/input: string) ~10 components branch on 'pending'; only fixable by projection or by editing each 10 sites across ToolPartView.tsx, MessageRenderer.tsx, TaskRenderer.tsx, BashRenderer.tsx, DefaultRenderer.tsx, QuestionRenderer.tsx, chatPageModel.ts
6 ModelRef.modelID → id UI view model src/types/message.ts uses modelID and is KEPT, so projection is mandatory at each boundary 6 files if not projected
7 roots → parentID request param, not response-shaped — cannot be projected SessionContext.tsx:70, SearchResults.tsx:121, useSessions.ts:138, api/session.ts:94 — 4
8 needs_client_registration removed UI switch label has no v2 member McpPanel.tsx:493,513,556,584 — 1 file
9 Permission always → save semantics differ; auto-approve rule capture needs a product decision PermissionDialog.tsx:184-189, InlinePermission.tsx:61 — 2
10 Dead code PatchPartView is exported but never rendered; snapshot/step-start parts are filtered but never shown SystemPartViews.tsx:96-135, parts/index.ts:7 — 2

Unavoidable-edit tally by reason

Reason Files to edit/delete
Todo removed 17 (4 delete + 13 edit)
Question → Form (non-choice) 5
Retry / error shape 2
Parts v2 never emits 6
Tool state model 7–10
roots → parentID 4
MCP status member 1
Permission always → save 2
Dead part code 2
ModelRef.modelID (if not projected) 6
Total (deduplicated, approximate) ~30 files, of which ~14 are UI components/hooks and the rest are API-layer

Recommended shape

  1. Build the projection in one place — extend src/utils/messageConversion.ts (it already tolerates both envelope keys) and add a sibling sessionConversion.ts exposing toUiSession(SessionInfo): Session.
  2. Apply the session projection at the fetch boundary (getSessions, getSession, createSession, updateSession, forkSession, and the session.created/session.updated event handlers) so the 123 .directory read sites stay untouched.
  3. Delete todo outright — it cannot be projected.
  4. Plan real UI work for Form fields and the retry/error shape; those are designs, not mappings.
  5. Re-point src/api/sdk.test.ts:10 from @opencode-ai/sdk/v2/client to @opencode/client/promise, and delete the stale @opencode-ai/sdk/v2 imports from src/types/api/* — as long as both surfaces are imported, v1 fields like share/summary/ version/Todo/question.* will keep type-checking and the migration will silently stall.