feat(opencode): import local session history - #1677
Conversation
Random ids made every scan differ, so delta classification flagged unchanged transcripts as diverged on re-import.
A locked or unopenable opencode.db now fails the listing instead of reporting an empty success; summaries normalize timestamps and the web import toast counts only created or actually-appended sessions.
|
Closing — opened by mistake, not ready for review. |
There was a problem hiding this comment.
Findings
- [Major] Delta re-import can duplicate or reorder OpenCode history —
classifyImportDeltatreats every unseenopencode:id as an append-only delta and ignores normal HAPI messages already stored after the import. Once an imported session is reopened, live user/agent messages are persisted without OpenCode source ids (cli/src/api/apiSession.ts:1016,cli/src/api/apiSession.ts:1046); re-importing the same native rows appends duplicates. A newly added part with an earlier source position is likewise appended at the end. Evidencehub/src/web/routes/opencodeSessions.ts:141.
Suggested fix:const sourceIds = transcript.messages.map((message) => message.localId) const storedIds = storedImported.map((message) => message.localId!) if (storedIds.some((id, index) => sourceIds[index] !== id)) return diverged() if (hasNonImportedHapiTail(existing, storedImported) && sourceIds.length > storedIds.length) return diverged() return { messages: transcript.messages.slice(storedIds.length) }
- [Major] Selected launch settings are ignored before reopen — the new OpenCode branch computes model, reasoning effort, and permission selections, but sends only ids/cwd/machine and immediately reopens the persisted session. New imports therefore use defaults; existing imports can retain an older permission mode (including
yolo) despite the visible selection. Evidenceweb/src/components/NewSession/index.tsx:1628, related creation pathhub/src/web/routes/opencodeSessions.ts:175.
Suggested fix:const result = await props.api.importOpencodeSessions({ sessionIds: [selectedOpencodeImportSession.id], cwd: selectedOpencodeImportSession.cwd ?? trimmedDirectory, machineId: opencodeImportMachineId ?? machineId, model: resolvedModel ?? null, modelReasoningEffort: resolvedModelReasoningEffort ?? null, permissionMode: codexFamilyPermissionMode }) // Hub: engine.applySessionConfig(importedSessionId, selectedConfig) before reopen.
Summary
Review mode: initial
Two major correctness issues found in delta reconciliation and import-and-continue configuration.
Testing
Not run locally (automation; executing PR code is prohibited by the review prompt). Current GitHub test and integration checks pass. Add regression coverage for re-import after HAPI continuation, insertion before the imported boundary, and selected OpenCode launch config on reopen.
HAPI Bot
Imported entries must remain a strict prefix of the native transcript. Insertions ahead of the imported boundary or new native messages after the session continued in HAPI now fail closed instead of appending duplicated or reordered history.
There was a problem hiding this comment.
Findings
- [Major] Selected OpenCode launch settings are ignored before reopen -- the branch computes and displays model, reasoning-effort, and permission selections, but the import request sends only the session/path/machine and immediately reopens the persisted row. The inactive-session configuration path exists in
SyncEngine.applySessionConfig, so a new import launches with defaults and a previously imported row can retain an older permission mode, includingyolo. Evidenceweb/src/components/NewSession/index.tsx:1630.
Suggested fix:const launchConfig = { model: resolvedModel ?? null, modelReasoningEffort: resolvedModelReasoningEffort ?? null, permissionMode: codexFamilyPermissionMode } const result = await props.api.importOpencodeSessions({ sessionIds: [selectedOpencodeImportSession.id], cwd: selectedOpencodeImportSession.cwd ?? trimmedDirectory, machineId: opencodeImportMachineId ?? machineId, launchConfig }) // Hub: validate launchConfig, then before reopen: await engine.applySessionConfig(importedSessionId, launchConfig)
Summary
Review mode: follow-up after new commits
One Major correctness issue remains in the import-and-continue configuration path.
Testing
Not run (automation; executing PR code is prohibited by the review prompt). GitHub test and integration checks pass. Missing regression coverage: selected OpenCode model, reasoning effort, and permission mode are persisted before reopen.
HAPI Bot
…story Pass the model, reasoning effort, and permission mode chosen in the new-session picker through the import request and persist them on the imported HAPI session via applySessionConfig, so reopen launches with the visible selection instead of stale or default values.
There was a problem hiding this comment.
Findings
- [Major] Default launch selections still do not reset persisted values — the route converts default model/effort to
nulland receives permission modedefault, but lines 311-314 omit all three keys beforeapplySessionConfig. Since inactive-session config applies changes by property presence, a previously imported session configured with a custom model, effort, oryoloreopens with those old values despite the current selections. Evidencehub/src/web/routes/opencodeSessions.ts:311.
Suggested fix:type LaunchConfig = Parameters<SyncEngine["applySessionConfig"]>[1] const launchConfig: LaunchConfig = {} const has = (key: string) => Object.prototype.hasOwnProperty.call(body ?? {}, key) if (has("model")) launchConfig.model = requestedModel if (has("modelReasoningEffort")) launchConfig.modelReasoningEffort = requestedModelReasoningEffort if (requestedPermissionMode) launchConfig.permissionMode = requestedPermissionMode as PermissionMode
- [Major] Import can persist a still-streaming assistant fragment — every text part is accepted without checking its completion marker. OpenCode updates the same part row while generating; the existing storage scanner gates non-user text on
time.endatcli/src/opencode/utils/opencodeStorageScanner.ts:764. Importing during generation therefore stores partial text, and the later finalized row makes re-import fail astranscript_diverged. Evidencecli/src/modules/common/opencodeSessions.ts:114.
Suggested fix:function collectTextParts(db: DatabaseLike, messageId: string, role: string) { // ...parse each text part... const time = asRecord(parsed.time) if (role !== "user" && parsed.synthetic !== true && typeof time?.end !== "number") continue texts.push({ id: partRow.id, text: parsed.text }) }
Summary
Review mode: follow-up after new commits
Two Major correctness issues remain in configuration reset semantics and transcript snapshot stability.
Testing
Not run (automation; executing PR code is prohibited by the review prompt). Add regression coverage for resetting an existing custom model/effort/yolo session to defaults, and for excluding an assistant text part without time.end until it is finalized.
HAPI Bot
…s on import - Launch config keys are applied by presence, so explicit null/default selections from the picker must reach applySessionConfig to clear stale model, effort, or permission values on re-import. Requests that omit the fields entirely still leave persisted config untouched. - Assistant text parts are mutable while a response streams. Mirror the live scanner gate and import only finished (time.end set), synthetic, or user text so an in-progress row can never be stored and later finalized as transcript_diverged.
There was a problem hiding this comment.
Findings
- [Major] Latest head does not typecheck — the new reset regression test passes a
string | undefinedvalue wheresessionIdrequiresstring, so the required GitHubtestjob fails before tests run. Evidencehub/src/web/routes/opencodeSessions.test.ts:349.
Suggested fix:const importedSessionId = body.results[0]?.hapiSessionId if (!importedSessionId) throw new Error('Imported session id missing') expect(applied).toEqual([{ sessionId: importedSessionId, config: { model: null, modelReasoningEffort: null, permissionMode: 'default' } }])
- [Major] Transcript query errors can still be returned as a successful partial snapshot —
collectTextPartsconverts any SQLite error into an empty part list, andbuildSessionMessagesreturns whatever it collected before an error. A failed query for a middle message can therefore persist[m1, m3]; the next healthy scan returns[m1, m2, m3], which the hub permanently rejects astranscript_diverged. The machine RPC already converts thrown scanner errors into a failure response, so full-transcript reads should propagate these errors. Evidencecli/src/modules/common/opencodeSessions.ts:108andcli/src/modules/common/opencodeSessions.ts:196.
Suggested fix:function collectTextParts(db: DatabaseLike, messageId: string, role: string) { const partRows = db.query( 'SELECT id, data FROM part WHERE message_id = ? ORDER BY time_created ASC' ).all(messageId) as Array<{ id: string; data: unknown }> // Keep skipping malformed JSON rows, but let database errors propagate. // ... } async function buildSessionMessages(...) { const messageRows = db.query(/* ... */).all(summary.id) // Build all messages without a catch that returns a partial prefix. }
Summary
Review mode: follow-up after new commits
The latest head remains blocked by a hub typecheck error, and the OpenCode scanner can still turn read failures into an unrecoverable partial import.
Testing
Not run locally (automation; executing PR code is prohibited by the review prompt). GitHub latest-head status: test failed during bun typecheck; drift-gate and integration passed.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] SQLite query errors can still produce a successful partial import —
collectTextPartsturns a failed part query into an empty result, whilebuildSessionMessagesreturns messages collected before any later failure. Importing that holey sequence makes the next healthy snapshot fail the prefix check astranscript_diverged, so the omitted history cannot be repaired. Evidencecli/src/modules/common/opencodeSessions.ts:108andcli/src/modules/common/opencodeSessions.ts:196.
Suggested fix:const partRows = db.query( `SELECT id, data FROM part WHERE message_id = ? ORDER BY time_created ASC` ).all(messageId) as Array<{ id: string; data: unknown }> // Build the full transcript without a catch that returns partial messages.
Summary
Review mode: follow-up after new commits
The follow-up fixes the prior typecheck blocker. One major data-integrity risk remains in transcript error handling.
Testing
Not run locally (automation; executing PR code is prohibited). GitHub integration and drift-gate passed; test was pending at review time. Add a scanner test whose injected database throws on a middle part query and assert the RPC fails instead of returning a partial transcript.
HAPI Bot
…artial snapshots Swallowed SQLite errors could return a truncated message list that the hub later rejects as transcript_diverged with no repair path. Full transcript reads now propagate query failures; summary lastUserMessage extraction stays resilient per message.
There was a problem hiding this comment.
Findings
- No reportable issues found.
Summary
Review mode: follow-up after new commits
The latest commit resolves the previously reported partial-transcript data-integrity issue: part-query failures now propagate instead of returning a holey message sequence. No additional issues were found in the full latest diff. Residual testing gap: the exact middle part-query failure path is not covered by a regression test.
Testing
Not run locally (automation; executing PR code is prohibited). GitHub test, integration, and drift-gate checks passed. Add a scanner test with an injected database that throws on a middle part query and assert listLocalOpencodeSessionsWithMessagesByIds rejects.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] Keep launch-config failures inside the per-session result - the transcript is already persisted before
applySessionConfigruns. If that call throws, the handler returns an unstructured 500, omits all results, and aborts the rest of a bulk request even though sessions may already have been created. Evidencehub/src/web/routes/opencodeSessions.ts:336
Suggested fix:try { await engine.applySessionConfig(result.hapiSessionId, launchConfig) } catch (error) { result.error = { code: 'config_failed', message: error instanceof Error ? error.message : 'Failed to apply OpenCode launch config' } }
- [Minor] Exercise the actual mid-transcript query failure - dropping
partbefore the call makeshasRequiredTables()return false atcli/src/modules/common/opencodeSessions.ts:253, so this test exits before any message or part query and does not cover the regression it names. Evidencecli/src/modules/common/opencodeSessions.test.ts:135
Suggested fix:let partReads = 0 const opener = async () => ({ query: (sql: string) => { if (/FROM part WHERE/.test(sql) && ++partReads === 2) throw new Error('injected part read failure') return db.query(sql) }, close: () => db.close() }) // Assert listLocalOpencodeSessionsWithMessagesByIds(..., opener) rejects.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The current head still has one partial-success error-path issue and one inadequate regression test.
Testing
- Not run locally (automation; executing PR code is prohibited). GitHub
test,integration, anddrift-gatechecks passed.
HAPI Bot
A thrown applySessionConfig call used to escape the bulk handler as an unstructured 500 even though transcripts were already persisted. Catch it per session and report it as a config_failed result so remaining sessions in a bulk request still import. Also cover the mid-transcript part-query failure with an injected opener that rejects instead of returning a holey transcript.
There was a problem hiding this comment.
Findings
-
[Major] Read the transcript from one SQLite snapshot —
buildSessionMessages()reads the message list and then each message's parts in separate statements. If OpenCode writes during that scan, the import can miss an earlier part while observing later messages; after that hole is persisted, the positional check inclassifyImportDelta()makes the completed transcript fail every re-import astranscript_diverged. Evidencecli/src/modules/common/opencodeSessions.ts:182.Suggested fix:
db.exec('BEGIN DEFERRED') try { const session = await buildSessionMessages(db, summary) db.exec('COMMIT') sessions.push(session) } catch (error) { try { db.exec('ROLLBACK') } catch {} throw error }
Add
exectoDatabaseLike, and start the transaction before derivinglastUserMessageif that summary must share the same snapshot.
Summary
Review mode: follow-up after new commits. One Major correctness issue remains. The new commits address both findings from the prior HAPI Bot review.
Testing
- Not run (automation). Current
drift-gate,integration, andtestchecks pass. Add a scanner test that mutates parts between the message-list read and later part reads, then verifies the imported transcript remains consistent and re-importable.
HAPI Bot
…ction Messages and their parts were read in separate statements, so a concurrent OpenCode write could leave a hole in the imported sequence that positional re-import checks then reject forever. Wrap each full transcript read in BEGIN DEFERRED/COMMIT with rollback on failure.
There was a problem hiding this comment.
Findings
- None.
Summary
Review mode: follow-up after new commits. No issues found at or above the 80% confidence threshold in the latest full diff. The latest commit addresses the prior snapshot-consistency Major by reading each OpenCode transcript inside one deferred SQLite transaction. Residual risk: the transaction path lacks a concurrent-writer regression test; the current scanner tests cover unreadable queries but do not exercise snapshot consistency under mutation.
Testing
- Not run (automation; PR code was not executed). GitHub checks at review time: drift-gate pass, integration pass, test pass.
HAPI Bot
Problem
OpenCode stores every conversation locally, but those sessions were invisible
once you started using HAPI: there was no way to bring an existing OpenCode
history into HAPI, unlike Codex (#536) and Pi (#975), which both already
support importing their local transcripts.
Solution
Mirror the existing Pi import flow for OpenCode:
$XDG_DATA_HOME/opencode/opencode.db,readonly via
bun:sqlite, no new dependency) and lists sessions over theexisting machine RPC channel. Only the SQLite-backed format is supported —
installations without
opencode.dbsimply report an empty list.GET /api/opencode/sessionsandPOST /api/opencode/import-sessions. Import creates a HAPI session withflavor: 'opencode'+opencodeSessionId, appends transcript messages asimported entries keyed by stable local ids (
opencode:<session>:<msg>:<part>),and only appends the delta on re-import. Diverged or active sessions fail
closed instead of being overwritten.
session id, so reopen continues the same conversation through the existing
opencodeSessionIdresume path.mirroring the Pi/Codex dialogs.
Tests
skip, archived-session handling, missing db / missing tables fail-closed).
divergence guard, and the active-session guard.
an isolated hub+runner stack (import → messages render → idempotent
re-import → delta append on new native messages).