Skip to content

feat(opencode): import local session history - #1677

Open
junmo-kim wants to merge 14 commits into
tiann:mainfrom
junmo-kim:feat/opencode-session-import
Open

feat(opencode): import local session history#1677
junmo-kim wants to merge 14 commits into
tiann:mainfrom
junmo-kim:feat/opencode-session-import

Conversation

@junmo-kim

@junmo-kim junmo-kim commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

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:

  • The CLI reads OpenCode's SQLite store ($XDG_DATA_HOME/opencode/opencode.db,
    readonly via bun:sqlite, no new dependency) and lists sessions over the
    existing machine RPC channel. Only the SQLite-backed format is supported —
    installations without opencode.db simply report an empty list.
  • The hub exposes GET /api/opencode/sessions and
    POST /api/opencode/import-sessions. Import creates a HAPI session with
    flavor: 'opencode' + opencodeSessionId, appends transcript messages as
    imported 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.
  • Resume works out of the box: imported sessions carry the native OpenCode
    session id, so reopen continues the same conversation through the existing
    opencodeSessionId resume path.
  • Web gets an "Import OpenCode history" entry in the new-session picker,
    mirroring the Pi/Codex dialogs.

Tests

  • Unit tests for the scanner (summaries, envelope conversion, synthetic-text
    skip, archived-session handling, missing db / missing tables fail-closed).
  • Hub route tests covering list annotation, create, delta re-import,
    divergence guard, and the active-session guard.
  • Web component test for the import actions.
  • Manually verified end-to-end against a real local OpenCode database through
    an isolated hub+runner stack (import → messages render → idempotent
    re-import → delta append on new native messages).

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

Copy link
Copy Markdown
Contributor Author

Closing — opened by mistake, not ready for review.

@junmo-kim junmo-kim closed this Aug 23, 2026
@junmo-kim junmo-kim reopened this Aug 23, 2026
@junmo-kim junmo-kim changed the title feat: import OpenCode sessions into HAPI feat(opencode): import local session history Aug 23, 2026
@junmo-kim
junmo-kim marked this pull request as ready for review August 23, 2026 09:17

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Delta re-import can duplicate or reorder OpenCode history — classifyImportDelta treats every unseen opencode: 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. Evidence hub/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. Evidence web/src/components/NewSession/index.tsx:1628, related creation path hub/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

Comment thread hub/src/web/routes/opencodeSessions.ts Outdated
Comment thread web/src/components/NewSession/index.tsx
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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, including yolo. Evidence web/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

Comment thread web/src/components/NewSession/index.tsx
…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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Default launch selections still do not reset persisted values — the route converts default model/effort to null and receives permission mode default, but lines 311-314 omit all three keys before applySessionConfig. Since inactive-session config applies changes by property presence, a previously imported session configured with a custom model, effort, or yolo reopens with those old values despite the current selections. Evidence hub/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.end at cli/src/opencode/utils/opencodeStorageScanner.ts:764. Importing during generation therefore stores partial text, and the later finalized row makes re-import fail as transcript_diverged. Evidence cli/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

Comment thread hub/src/web/routes/opencodeSessions.ts Outdated
Comment thread cli/src/modules/common/opencodeSessions.ts
…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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Latest head does not typecheck — the new reset regression test passes a string | undefined value where sessionId requires string, so the required GitHub test job fails before tests run. Evidence hub/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 — collectTextParts converts any SQLite error into an empty part list, and buildSessionMessages returns 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 as transcript_diverged. The machine RPC already converts thrown scanner errors into a failure response, so full-transcript reads should propagate these errors. Evidence cli/src/modules/common/opencodeSessions.ts:108 and cli/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

Comment thread hub/src/web/routes/opencodeSessions.test.ts Outdated
Comment thread cli/src/modules/common/opencodeSessions.ts Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] SQLite query errors can still produce a successful partial import — collectTextParts turns a failed part query into an empty result, while buildSessionMessages returns messages collected before any later failure. Importing that holey sequence makes the next healthy snapshot fail the prefix check as transcript_diverged, so the omitted history cannot be repaired. Evidence cli/src/modules/common/opencodeSessions.ts:108 and cli/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

Comment thread cli/src/modules/common/opencodeSessions.ts Outdated
…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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Keep launch-config failures inside the per-session result - the transcript is already persisted before applySessionConfig runs. 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. Evidence hub/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 part before the call makes hasRequiredTables() return false at cli/src/modules/common/opencodeSessions.ts:253, so this test exits before any message or part query and does not cover the regression it names. Evidence cli/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, and drift-gate checks passed.

HAPI Bot

Comment thread hub/src/web/routes/opencodeSessions.ts Outdated
Comment thread cli/src/modules/common/opencodeSessions.test.ts
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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in classifyImportDelta() makes the completed transcript fail every re-import as transcript_diverged. Evidence cli/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 exec to DatabaseLike, and start the transaction before deriving lastUserMessage if 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, and test checks 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

Comment thread cli/src/modules/common/opencodeSessions.ts
…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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant