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 |
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.
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 v2SessionInfo: noshare/summary/directory/version/path. Stale surface still exposes all five.Todo/question.*— absent from@opencode/client(0 case-insensitive hits fortodoin the wholedist/); still present in the stale surface.
Verified: the string
tododoes not occur anywhere undernode_modules/@opencode/client/dist. This independently confirmsdocs-v2-types.md: todo was removed, not renamed.
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.
src/types/api/session.ts → export type Session = SDKSession (v2-native alias while migrating).
| 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 |
| 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. |
SessionListParams is consumed at api/session.ts:94-104 and callers pass
roots:
- SessionContext.tsx:70
roots: true - SearchResults.tsx:121
getSessions({ search, roots: false, limit: 50 }, serverId) - useSessions.ts:138
roots: rootsOnly
v2 SessionListInput has no roots; session.children(id) also no longer exists — both fold into
session.list({ parentID }). See §9.
// src/types/api/message.ts (at audit start)
export interface MessageWithParts { info: Message; parts: Part[] }| 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 |
{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.
Message = UserMessage | AssistantMessage. v1 info shape is mirrored by src/types/message.ts
UserMessageInfo / AssistantMessageInfo (KEPT).
| 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 |
| 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 |
MessageErrorView.tsx and SystemPartViews.tsx read error.data.{message,isRetryable,statusCode,providerID}:
- MessageErrorView.tsx:91, :105, :111-116, :123
- SystemPartViews.tsx:25
error.data.isRetryable, :53error.data.message, :55-57error.data.statusCode
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.
Part is a 12-member union discriminated by type:
text | reasoning | tool | file | agent | step-start | step-finish | snapshot | patch | subtask | retry | compaction.
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).
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 |
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 |
v1 ModelRef = {providerID, modelID, variant?}; v2 ModelRef = {id, providerID, variant?}.
Sites reading .modelID off a ModelRef (not off AssistantMessageInfo):
- ChatPane.tsx:440
agent.model.providerID}:${agent.model.modelID} - SubtaskPartView.tsx:117
part.model.providerID}/{part.model.modelID} - useChatSession.ts:848, :1031
- useModelSelection.ts:154
model.providerID}:${model.modelID} - useSessionManager.ts:318, messageStore.ts:474
- sessionHelpers.ts:32, modelUtils.ts:40
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.
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."
| 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 |
API layer
- src/api/todo.ts:1
import type { Todo as SDKTodo } from '@opencode-ai/sdk/v2/client' - src/api/todo.ts:27
normalizeTodoItems(todos: SDKTodo[] …) - src/api/session.ts:8
import { normalizeTodoItems } from './todo' - src/api/session.ts:15
import type { TodoItem } from '../types/api/event' - src/api/session.ts:280
export type ApiTodo = TodoItem - src/api/session.ts:286-290
getSessionTodos()→sdk.session.todo({…})— v1-only method - src/api/events.ts:13
import { normalizeTodoItems } from './todo' - src/api/events.ts:22
TodoUpdatedPayloadimport - src/api/events.ts:851-857
case EventTypes.TODO_UPDATED→callbacks.onTodoUpdated?.({sessionID, todos: normalizeTodoItems(payload.properties.todos)})—todo.updatedevent does not exist in v2 - src/types/api/event.ts:15
EventTodoUpdated, :20Todo, :53-60TodoItem/TodoUpdatedPayload, :109TODO_UPDATED: 'todo.updated', :170onTodoUpdated? - src/types/api/index.ts:121-122 re-exports
Event wiring / state
- src/contexts/SessionContext.tsx:10
import { todoStore } from '../store/todoStore' - src/contexts/SessionContext.tsx:199-201
onTodoUpdated: data => { todoStore.setTodos(data.sessionID, data.todos) } - src/utils/sessionLifecycle.ts:5
import { todoStore } from '../store/todoStore' - src/utils/sessionLifecycle.ts:13
todoStore.clearTodos(id) - src/store/index.ts:56-57
export { todoStore, useTodos, useTodoStats, useCurrentTask }/export type { SessionTodos } - src/main.tsx:72 comment only
API calls (the v1 method)
- src/features/chat/input/InputFooter.tsx:7
import { getSessionTodos } from '../../../api/session' - src/features/chat/input/InputFooter.tsx:9
import type { TodoItem } - src/features/chat/input/InputFooter.tsx:58-61
getSessionTodos(sessionId).then(apiTodos => todoStore.setTodos(sessionId, apiTodos))
Rendering
- src/features/chat/input/InputFooter.tsx:6
useTodos, useTodoStats, useCurrentTask, todoStore - src/features/chat/input/InputFooter.tsx:42-43
const todos = useTodos(...),const stats = useTodoStats(...) - src/features/chat/input/InputFooter.tsx:53-61 initial fetch
- src/features/chat/input/InputFooter.tsx:79-181
hasTodosswap-panel state machine - src/features/chat/input/InputFooter.tsx:229-303 disclaimer/todo swap rendering
- src/features/chat/input/InputFooter.tsx:310-380
TodoSwapPanel - src/features/chat/input/InputFooter.tsx:382-410
TodoRow(readstodo.status,todo.content,todo.priority) - src/features/message/parts/ToolPartView.tsx:25-27
TodoRenderer, hasTodosimports - src/features/message/parts/ToolPartView.tsx:541-542
if (lowerTool.includes('todo') && hasTodos(part)) return <TodoRenderer …/> - src/features/message/tools/renderers/index.ts:4-5 exports
- src/features/message/tools/index.ts:11
export { DefaultRenderer, TodoRenderer, TaskRenderer, hasTodos }
Tool-name dispatch
- src/features/message/tools/registry.tsx:279-281
// Todo (must be before write/read…)/match: includes('todo') - src/features/message/MessageRenderer.tsx:1214
| 'todo'(summary category union) - src/features/message/MessageRenderer.tsx:1331
if (lower.includes('todo')) return 'todo' - src/features/message/MessageRenderer.tsx:953 / ToolPartView.tsx:474 regex
…|todo|question|ask
i18n
src/locales/en/message.json:79-81todoDone/todoActive/todoFailed;:108todoblocksrc/locales/zh-CN/message.json:63-65;:82todoblock- src/features/settings/components/configEditorMeta.ts:171
{ tool: 'todowrite', … }— a tool-name label, safe to keep
Unrelated (false positives)
- src/components/Icons.tsx:78
ListTodo/ :210PermissionListIcon— icon import - src/utils/errorHandling.ts:44
// TODO:comment - src/features/mention/useMention.ts:47
// TODO:comment - src/features/message/parts/ToolPartView.tsx:474 regex also matches
question/ask
- src/types/api/index.ts re-export reaches
TodoItem src/features/message/tools/renderers/TodoRenderer.tsx/todoUtils.ts— no dedicated test file found- src/contexts/SessionContext.test.tsx — session-context level
- src/store/messageStore.test.ts — no todo usage
Note: no
todoStore.test.ts,todoUtils.test.tsorTodoRenderer.test.tsxexists; the todo module is under-tested, so removal carries little test churn.
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
- src/api/events.ts:833
QUESTION_ASKED→onQuestionAsked - src/api/events.ts:836
QUESTION_REPLIED→onQuestionReplied - src/api/events.ts:839
QUESTION_REJECTED→onQuestionRejected - src/types/api/event.ts:104-106
QUESTION_ASKED/REPLIED/REJECTEDliterals; :119-120 payload aliases - src/hooks/useChatSession.ts:430
onQuestionAsked, :448onQuestionReplied, :451onQuestionRejected - src/hooks/useGlobalEvents.ts:44, :143, :210
QuestionRequest / QuestionInfo / QuestionOption field reads
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}.
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. |
Declared in src/types/api/mcp.ts. Consumed in src/components/McpPanel.tsx
and src/api/mcp.ts.
| 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."
| 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.
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.
| 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.
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).
- SessionList.tsx:476, :592, :856-860
- SessionContext.tsx:80-81, :129
- useSessions.ts:109, :149-151
- useSessionManager.ts:165, :206, :220
- useChatSession.ts:687, :941, :965, :1018, :1089, :1118, :1121, :1131
- SidePanel.tsx:481, :868-869, :906-911, :983, :985, :997, :1000, :1026
- MultiServerFolderList.tsx:257-267, SessionChildrenSlot.tsx:64-102, SearchResults.tsx:180-188, ActiveSessionItem.tsx:22, NotificationItem.tsx:35, App.tsx:222
- ShareDialog.tsx:34
updatedSession.share?.url - useSessionManager.ts:167, :209, :224
shareUrl: sessionInfo?.share?.url - producer: api/session.ts:220
sdk.session.share(method removed)
- SessionList.tsx:441-442, :698, :702-710, :844-852 —
.additions/.deletions/.files
Projection seam already exists: src/utils/messageConversion.ts:5-17.
- ChatArea.tsx:141, chatAreaUtils.ts:11, outlineIndexModel.ts:21, chatPageModel.ts, messageStore.ts:564-572
- messageStore.ts:374, :387, :549, :572
- types/index.ts:42-47 — already erroring
- MessageRenderer.tsx:797
assistantInfo?.modelID - ContextDetailsDialog.tsx:102, :106
- ChatPane.tsx:440 ← tsc error
- SubtaskPartView.tsx:117
- useChatSession.ts:848, useModelSelection.ts:154, useSessionManager.ts:318, messageStore.ts:474, sessionHelpers.ts:32, modelUtils.ts:40
v2 completed tool state carries content: [ToolContent, ...], not output; streaming carries
input: string and there is no pending status.
- registry.tsx:225-226, chatPageModel.ts:100, sessionStatsCompute.ts:25-28
pendingchecks: ToolPartView.tsx:61, BashRenderer.tsx:28, DefaultRenderer.tsx:20, QuestionRenderer.tsx:42, TaskRenderer.tsx:37, :56, :217, :467, MessageRenderer.tsx:1368, chatPageModel.ts:823
- SystemPartViews.tsx:17-65 —
RetryPartViewreadserror.data.isRetryable(:25),error.data.message(:53),error.data.statusCode(:55-57) - MessageErrorView.tsx:91, :105, :111-116, :123
- v2 has no
ApiErrorin@opencode/client; v2SessionStructuredError = {type, message, status?, response?}andSessionMessageAssistantRetry = {attempt, at, error}.
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).
- API: permission.ts:80, :97, :112; events events.ts:833-841
- UI: InlineQuestion.tsx (all of it), QuestionDialog.tsx (all of it), QuestionRenderer.tsx, usePermissionHandler.ts:133-179, ChatPane.tsx:783-1017
- InlinePermission.tsx:55-64, :79-88, PermissionDialog.tsx:55-64, :96, :145-159, :184-189
tool.callIDcorrelation: InlineToolRequestContext.tsx:69- v2 has no
always;savediffers semantically, so auto-approve rule capture (PermissionDialog.tsx:184-189) needs a decision.
- SessionContext.tsx:70
roots: true, SearchResults.tsx:121roots: false, useSessions.ts:138roots: rootsOnly, api/session.ts:94-99
- McpPanel.tsx:493, :513, :556, :584
- v2 adds
pending; max progress bar at McpPanel.tsx:87-88
- childSessionStore.ts:83 — exists in v2, no action.
Complete deletion/stub plan. v2 has no todo type, no session.todo(), no todo.updated event.
| 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 |
| 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 |
- src/components/Icons.tsx:78
ListTodoicon + :210PermissionListIcon - src/features/settings/components/configEditorMeta.ts:171
todowritelabel — harmless if the tool disappears TODO:comments at src/utils/errorHandling.ts:44, src/features/mention/useMention.ts:47- the
todo|question|askregexes at ToolPartView.tsx:474 / MessageRenderer.tsx:953 — narrow toquestion|ask
Snapshot: npx tsc --noEmit -p tsconfig.app.json → 239 errors (154 in src/types/api + src/api).
UI-file errors grouped by cause:
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.
| 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[] |
| # | 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 |
| 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 |
- Build the projection in one place — extend src/utils/messageConversion.ts
(it already tolerates both envelope keys) and add a sibling
sessionConversion.tsexposingtoUiSession(SessionInfo): Session. - Apply the session projection at the fetch boundary (
getSessions,getSession,createSession,updateSession,forkSession, and thesession.created/session.updatedevent handlers) so the 123.directoryread sites stay untouched. - Delete todo outright — it cannot be projected.
- Plan real UI work for Form fields and the retry/error shape; those are designs, not mappings.
- Re-point src/api/sdk.test.ts:10 from
@opencode-ai/sdk/v2/clientto@opencode/client/promise, and delete the stale@opencode-ai/sdk/v2imports fromsrc/types/api/*— as long as both surfaces are imported, v1 fields likeshare/summary/version/Todo/question.*will keep type-checking and the migration will silently stall.