From 96717c1a8c25ae69a9e276a4b897defbe51e8bba Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 6 Jul 2026 18:58:34 -0400 Subject: [PATCH] feat(tui): improve session fork handling --- .opencode/skills/debug-opencode/SKILL.md | 163 ------------------ packages/cli/AGENTS.md | 95 +--------- .../client/src/promise/generated/types.ts | 4 + packages/core/schema.json | 26 ++- packages/core/src/database/migration.gen.ts | 1 + .../20260706223930_add-session-fork.ts | 39 +++++ packages/core/src/database/schema.gen.ts | 2 + packages/core/src/session.ts | 24 ++- packages/core/src/session/info.ts | 6 + packages/core/src/session/projector.ts | 4 +- .../core/src/session/runner/to-llm-message.ts | 21 +++ packages/core/src/session/sql.ts | 2 + packages/core/test/database-migration.test.ts | 34 ++++ packages/core/test/session-create.test.ts | 4 +- packages/core/test/session-prompt.test.ts | 28 +-- .../core/test/session-runner-message.test.ts | 29 ++++ packages/schema/src/session.ts | 5 + packages/sdk/js/src/v2/gen/types.gen.ts | 8 + packages/tui/src/app.tsx | 22 +-- .../session/dialog-fork-from-timeline.tsx | 88 ---------- .../tui/src/routes/session/dialog-fork.tsx | 89 ++++++++++ .../tui/src/routes/session/dialog-message.tsx | 8 +- .../src/routes/session/dialog-timeline.tsx | 6 +- packages/tui/src/routes/session/index.tsx | 16 +- 24 files changed, 336 insertions(+), 388 deletions(-) delete mode 100644 .opencode/skills/debug-opencode/SKILL.md create mode 100644 packages/core/src/database/migration/20260706223930_add-session-fork.ts delete mode 100644 packages/tui/src/routes/session/dialog-fork-from-timeline.tsx create mode 100644 packages/tui/src/routes/session/dialog-fork.tsx diff --git a/.opencode/skills/debug-opencode/SKILL.md b/.opencode/skills/debug-opencode/SKILL.md deleted file mode 100644 index 01e0ca3523..0000000000 --- a/.opencode/skills/debug-opencode/SKILL.md +++ /dev/null @@ -1,163 +0,0 @@ ---- -name: debug-opencode -description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector. ---- - -# Debugging opencode itself - -Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise. - -## Migration context - -- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state. -- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI (see "Comparing V2 against the legacy TUI" below) rather than guessing. - -## Server/client model - -opencode V2 is a client/server system, not a single monolithic process: - -- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`). -- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`). -- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible. -- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself. -- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one. -- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions. -- Every log line is tagged `role=server` or `role=cli` and a per-process `run=`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes. - -## Starting the dev TUI - -- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI. -- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server. - -## Interactive debugging with termctrl - -- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots. -- Use a dedicated session name and do not reuse or kill an unrelated session. - -```bash -termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev -termctrl wait opencode-v2-dev "Ask anything" --timeout 20000 -termctrl show opencode-v2-dev -``` - -- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`. -- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input. - -```bash -termctrl send opencode-v2-dev 'text:example prompt' enter -termctrl send opencode-v2-dev ctrl-c -``` - -- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits. -- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed. - -```bash -termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png -``` - -- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again: - -```bash -termctrl resize opencode-v2-dev --cols 100 --rows 30 -termctrl show opencode-v2-dev -``` - -- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change. -- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`. -- Always clean up the Terminal Control session when the check is complete: - -```bash -termctrl stop opencode-v2-dev -``` - -## Comparing V2 against the legacy TUI - -Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states: - -```bash -# From packages/cli: local V2 TUI -termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev - -# Released legacy TUI behavior reference -termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest - -termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png -termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png -``` - -- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints. -- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`. - -## Server/API debugging - -- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI. -- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering. -- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path: - -```bash -bun dev api get /health -bun dev api get /openapi.json -bun dev api --param key=value -``` - -- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`. -- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control. -- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters. - -## Auditing installed `opencode2` sessions - -Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy. - -For a supplied `ses_...` ID, compare three sources: - -- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state. -- The database's ordered `event` rows for durable history. -- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering. - -Locate an uncertain database without modifying it: - -```bash -SESSION=ses_... -for db in ~/.local/share/opencode/*.db; do - sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db" -done -``` - -## Logs - -- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine. -- Each line is structured `key=value` text: `timestamp`, `level`, `run=` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file. -- Tail the live file while reproducing an issue instead of guessing from stale output: - -```bash -tail -f ~/.local/share/opencode/log/opencode-local.log -``` - -- Filter to one run or role when the file is noisy: - -```bash -grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log -grep 'role=server' ~/.local/share/opencode/log/opencode-local.log -``` - -- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro. -- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file. -- `termctrl logs ` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead. - -## Debugger - -- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL: - -```bash -termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \ - bun run --inspect=ws://localhost:6499/ src/index.ts -``` - -- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches. -- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI. - -## Verification - -- Run `bun typecheck` from `packages/cli` after CLI adapter changes. -- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root. -- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 209f03d623..73bebeb94f 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -3,96 +3,5 @@ ## Migration context - The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state. -- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI. -- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states: - -```bash -# From packages/cli: local V2 TUI -termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev - -# Released legacy TUI behavior reference -termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest - -termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png -termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png -``` - -- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints. -- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`. - -## Interactive debugging - -- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI. -- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server. -- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots. -- Use a dedicated session name and do not reuse or kill an unrelated session. - -```bash -termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev -termctrl wait opencode-v2-dev "Ask anything" --timeout 20000 -termctrl show opencode-v2-dev -``` - -- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`. -- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input. - -```bash -termctrl send opencode-v2-dev 'text:example prompt' enter -termctrl send opencode-v2-dev ctrl-c -``` - -- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits. -- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed. - -```bash -termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png -``` - -- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again: - -```bash -termctrl resize opencode-v2-dev --cols 100 --rows 30 -termctrl show opencode-v2-dev -``` - -- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change. -- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`. -- Always clean up the Terminal Control session when the check is complete: - -```bash -termctrl stop opencode-v2-dev -``` - -## Server/API debugging - -- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI. -- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering. -- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path: - -```bash -bun dev api get /health -bun dev api get /openapi.json -bun dev api --param key=value -``` - -- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`. -- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control. -- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters. - -## Debugger - -- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL: - -```bash -termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \ - bun run --inspect=ws://localhost:6499/ src/index.ts -``` - -- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches. -- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI. - -## Verification - -- Run `bun typecheck` from `packages/cli` after CLI adapter changes. -- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root. -- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state. +- Preserve established TUI behavior unless the task intentionally changes it. +- Load the `opencode-dev` skill before interactively running, debugging, or verifying opencode's V2 CLI, TUI, or server. diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 3559c0f178..f52039b6af 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -326,6 +326,7 @@ export type SessionListOutput = { readonly data: ReadonlyArray<{ readonly id: string readonly parentID?: string + readonly fork?: { readonly sessionID: string; readonly messageID?: string } readonly projectID: string readonly agent?: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } @@ -388,6 +389,7 @@ export type SessionCreateOutput = { readonly data: { readonly id: string readonly parentID?: string + readonly fork?: { readonly sessionID: string; readonly messageID?: string } readonly projectID: string readonly agent?: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } @@ -426,6 +428,7 @@ export type SessionGetOutput = { readonly data: { readonly id: string readonly parentID?: string + readonly fork?: { readonly sessionID: string; readonly messageID?: string } readonly projectID: string readonly agent?: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } @@ -465,6 +468,7 @@ export type SessionForkOutput = { readonly data: { readonly id: string readonly parentID?: string + readonly fork?: { readonly sessionID: string; readonly messageID?: string } readonly projectID: string readonly agent?: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } diff --git a/packages/core/schema.json b/packages/core/schema.json index 222cc7b399..4bd411e53b 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "992b24b9-f3e9-41f5-87a5-4917d1423169", + "id": "95328a41-789d-44de-9643-6ac6ecd6b4ec", "prevIds": [ - "96e9fe64-660f-4a73-9414-b38bb7eac290" + "992b24b9-f3e9-41f5-87a5-4917d1423169" ], "ddl": [ { @@ -1166,6 +1166,26 @@ "entityType": "columns", "table": "session" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fork_session_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fork_message_id", + "entityType": "columns", + "table": "session" + }, { "type": "text", "notNull": true, @@ -2252,4 +2272,4 @@ } ], "renames": [] -} +} \ No newline at end of file diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index adb443ae0e..e841868335 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -46,5 +46,6 @@ export const migrations = ( import("./migration/20260703190000_reset_v2_shell_event_payloads"), import("./migration/20260703200000_reset_v2_session_events"), import("./migration/20260705180000_rename_instructions"), + import("./migration/20260706223930_add-session-fork"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260706223930_add-session-fork.ts b/packages/core/src/database/migration/20260706223930_add-session-fork.ts new file mode 100644 index 0000000000..2d7b09b34a --- /dev/null +++ b/packages/core/src/database/migration/20260706223930_add-session-fork.ts @@ -0,0 +1,39 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260706223930_add-session-fork", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_session_id\` text;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_message_id\` text;`) + yield* tx.run(` + UPDATE \`session\` + SET + \`parent_id\` = NULL, + \`fork_session_id\` = ( + SELECT json_extract(\`event\`.\`data\`, '$.parentID') + FROM \`event\` + WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\` + AND \`event\`.\`type\` = 'session.forked' + ORDER BY \`event\`.\`seq\` + LIMIT 1 + ), + \`fork_message_id\` = ( + SELECT json_extract(\`event\`.\`data\`, '$.from') + FROM \`event\` + WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\` + AND \`event\`.\`type\` = 'session.forked' + ORDER BY \`event\`.\`seq\` + LIMIT 1 + ) + WHERE EXISTS ( + SELECT 1 + FROM \`event\` + WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\` + AND \`event\`.\`type\` = 'session.forked' + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index dfa24f9b94..2409262316 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -196,6 +196,8 @@ export default { \`project_id\` text NOT NULL, \`workspace_id\` text, \`parent_id\` text, + \`fork_session_id\` text, + \`fork_message_id\` text, \`slug\` text NOT NULL, \`directory\` text NOT NULL, \`path\` text, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b26b072dcf..479ffa5cdc 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -746,6 +746,7 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct start: undefined, end: undefined, name: undefined, + mime: undefined, } : yield* readFileAttachment(fs, input.uri) if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES) @@ -754,7 +755,7 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`, }) - const mime = Mime.detect(resolved.bytes) + const mime = resolved.mime ?? Mime.detect(resolved.bytes) const content = mime === "text/plain" && resolved.start !== undefined ? Buffer.from( @@ -791,6 +792,25 @@ const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* ( const info = yield* fs.stat(target).pipe( Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), ) + if (info.type === "Directory") { + const entries = yield* fs.readDirectoryEntries(target).pipe( + Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), + ) + return { + bytes: Buffer.from( + entries + .filter((entry) => entry.type === "file" || entry.type === "directory") + .sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1)) + .map((entry) => entry.name + (entry.type === "directory" ? path.sep : "")) + .join("\n"), + ), + source: { type: "uri" as const, uri }, + start: undefined, + end: undefined, + name: path.basename(target), + mime: "application/x-directory", + } + } if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` }) if (Number(info.size) > MAX_ATTACHMENT_BYTES) return yield* new AttachmentError({ @@ -800,7 +820,7 @@ const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* ( const bytes = yield* fs.readFile(target).pipe( Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), ) - return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target) } + return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined } }) function decodeDataURL(uri: string) { diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 66832750fd..1c8ddf153d 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -17,6 +17,12 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In projectID: ProjectV2.ID.make(row.project_id), title: row.title, parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined, + fork: row.fork_session_id + ? { + sessionID: SessionSchema.ID.make(row.fork_session_id), + messageID: row.fork_message_id ? SessionMessage.ID.make(row.fork_message_id) : undefined, + } + : undefined, agent: row.agent ? AgentV2.ID.make(row.agent) : undefined, model: row.model ? { diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index cda0cd7d7b..e531ebcfdf 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -190,7 +190,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .insert(SessionTable) .values({ id: event.data.sessionID, - parent_id: event.data.parentID, + parent_id: null, + fork_session_id: event.data.parentID, + fork_message_id: event.data.from, project_id: parent.project_id, workspace_id: parent.workspace_id, slug: Slug.create(), diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 39df522866..bce9443d12 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -41,6 +41,26 @@ const textAttachment = (file: FileAttachment) => }, }) +const directoryAttachment = (file: FileAttachment) => + Message.make({ + role: "user", + content: [ + `Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`, + file.description === undefined ? undefined : `Description: ${file.description}`, + file.data.length === 0 ? undefined : "", + file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"), + ] + .filter((line): line is string => line !== undefined) + .join("\n"), + metadata: { + attachment: { + source: file.source, + name: file.name, + description: file.description, + }, + }, + }) + const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) const providerMetadata = ( @@ -157,6 +177,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess const files = message.files ?? [] return [ ...files.filter((file) => file.mime === "text/plain").map(textAttachment), + ...files.filter((file) => file.mime === "application/x-directory").map(directoryAttachment), Message.make({ id: message.id, role: "user", diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 3e6f79863c..97b908bee1 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -29,6 +29,8 @@ export const SessionTable = sqliteTable( .references(() => ProjectTable.id, { onDelete: "cascade" }), workspace_id: text().$type(), parent_id: text().$type(), + fork_session_id: text().$type(), + fork_message_id: text().$type(), slug: text().notNull(), directory: directoryColumn().notNull(), path: pathColumn(), diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index c6384c55de..c5f783e574 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -17,6 +17,7 @@ import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/ import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events" import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions" +import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -156,6 +157,39 @@ describe("DatabaseMigration", () => { ) }) + test("separates existing fork provenance from subagent hierarchy", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, parent_id text)`) + yield* db.run( + sql`CREATE TABLE event (aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`INSERT INTO session VALUES ('ses_source', NULL), ('ses_fork', 'ses_source')`) + yield* db.run( + sql`INSERT INTO event VALUES ('ses_fork', 0, 'session.forked', '{"sessionID":"ses_fork","parentID":"ses_source","from":"msg_boundary"}')`, + ) + + yield* DatabaseMigration.applyOnly(db, [addSessionForkMigration]) + + expect( + yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_fork'`), + ).toEqual({ + parent_id: null, + fork_session_id: "ses_source", + fork_message_id: "msg_boundary", + }) + expect( + yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_source'`), + ).toEqual({ + parent_id: null, + fork_session_id: null, + fork_message_id: null, + }) + }), + ) + }) + test("renames instruction state without losing rows or durable updates", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index fc3b4d127d..0a2e57edf5 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -206,7 +206,8 @@ describe("SessionV2.create", () => { const forkContext = yield* session.context(forked.id) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) - expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" }) + expect(forked).toMatchObject({ title: "Parent (fork #1)", fork: { sessionID: parent.id } }) + expect(forked.parentID).toBeUndefined() expect(forkContext).toMatchObject([ { type: "user", text: "First" }, { type: "synthetic", text: "parent note", sessionID: forked.id }, @@ -264,6 +265,7 @@ describe("SessionV2.create", () => { const context = yield* session.context(forked.id) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) + expect(forked.fork).toEqual({ sessionID: parent.id, messageID: second.id }) expect(context).toMatchObject([{ text: "First" }]) expect(context[0]?.id).not.toBe(first.id) expect(history[0]).toMatchObject({ data: { from: second.id } }) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 2890fb1fcc..31909c0e76 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -283,25 +283,27 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("rejects directories as file attachments", () => + it.effect("materializes directories as directory attachments", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const uri = pathToFileURL(import.meta.dir).href - const error = yield* session - .prompt({ - sessionID, - prompt: { text: "Inspect this", files: [{ uri, name: "source" }] }, - resume: false, - }) - .pipe(Effect.flip) - - expect(error).toMatchObject({ - _tag: "Session.AttachmentError", - uri, - message: `Attachment is not a file: ${uri}`, + const message = yield* session.prompt({ + sessionID, + prompt: { text: "Inspect this", files: [{ uri, name: "source" }] }, + resume: false, }) + + expect(message.prompt.files).toHaveLength(1) + expect(message.prompt.files?.[0]).toMatchObject({ + mime: "application/x-directory", + source: { type: "uri", uri }, + name: "source", + }) + expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain( + "session-prompt.test.ts", + ) }), ) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 14771c5b9e..a7852c292a 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -217,6 +217,35 @@ Recent work ]) }) + test("lowers directory attachments as directory context", () => { + const directory = FileAttachment.make({ + data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")), + mime: "application/x-directory", + source: { type: "uri", uri: "file:///project/src" }, + name: "src/", + }) + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-directory"), + type: "user", + text: "Review this directory", + files: [directory], + time: { created }, + }), + ], + model, + ) + + expect(messages).toHaveLength(2) + expect(messages[0]).toMatchObject({ + role: "user", + content: [{ type: "text", text: "Attached directory: src/\n\nlib/\nindex.ts" }], + metadata: { attachment: { source: directory.source, name: "src/" } }, + }) + expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }]) + }) + test("uses materialized image data as provider media and drops unsupported attachments", () => { const data = Base64.make("AAECAw==") const messages = toLLMMessages( diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index 2ae169c138..1d5a5ffef0 100644 --- a/packages/schema/src/session.ts +++ b/packages/schema/src/session.ts @@ -8,6 +8,7 @@ import { Project } from "./project.js" import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema.js" import { SessionEvent } from "./session-event.js" import { SessionID } from "./session-id.js" +import { SessionMessage } from "./session-message.js" import { Revert } from "./revert.js" export const ID = SessionID @@ -19,6 +20,10 @@ export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, parentID: ID.pipe(optional), + fork: Schema.Struct({ + sessionID: ID, + messageID: SessionMessage.ID.pipe(optional), + }).pipe(optional), projectID: Project.ID, agent: Agent.ID.pipe(optional), model: Model.Ref.pipe(optional), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f1e2076e7e..3b4854bcd0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4330,6 +4330,10 @@ export type PluginInfo = { export type SessionV2Info = { id: string parentID?: string + fork?: { + sessionID: string + messageID?: string + } projectID: string agent?: string model?: ModelRef @@ -8541,6 +8545,10 @@ export type RevertStateV2 = { export type SessionV2InfoV2 = { id: string parentID?: string + fork?: { + sessionID: string + messageID?: string + } projectID: string agent?: string model?: ModelRef diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index fa36ad1839..c2d02452d9 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -559,13 +559,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi route.navigate({ type: "session", sessionID: match }) return } - void sdk.client.session.fork({ sessionID: match }).then((result) => { - if (result.data?.id) { - route.navigate({ type: "session", sessionID: result.data.id }) - return - } - toast.show({ message: "Failed to fork session", variant: "error" }) - }) + void sdk.api.session + .fork({ sessionID: match }) + .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .catch(toast.error) }) .catch(toast.error) }) @@ -577,13 +574,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi createEffect(() => { if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return forked = true - void sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => { - if (result.data?.id) { - route.navigate({ type: "session", sessionID: result.data.id }) - } else { - toast.show({ message: "Failed to fork session", variant: "error" }) - } - }) + void sdk.api.session + .fork({ sessionID: args.sessionID }) + .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .catch(toast.error) }) const connected = useConnected() diff --git a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx b/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx deleted file mode 100644 index d0952a907e..0000000000 --- a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { createMemo, onMount } from "solid-js" -import { useSync } from "../../context/sync" -import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select" -import type { TextPart } from "@opencode-ai/sdk/v2" -import { Locale } from "../../util/locale" -import { useSDK } from "../../context/sdk" -import { useRoute } from "../../context/route" -import { useDialog, type DialogContext } from "../../ui/dialog" -import { emptyPrompt, type PromptInfo } from "../../component/prompt/history" - -export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) { - const sync = useSync() - const dialog = useDialog() - const sdk = useSDK() - const route = useRoute() - - onMount(() => { - dialog.setSize("large") - }) - - const options = createMemo((): DialogSelectOption[] => { - const messages = sync.data.message[props.sessionID] ?? [] - const fullSession = { - title: "Full session", - value: undefined, - onSelect: async (dialog: DialogContext) => { - const forked = await sdk.client.session.fork({ sessionID: props.sessionID }) - route.navigate({ - sessionID: forked.data!.id, - type: "session", - }) - dialog.clear() - }, - } satisfies DialogSelectOption - const result = [] as DialogSelectOption[] - for (const message of messages) { - if (message.role !== "user") continue - const part = (sync.data.part[message.id] ?? []).find( - (x) => x.type === "text" && !x.synthetic && !x.ignored, - ) as TextPart - if (!part) continue - result.push({ - title: part.text.replace(/\n/g, " "), - value: message.id, - footer: Locale.time(message.time.created), - onSelect: async (dialog) => { - const forked = await sdk.client.session.fork({ - sessionID: props.sessionID, - messageID: message.id, - }) - const parts = sync.data.part[message.id] ?? [] - const prompt = parts.reduce( - (agg, part) => { - if (part.type === "text") { - if (!part.synthetic) agg.text += part.text - } - if (part.type === "file") { - const files = (agg.files ??= []) - files.push({ - uri: part.url, - name: part.filename, - mention: part.source?.text - ? { - start: part.source.text.start, - end: part.source.text.end, - text: part.source.text.value, - } - : undefined, - }) - } - return agg - }, - emptyPrompt() as PromptInfo, - ) - route.navigate({ - sessionID: forked.data!.id, - type: "session", - prompt, - }) - dialog.clear() - }, - }) - } - return [fullSession, ...result.reverse()] - }) - - return props.onMove(option.value)} title="Fork session" options={options()} /> -} diff --git a/packages/tui/src/routes/session/dialog-fork.tsx b/packages/tui/src/routes/session/dialog-fork.tsx new file mode 100644 index 0000000000..c6eb91594d --- /dev/null +++ b/packages/tui/src/routes/session/dialog-fork.tsx @@ -0,0 +1,89 @@ +import { createMemo, createSignal, onMount, Show } from "solid-js" +import { useData } from "../../context/data" +import { useRoute } from "../../context/route" +import { useSDK } from "../../context/sdk" +import { Spinner } from "../../component/spinner" +import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select" +import { useDialog } from "../../ui/dialog" +import { useToast } from "../../ui/toast" +import { errorMessage } from "../../util/error" +import { Locale } from "../../util/locale" + +export function DialogFork(props: { sessionID: string; messageID?: string; onMove?: (messageID?: string) => void }) { + const data = useData() + const dialog = useDialog() + const sdk = useSDK() + const route = useRoute() + const toast = useToast() + const [pending, setPending] = createSignal(false) + + const fork = async (messageID?: string) => { + setPending(true) + const result = await sdk.api.session.fork({ sessionID: props.sessionID, messageID }).catch((error) => { + toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) + return undefined + }) + if (!result) return dialog.clear() + const message = messageID ? data.session.message.get(props.sessionID, messageID) : undefined + route.navigate({ + sessionID: result.id, + type: "session", + prompt: + message?.type === "user" + ? { + text: message.text, + files: message.files?.map((file) => ({ + uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, + name: file.name, + description: file.description, + mention: file.mention, + })), + agents: structuredClone(message.agents ?? []), + pasted: [], + } + : undefined, + }) + dialog.clear() + toast.show({ message: "Forked session", variant: "success", duration: 4000 }) + } + + onMount(() => { + dialog.setSize("large") + if (props.messageID) void fork(props.messageID) + }) + + const options = createMemo((): DialogSelectOption[] => [ + { + title: "Full session", + value: undefined, + onSelect: () => fork(), + }, + ...data.session.message + .list(props.sessionID) + .filter((message) => message.type === "user") + .toReversed() + .map((message) => ({ + title: message.text.replace(/\n/g, " "), + value: message.id, + footer: Locale.time(message.time.created), + onSelect: () => fork(message.id), + })), + ]) + + return ( + + Forking session... + + } + > + props.onMove?.(option.value)} + title="Fork session" + options={options()} + /> + + ) +} diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index 240be47408..f31b14868a 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -5,8 +5,9 @@ import { useClipboard } from "../../context/clipboard" import { useToast } from "../../ui/toast" import { useSDK } from "../../context/sdk" import { errorMessage } from "../../util/error" +import { DialogFork } from "./dialog-fork" -export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) { +export function DialogMessage(props: { messageID: string; sessionID: string }) { const data = useData() const clipboard = useClipboard() const toast = useToast() @@ -55,8 +56,9 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set value: "session.fork", description: "create a new session", onSelect: (dialog) => { - toast.show({ message: "Forking is not implemented for V2 sessions yet", variant: "error", duration: 5000 }) - dialog.clear() + const value = message() + if (!value || value.type !== "user") return + dialog.replace(() => ) }, }, ]} diff --git a/packages/tui/src/routes/session/dialog-timeline.tsx b/packages/tui/src/routes/session/dialog-timeline.tsx index bda87119d4..b3e162e9ad 100644 --- a/packages/tui/src/routes/session/dialog-timeline.tsx +++ b/packages/tui/src/routes/session/dialog-timeline.tsx @@ -5,12 +5,10 @@ import type { TextPart } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { DialogMessage } from "./dialog-message" import { useDialog } from "../../ui/dialog" -import type { PromptInfo } from "../../component/prompt/history" export function DialogTimeline(props: { sessionID: string onMove: (messageID: string) => void - setPrompt?: (prompt: PromptInfo) => void }) { const sync = useSync() const dialog = useDialog() @@ -33,9 +31,7 @@ export function DialogTimeline(props: { value: message.id, footer: Locale.time(message.time.created), onSelect: (dialog) => { - dialog.replace(() => ( - - )) + dialog.replace(() => ) }, }) } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 0972a3545e..7b70f26375 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -46,6 +46,7 @@ import { useDialog } from "../../ui/dialog" import { DialogSessionRename } from "../../component/dialog-session-rename" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" +import { DialogFork } from "./dialog-fork" import { Sidebar } from "./sidebar" import { Composer } from "./composer" import { filetype } from "../../util/filetype" @@ -370,7 +371,18 @@ export function Session() { value: "session.fork", category: "Session", slash: { name: "fork" }, - run: () => unavailable("Forking"), + run: () => { + dialog.replace(() => ( + { + if (!messageID) return + const child = scroll.getChildren().find((child) => child.id === messageID) + if (child) scroll.scrollBy(child.y - scroll.y - 1) + }} + /> + )) + }, }, { title: "Compact session", @@ -1405,7 +1417,7 @@ function UserMessage(props: { message: SessionMessageUser }) {