mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e1de007f | |||
| a0c77b71ae | |||
| e62918224f | |||
| 30d140008f | |||
| a38d44265b | |||
| 0cce215de4 | |||
| a15ec425de | |||
| ae494ffce5 | |||
| 25c2150baf | |||
| 9314992f98 | |||
| 0fbe489e35 | |||
| 03e9789064 | |||
| 0c558a6856 | |||
| f297bd3b8b | |||
| 1bfc23f503 | |||
| 3839aafa25 | |||
| bd2f37bc90 | |||
| 120e4e7388 | |||
| 6f91bc7415 | |||
| c46f6ae112 | |||
| 285444aab4 | |||
| 56c33e84a3 | |||
| 73581b3c3b | |||
| 6e4d01f846 | |||
| 8646587c95 | |||
| fb47f06228 | |||
| 3acaa5a359 | |||
| 0726a25142 | |||
| 0df96ddeb0 | |||
| 887673310b | |||
| 693a1bff81 | |||
| b40ed3aa85 | |||
| 732bb9c3cb | |||
| 2fd1660b4d | |||
| 681526d348 | |||
| e78869c849 | |||
| 934935963d | |||
| f3912a2a8a | |||
| 45d58717a4 | |||
| 74e3155ef0 | |||
| 0af6c82563 | |||
| 686127f809 | |||
| 5256655c4d | |||
| d10b652637 | |||
| b03ca0d4e2 | |||
| 25aaea3d31 | |||
| cae7a139bc | |||
| 5ea62ab05f | |||
| faadc05c88 | |||
| 3e253c589e | |||
| 0a0fc09533 | |||
| 5aa0413fea | |||
| 6f4c199629 | |||
| ed8e1f4654 | |||
| 3b0195e045 | |||
| 143a776373 | |||
| 76b318e990 | |||
| c0ab35c3c2 | |||
| b1c1b79d15 | |||
| 4eb1b37d9b | |||
| f2b3a12910 | |||
| daa998f9c3 | |||
| c74a0d8529 | |||
| 330ef108ce | |||
| f9ac6f3171 | |||
| 62e5d73d45 | |||
| ab20a7b4a3 | |||
| ad9a95f6ee | |||
| 4ed343706d | |||
| 6d2eb2240a | |||
| 53b8111121 |
@@ -64,7 +64,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -112,6 +111,7 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"happy-dom": "20.11.1",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
@@ -489,18 +489,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.8",
|
||||
@@ -2067,8 +2055,6 @@
|
||||
|
||||
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
|
||||
|
||||
"@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"],
|
||||
|
||||
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
|
||||
|
||||
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
|
||||
@@ -6333,8 +6319,6 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/app/@opencode-ai/sdk": ["@opencode-ai/sdk@vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
|
||||
|
||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
|
||||
|
||||
@@ -5,8 +5,27 @@
|
||||
- Use the `dev` branch database schema and migration registry as the V1 baseline.
|
||||
- Remove migrations that exist only on the V2 branch.
|
||||
- Generate one canonical migration from the `dev` schema to the final V2 schema.
|
||||
- Add explicit data operations to that migration where generated DDL is insufficient.
|
||||
- Test the migration against a populated database at the exact `dev` schema.
|
||||
- Keep the canonical migration focused on schema changes and dropping obsolete tables.
|
||||
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI.
|
||||
- Show committed session progress while the endpoint runs.
|
||||
|
||||
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
|
||||
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
|
||||
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
|
||||
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
|
||||
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
|
||||
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
|
||||
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
|
||||
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
|
||||
and help flows do not trigger the backfill.
|
||||
|
||||
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
|
||||
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
|
||||
the status check and spinner presentation.
|
||||
|
||||
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
|
||||
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
|
||||
for the current single elected server process.
|
||||
|
||||
## Preserve
|
||||
|
||||
@@ -15,20 +34,35 @@ The canonical V1 data remains in its existing tables. In particular, preserve `s
|
||||
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
|
||||
workspace relationships.
|
||||
|
||||
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
|
||||
generated migration must not drop the table.
|
||||
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
|
||||
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider
|
||||
ID, model ID, and variant, normalizing an absent variant to `default`.
|
||||
|
||||
## Truncate
|
||||
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal
|
||||
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
|
||||
cache-write token totals with those sums.
|
||||
|
||||
Truncate these pre-launch V2 tables before applying schema changes:
|
||||
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection
|
||||
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
|
||||
parts, and file history.
|
||||
|
||||
- `event`
|
||||
- `event_sequence`
|
||||
- `session_message`
|
||||
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
|
||||
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
|
||||
|
||||
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
|
||||
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
|
||||
and `part` rows rather than retaining its pre-launch V2 contents.
|
||||
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
|
||||
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
|
||||
unmanaged legacy storage.
|
||||
|
||||
## Per-Session Replacement
|
||||
|
||||
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
|
||||
hold SQLite's writer lock long enough to block the running TUI.
|
||||
|
||||
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
|
||||
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
|
||||
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
|
||||
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
|
||||
remain untouched.
|
||||
|
||||
## Message Backfill
|
||||
|
||||
@@ -36,9 +70,23 @@ Backfill canonical V1 history from `message` and `part` into `session_message`.
|
||||
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
|
||||
V2 session APIs, which read `session_message`.
|
||||
|
||||
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
|
||||
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
|
||||
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
|
||||
untouched in the V1 tables.
|
||||
|
||||
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
|
||||
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
|
||||
leave skipped source rows unchanged.
|
||||
|
||||
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
|
||||
avoid rewriting other persisted state that may refer to a message.
|
||||
|
||||
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
|
||||
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
|
||||
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
|
||||
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
|
||||
|
||||
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
|
||||
contiguous `session_message.seq` values starting at `0`.
|
||||
|
||||
@@ -46,15 +94,122 @@ Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becom
|
||||
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
|
||||
payload.
|
||||
|
||||
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
|
||||
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
|
||||
stable. Omit only explicitly dropped internal concepts and undecodable messages.
|
||||
|
||||
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
|
||||
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
|
||||
part mappings must be decided explicitly before implementing the backfill.
|
||||
|
||||
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
|
||||
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
|
||||
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
|
||||
Keep all source rows unchanged in the V1 `message` and `part` tables.
|
||||
|
||||
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
|
||||
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
|
||||
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
|
||||
|
||||
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
|
||||
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
|
||||
state has no start time. Set the error to type `tool.interrupted` with message
|
||||
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
|
||||
|
||||
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
|
||||
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
|
||||
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
|
||||
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
|
||||
`[Old tool result content cleared]` as the only output and omit attachments.
|
||||
|
||||
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
|
||||
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
|
||||
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
|
||||
|
||||
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
|
||||
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
|
||||
`tokens.total` because it is derivable and V2 does not persist it.
|
||||
|
||||
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
|
||||
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
|
||||
|
||||
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
|
||||
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
|
||||
|
||||
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
|
||||
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
|
||||
`message` row.
|
||||
|
||||
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
|
||||
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
|
||||
finish values in metadata.
|
||||
|
||||
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
|
||||
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
|
||||
preserve the message, and discard V1-only retryability and raw provider details.
|
||||
|
||||
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
|
||||
useful enough to preserve. The original retry rows remain in the V1 `part` table.
|
||||
|
||||
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
|
||||
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
|
||||
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
|
||||
markers without snapshots.
|
||||
|
||||
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
|
||||
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
|
||||
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
|
||||
removed.
|
||||
|
||||
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
|
||||
a blocker for the V1 migration, which should target the current storage contract.
|
||||
|
||||
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
|
||||
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
|
||||
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
|
||||
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
|
||||
|
||||
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
|
||||
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
|
||||
|
||||
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
|
||||
metadata. The original value remains in the V1 `message` row.
|
||||
|
||||
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
|
||||
must not affect future V2 execution. The original value remains in the V1 `message` row.
|
||||
|
||||
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
|
||||
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
|
||||
format only in the V1 `message` row.
|
||||
|
||||
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
|
||||
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
|
||||
row.
|
||||
|
||||
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
|
||||
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
|
||||
Omit `agents` when there are no agent parts.
|
||||
|
||||
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
|
||||
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
|
||||
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
|
||||
|
||||
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
|
||||
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
|
||||
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
|
||||
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
|
||||
|
||||
For a non-embedded V1 file, do not create a V2 file attachment. Append
|
||||
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
|
||||
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
|
||||
the preserved V1 `part` row.
|
||||
|
||||
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
|
||||
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
|
||||
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
|
||||
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
|
||||
source user row. Entirely synthetic messages continue to reuse their original message ID.
|
||||
|
||||
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
|
||||
admitted compaction input ID and preserves references to the initiating message.
|
||||
|
||||
@@ -64,9 +219,13 @@ serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an
|
||||
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
|
||||
assistant row.
|
||||
|
||||
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
|
||||
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
|
||||
before migrated history. The `event` table remains empty.
|
||||
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
|
||||
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
|
||||
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables.
|
||||
|
||||
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
|
||||
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
|
||||
history. The migrated session's prior `event` rows are removed in the same transaction.
|
||||
|
||||
## Drop
|
||||
|
||||
@@ -74,6 +233,7 @@ Drop these pre-launch V2 tables without preserving or transforming their rows:
|
||||
|
||||
- `session_input`
|
||||
- `session_context_epoch`
|
||||
- `data_migration`
|
||||
|
||||
Do not transfer `session_input` rows into `session_pending`.
|
||||
|
||||
@@ -103,16 +263,36 @@ schema.
|
||||
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
|
||||
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
|
||||
|
||||
## Verification
|
||||
## Execution
|
||||
|
||||
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
|
||||
credentials, permissions, shares, and workspaces. After migration, it should verify:
|
||||
Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2
|
||||
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the
|
||||
current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions.
|
||||
Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its
|
||||
maximum message sequence. Commit each imported Session independently and leave the source database untouched.
|
||||
|
||||
- Preserved rows and encoded values remain unchanged.
|
||||
- Todo rows remain available in the unchanged `todo` table.
|
||||
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
|
||||
- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
|
||||
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
|
||||
- Dropped tables no longer exist.
|
||||
- New tables exist and are empty.
|
||||
- The final schema has no ungenerated changes.
|
||||
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor:
|
||||
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed.
|
||||
|
||||
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated
|
||||
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every
|
||||
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key
|
||||
exists.
|
||||
|
||||
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
|
||||
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
|
||||
seed migration state specially.
|
||||
|
||||
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
|
||||
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
|
||||
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
|
||||
cursor. Mark the migration complete after the final session and return immediately on later calls.
|
||||
|
||||
Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session`
|
||||
row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal.
|
||||
Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each
|
||||
successfully committed session advances the cursor.
|
||||
|
||||
## Testing
|
||||
|
||||
Detailed migration test design is deferred until after the canonical migration is implemented.
|
||||
|
||||
@@ -444,6 +444,7 @@ const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
}
|
||||
|
||||
const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
|
||||
if (finishReason === undefined) return hasToolCalls ? "tool-calls" : "unknown"
|
||||
if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
|
||||
if (finishReason === "MAX_TOKENS") return "length"
|
||||
if (
|
||||
|
||||
@@ -67,6 +67,12 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
|
||||
// extension until direct Google OpenAI-compatible routing is supported here:
|
||||
// https://github.com/vercel/ai/issues/11590
|
||||
// https://github.com/vercel/ai/pull/11745
|
||||
// https://ai.google.dev/gemini-api/docs/thought-signatures#openai
|
||||
|
||||
const OpenAIChatUserContent = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
@@ -145,22 +151,33 @@ export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
|
||||
// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the
|
||||
// byte stream into strings, then `Protocol.jsonEvent` decodes each string into
|
||||
// this provider-native event shape.
|
||||
const OpenAIChatUsage = Schema.Struct({
|
||||
prompt_tokens: Schema.optional(Schema.Number),
|
||||
completion_tokens: Schema.optional(Schema.Number),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
cached_tokens: Schema.optional(Schema.Number),
|
||||
cache_write_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
completion_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
reasoning_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const OpenAIChatUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
prompt_tokens: optionalNull(Schema.Number),
|
||||
completion_tokens: optionalNull(Schema.Number),
|
||||
total_tokens: optionalNull(Schema.Number),
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
cached_tokens: optionalNull(Schema.Number),
|
||||
cache_write_tokens: optionalNull(Schema.Number),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
completion_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
reasoning_tokens: optionalNull(Schema.Number),
|
||||
accepted_prediction_tokens: optionalNull(Schema.Number),
|
||||
rejected_prediction_tokens: optionalNull(Schema.Number),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenAIChatToolCallDeltaFunction = Schema.Struct({
|
||||
name: optionalNull(Schema.String),
|
||||
@@ -168,7 +185,7 @@ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
|
||||
})
|
||||
|
||||
const OpenAIChatToolCallDelta = Schema.Struct({
|
||||
index: Schema.Number,
|
||||
index: optionalNull(Schema.Number),
|
||||
id: optionalNull(Schema.String),
|
||||
function: optionalNull(OpenAIChatToolCallDeltaFunction),
|
||||
})
|
||||
@@ -222,6 +239,8 @@ export interface ParserState {
|
||||
readonly reasoningDetails: Array<unknown>
|
||||
readonly reasoningDetailsObserved: boolean
|
||||
readonly reasoningEmitted: boolean
|
||||
readonly latestToolIndex?: number
|
||||
readonly nextToolIndex: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -559,22 +578,34 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// satisfied on both sides.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.prompt_tokens_details?.cached_tokens
|
||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
return new Usage({
|
||||
inputTokens: usage.prompt_tokens,
|
||||
outputTokens: usage.completion_tokens,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
||||
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
||||
providerMetadata: { openai: usage },
|
||||
})
|
||||
}
|
||||
|
||||
const toolIndexByID = (
|
||||
tools: ParserState["tools"],
|
||||
pendingTools: ParserState["pendingTools"],
|
||||
id: string | undefined,
|
||||
) => {
|
||||
if (!id) return undefined
|
||||
const entry = Object.entries({ ...pendingTools, ...tools }).find(([, tool]) => tool?.id === id)
|
||||
return entry ? Number(entry[0]) : undefined
|
||||
}
|
||||
|
||||
const reasoningDelta = (
|
||||
delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined,
|
||||
configuredField?: string,
|
||||
@@ -657,17 +688,34 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const events: LLMEvent[] = []
|
||||
const usage = mapUsage(event.usage) ?? state.usage
|
||||
const choice = event.choices?.[0]
|
||||
const finishReason = choice?.finish_reason
|
||||
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
|
||||
: state.finishReason
|
||||
const rawFinishReason = choice?.finish_reason
|
||||
const finishReason =
|
||||
rawFinishReason !== undefined && rawFinishReason !== null
|
||||
? { normalized: mapFinishReason(rawFinishReason), raw: choice?.native_finish_reason ?? rawFinishReason }
|
||||
: state.finishReason
|
||||
const delta = choice?.delta
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
let tools = state.tools
|
||||
let pendingTools = state.pendingTools
|
||||
let latestToolIndex = state.latestToolIndex
|
||||
let nextToolIndex = state.nextToolIndex
|
||||
|
||||
let lifecycle = state.lifecycle
|
||||
|
||||
const reasoning = reasoningDelta(delta, state.reasoningField)
|
||||
const hasLateContent =
|
||||
Boolean(delta?.content) ||
|
||||
reasoning !== undefined ||
|
||||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
||||
toolDeltas.some(
|
||||
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
|
||||
)
|
||||
if (state.finishReason !== undefined) {
|
||||
if (hasLateContent)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
|
||||
return [{ ...state, usage }, events] as const
|
||||
}
|
||||
|
||||
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||
@@ -694,24 +742,37 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
|
||||
for (const tool of toolDeltas) {
|
||||
const current = tools[tool.index]
|
||||
const pending = pendingTools[tool.index]
|
||||
// Compatible providers may omit indexes. Prefer durable identity, then use
|
||||
// batch position for parallel deltas or the latest call for sparse chunks.
|
||||
for (const [position, tool] of toolDeltas.entries()) {
|
||||
const matched = toolIndexByID(tools, pendingTools, tool.id || undefined)
|
||||
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
|
||||
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
|
||||
const index =
|
||||
tool.index ?? matched ??
|
||||
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
|
||||
const current = tools[index]
|
||||
const pending = pendingTools[index]
|
||||
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
||||
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
|
||||
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
|
||||
latestToolIndex = index
|
||||
nextToolIndex = Math.max(nextToolIndex, index + 1)
|
||||
if (!current && (!id || !name)) {
|
||||
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
|
||||
pendingTools = {
|
||||
...pendingTools,
|
||||
[index]: { id: id || undefined, name: name || undefined, input: text },
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (pending) {
|
||||
pendingTools = { ...pendingTools }
|
||||
delete pendingTools[tool.index]
|
||||
delete pendingTools[index]
|
||||
}
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
tools,
|
||||
tool.index,
|
||||
index,
|
||||
{ id: id || undefined, name: name || undefined, text },
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
)
|
||||
@@ -743,6 +804,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
reasoningDetails: state.reasoningDetails,
|
||||
reasoningDetailsObserved,
|
||||
reasoningEmitted,
|
||||
latestToolIndex,
|
||||
nextToolIndex,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
@@ -799,6 +862,7 @@ export const protocol = Protocol.make({
|
||||
reasoningDetails: [],
|
||||
reasoningDetailsObserved: false,
|
||||
reasoningEmitted: false,
|
||||
nextToolIndex: 0,
|
||||
}),
|
||||
step,
|
||||
onHalt: finishEvents,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ProviderPackage } from "../provider-package"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
import { ProviderShared } from "../protocols/shared"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||
|
||||
export const id = ProviderID.make("azure")
|
||||
@@ -19,7 +20,7 @@ export type LanguageModelOptions = AzureURL &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly apiVersion?: string
|
||||
readonly queryParams?: Record<string, string>
|
||||
readonly useCompletionUrls?: boolean
|
||||
readonly useDeploymentBasedUrls?: boolean
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Config = LanguageModelOptions
|
||||
@@ -29,27 +30,22 @@ export type Settings = ProviderPackage.Settings &
|
||||
readonly apiKey?: string
|
||||
readonly apiVersion?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly useDeploymentBasedUrls?: boolean
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
|
||||
|
||||
const responsesRoute = OpenAIResponses.route.with({
|
||||
id: "azure-openai-responses",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
endpoint: {
|
||||
query: { "api-version": "v1" },
|
||||
},
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
id: "azure-openai-chat",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
endpoint: {
|
||||
query: { "api-version": "v1" },
|
||||
},
|
||||
})
|
||||
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
@@ -59,7 +55,7 @@ const defaults = (input: Config) => {
|
||||
apiKey: _,
|
||||
apiVersion: _apiVersion,
|
||||
resourceName: _resourceName,
|
||||
useCompletionUrls: _useCompletionUrls,
|
||||
useDeploymentBasedUrls: _useDeploymentBasedUrls,
|
||||
baseURL: _baseURL,
|
||||
queryParams: _queryParams,
|
||||
...rest
|
||||
@@ -80,37 +76,39 @@ const auth = (input: Config) => {
|
||||
)
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
|
||||
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
|
||||
route.with({
|
||||
auth: auth(input),
|
||||
endpoint: {
|
||||
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
|
||||
baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!),
|
||||
query: {
|
||||
...(input.apiVersion ? { "api-version": input.apiVersion } : {}),
|
||||
...input.queryParams,
|
||||
},
|
||||
},
|
||||
endpoint: endpoint(input, modelID),
|
||||
})
|
||||
|
||||
function endpoint(input: Config, modelID: string | ModelID) {
|
||||
const baseURL = ProviderShared.trimBaseUrl(input.baseURL ?? resourceBaseURL(input.resourceName!))
|
||||
const query = { "api-version": input.apiVersion ?? "v1", ...input.queryParams }
|
||||
|
||||
if (input.useDeploymentBasedUrls) return { baseURL: `${baseURL}/deployments/${modelID}`, query }
|
||||
if (input.baseURL !== undefined && !new URL(input.baseURL).hostname.endsWith(".openai.azure.com")) {
|
||||
return { baseURL, query: input.queryParams }
|
||||
}
|
||||
return { baseURL: `${baseURL}/v1`, query }
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
|
||||
const responses = (modelID: string | ModelID) =>
|
||||
configuredResponsesRoute
|
||||
configuredRoute(responsesRoute, input, modelID)
|
||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
|
||||
const chat = (modelID: string | ModelID) =>
|
||||
configuredChatRoute
|
||||
configuredRoute(chatRoute, input, modelID)
|
||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
|
||||
model: responses,
|
||||
responses,
|
||||
chat,
|
||||
configure,
|
||||
@@ -131,6 +129,7 @@ const config = (settings: Settings): Config => {
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
|
||||
}
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
|
||||
@@ -15,8 +15,7 @@ export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||
|
||||
const VERSION = "vertex-2023-10-16" as const
|
||||
|
||||
// models.dev uses this provider id even though the API contract is Anthropic Messages.
|
||||
export const id = ProviderID.make("google-vertex-anthropic")
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
GoogleVertexShared.OAuthOptions & {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
LanguageModel,
|
||||
LanguageModelLimits,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputReason,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -231,6 +232,17 @@ const streamError = (route: string, message: string, cause: Cause.Cause<unknown>
|
||||
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
||||
}
|
||||
|
||||
const incompleteStreamError = (route: string) =>
|
||||
new AIError({
|
||||
module: "LLMClient",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
message: "The provider response ended unexpectedly.",
|
||||
route,
|
||||
}),
|
||||
})
|
||||
|
||||
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
|
||||
Stream.suspend(() => {
|
||||
let terminal = false
|
||||
@@ -247,7 +259,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
|
||||
Effect.suspend(() =>
|
||||
terminal
|
||||
? Effect.void
|
||||
: Effect.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")),
|
||||
: Effect.fail(incompleteStreamError(route)),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -416,10 +428,7 @@ const generateWith = (stream: Interface["stream"]) =>
|
||||
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return response
|
||||
return yield* ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
"Provider stream ended without a terminal finish event",
|
||||
)
|
||||
return yield* incompleteStreamError(`${request.model.provider}/${request.model.route.id}`)
|
||||
})
|
||||
|
||||
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
|
||||
|
||||
@@ -105,6 +105,7 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
|
||||
)({
|
||||
_tag: Schema.tag("InvalidProviderOutput"),
|
||||
message: Schema.String,
|
||||
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
|
||||
route: Schema.optional(Schema.String),
|
||||
raw: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
|
||||
@@ -24,7 +24,7 @@ export type ToolExecute<Parameters extends ToolSchema<any>, Success extends Tool
|
||||
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
|
||||
|
||||
export interface ToolModelOutputInput<Parameters, Output> {
|
||||
readonly callID: ToolCallPart["id"]
|
||||
readonly id: ToolCallPart["id"]
|
||||
readonly parameters: Parameters
|
||||
readonly output: Output
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export interface Definition<Parameters extends ToolSchema<any>, Success extends
|
||||
/** @internal */
|
||||
readonly _project: (
|
||||
parameters: Schema.Schema.Type<Parameters>,
|
||||
callID: ToolCallPart["id"],
|
||||
id: ToolCallPart["id"],
|
||||
output: unknown,
|
||||
) => ToolOutputType
|
||||
/** @internal */
|
||||
@@ -173,8 +173,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
toStructuredOutput: config.toStructuredOutput,
|
||||
_decode: Effect.succeed,
|
||||
_encode: Effect.succeed,
|
||||
_project: (parameters, callID, output) =>
|
||||
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
|
||||
_project: (parameters, id, output) =>
|
||||
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
|
||||
_legacyResult: config.toModelOutput === undefined && config.toStructuredOutput === undefined,
|
||||
_definition: new ToolDefinition({
|
||||
name: "",
|
||||
@@ -193,8 +193,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
toStructuredOutput: config.toStructuredOutput,
|
||||
_decode: Schema.decodeUnknownEffect(config.parameters),
|
||||
_encode: Schema.encodeEffect(config.success),
|
||||
_project: (parameters, callID, output) =>
|
||||
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
|
||||
_project: (parameters, id, output) =>
|
||||
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
|
||||
_legacyResult: false,
|
||||
_definition: new ToolDefinition({
|
||||
name: "",
|
||||
@@ -239,12 +239,12 @@ const project = (
|
||||
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
|
||||
toStructuredOutput: ((output: unknown) => unknown) | undefined,
|
||||
parameters: unknown,
|
||||
callID: ToolCallPart["id"],
|
||||
id: ToolCallPart["id"],
|
||||
output: unknown,
|
||||
): ToolOutputType =>
|
||||
ToolOutput.make(
|
||||
toStructuredOutput?.(output) ?? output,
|
||||
toModelOutput?.({ callID, parameters, output }) ??
|
||||
toModelOutput?.({ id, parameters, output }) ??
|
||||
(typeof output === "string" ? [{ type: "text", text: output }] : []),
|
||||
)
|
||||
|
||||
|
||||
@@ -133,8 +133,8 @@ describe("llm route", () => {
|
||||
Effect.gen(function* () {
|
||||
const error = yield* (yield* LLMClient.Service).stream(request).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(error.message).toContain("Provider stream ended without a terminal finish event")
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
|
||||
expect(error.message).toContain("The provider response ended unexpectedly.")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -209,6 +209,27 @@ describe("provider package entrypoints", () => {
|
||||
expect(chat.route.id).toBe("azure-openai-chat")
|
||||
})
|
||||
|
||||
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
|
||||
const Azure = await import("@opencode-ai/ai/providers/azure")
|
||||
const deployment = Azure.model("custom-deployment", {
|
||||
apiKey: "fixture",
|
||||
resourceName: "opencode-test",
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useDeploymentBasedUrls: true,
|
||||
})
|
||||
const gateway = Azure.model("gateway-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/azure/",
|
||||
})
|
||||
|
||||
expect(deployment.route.endpoint).toMatchObject({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
|
||||
query: { "api-version": "2025-01-01-preview" },
|
||||
})
|
||||
expect(gateway.route.endpoint.baseURL).toBe("https://gateway.example/azure")
|
||||
expect(gateway.route.endpoint.query).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps Google package settings onto the Gemini model", async () => {
|
||||
const Google = await import("@opencode-ai/ai/providers/google")
|
||||
const selected = Google.model("gemini-2.5-flash", {
|
||||
|
||||
@@ -538,7 +538,8 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
message: "Provider stream ended without a terminal finish event",
|
||||
classification: "incomplete-stream",
|
||||
message: "The provider response ended unexpectedly.",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -601,6 +601,34 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps tool calls without a finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: undefined })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assigns unique ids to multiple streamed tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
|
||||
@@ -56,13 +56,14 @@ describe("Google Vertex providers", () => {
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}).model("claude-sonnet-4-6")
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}).model("claude-sonnet-4-6"),
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(
|
||||
@@ -97,6 +98,7 @@ describe("Google Vertex providers", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(model.provider).toBe("google-vertex")
|
||||
expect(response.text).toBe("Hello.")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1136,9 +1136,12 @@ describe("OpenAI Chat route", () => {
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
||||
expect(streamError.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(streamError.message).toContain("Provider stream ended without a terminal finish event")
|
||||
expect(error.message).toContain("Provider stream ended without a terminal finish event")
|
||||
expect(streamError.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
})
|
||||
expect(streamError.message).toContain("The provider response ended unexpectedly.")
|
||||
expect(error.message).toContain("The provider response ended unexpectedly.")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { compileRequest } from "../../src/route/client"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse } from "../lib/http"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
|
||||
const Json = Schema.fromJsonString(Schema.Unknown)
|
||||
@@ -253,4 +253,106 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts nullable usage and preserves provider fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ content: "Hello" }),
|
||||
deltaChunk({}, "stop"),
|
||||
usageChunk({
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
total_tokens: null,
|
||||
prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
|
||||
completion_tokens_details: {
|
||||
reasoning_tokens: null,
|
||||
accepted_prediction_tokens: null,
|
||||
rejected_prediction_tokens: null,
|
||||
},
|
||||
cost: "0.001",
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: undefined,
|
||||
outputTokens: undefined,
|
||||
totalTokens: undefined,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
total_tokens: null,
|
||||
prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
|
||||
cost: "0.001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles indexless parallel tool calls across sparse chunks", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [
|
||||
{ id: "call_paris", function: { name: "weather", arguments: '{"city":"' } },
|
||||
{ index: null, id: "call_london", function: { name: "weather", arguments: '{"city":"' } },
|
||||
],
|
||||
}),
|
||||
deltaChunk({ tool_calls: [{ function: { arguments: 'London"}' } }] }),
|
||||
deltaChunk({ tool_calls: [{ id: "call_paris", function: { arguments: 'Paris"}' } }] }),
|
||||
deltaChunk({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toMatchObject([
|
||||
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
|
||||
{ id: "call_london", name: "weather", input: { city: "London" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats an empty finish reason as terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "unknown", raw: "" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects content after a terminal chunk", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ content: "Hello" }),
|
||||
deltaChunk({}, "stop"),
|
||||
deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{}" } }] }),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("LLMClient tools", () => {
|
||||
LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ callID: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
|
||||
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
|
||||
expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
|
||||
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
|
||||
expect(dispatched.events).toEqual([
|
||||
|
||||
@@ -27,8 +27,8 @@ Tool.make({
|
||||
parameters: Schema.Struct({ city: Schema.String }),
|
||||
success: Schema.Struct({ forecast: Schema.NumberFromString }),
|
||||
execute: () => Effect.succeed({ forecast: 1 }),
|
||||
toModelOutput: ({ callID, parameters, output }) => [
|
||||
{ type: "text", text: `${callID}:${parameters.city}:${output.forecast}` },
|
||||
toModelOutput: ({ id, parameters, output }) => [
|
||||
{ type: "text", text: `${id}:${parameters.city}:${output.forecast}` },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
# V1 API Migration Checklist
|
||||
|
||||
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
|
||||
|
||||
## Events
|
||||
|
||||
- [x] Replace `GET /global/event` with `GET /api/event`.
|
||||
- `src/context/server-sdk.tsx`
|
||||
- [x] Reduce current granular session and message events into the existing app projections.
|
||||
- `src/context/server-session-v2-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- `src/context/notification.tsx`
|
||||
- `src/pages/session/usage-exceeded-dialogs.tsx`
|
||||
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- [x] Adapt current permission and question events to the existing request model.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/permission.tsx`
|
||||
- [x] Consume current file watcher events.
|
||||
- `src/context/file.tsx`
|
||||
- [x] Consume current VCS events.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Consume current `pty.exited` events.
|
||||
- `src/context/terminal.tsx`
|
||||
- [ ] Migrate LSP and reference events.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
|
||||
## Sessions
|
||||
|
||||
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate session listing from `GET /session`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- `src/context/directory-sync.ts`
|
||||
- `src/pages/layout.tsx`
|
||||
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
|
||||
- `src/components/titlebar.tsx`
|
||||
- [x] Migrate session updates from `PATCH /session/:sessionID`.
|
||||
- `src/context/directory-sync.ts`
|
||||
- `src/context/layout.tsx`
|
||||
- `src/pages/home.tsx`
|
||||
- `src/pages/layout.tsx`
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- `src/components/titlebar-tab-nav.tsx`
|
||||
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
|
||||
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
|
||||
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
|
||||
- [x] Migrate abort from `POST /session/:sessionID/abort`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
|
||||
- `src/components/dialog-fork.tsx`
|
||||
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- Blocked: the current API has no sharing contract or implementation.
|
||||
|
||||
## Session Compatibility Fallbacks
|
||||
|
||||
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
|
||||
|
||||
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
|
||||
## Filesystem
|
||||
|
||||
- [ ] Migrate file listing from `GET /file`.
|
||||
- `src/context/file.tsx`
|
||||
- [ ] Migrate file reads from `GET /file/content`.
|
||||
- `src/context/file.tsx`
|
||||
- `src/pages/session/review-tab.tsx`
|
||||
- `src/pages/session/v2/review-panel-v2.tsx`
|
||||
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/components/dialog-select-directory.tsx`
|
||||
- `src/components/dialog-select-directory-v2.tsx`
|
||||
|
||||
## Projects And Worktrees
|
||||
|
||||
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate Git initialization from `POST /project/git/init`.
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
|
||||
- `src/context/layout.tsx`
|
||||
- `src/components/edit-project.ts`
|
||||
- `src/pages/layout.tsx`
|
||||
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
|
||||
- `src/pages/layout.tsx`
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
|
||||
- [ ] Migrate instance disposal from `POST /instance/dispose`.
|
||||
- `src/pages/layout.tsx`
|
||||
|
||||
## VCS
|
||||
|
||||
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
|
||||
- `src/pages/layout.tsx`
|
||||
|
||||
## Configuration And Authentication
|
||||
|
||||
- [ ] Migrate global configuration reads from `GET /global/config`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate directory configuration reads from `GET /config`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate global configuration updates from `PATCH /global/config`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
|
||||
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- `src/components/dialog-custom-provider.tsx`
|
||||
- `src/components/settings-providers.tsx`
|
||||
- `src/components/settings-v2/providers.tsx`
|
||||
- [ ] Migrate global disposal from `POST /global/dispose`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- `src/components/settings-providers.tsx`
|
||||
- `src/components/settings-v2/providers.tsx`
|
||||
|
||||
## Permissions And Questions
|
||||
|
||||
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/context/permission.tsx`
|
||||
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
|
||||
- `src/context/permission.tsx`
|
||||
- `src/pages/session/composer/session-composer-state.ts`
|
||||
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
|
||||
- `src/pages/session/composer/session-question-dock.tsx`
|
||||
|
||||
## Commands, MCP, LSP, And References
|
||||
|
||||
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [ ] Migrate LSP status from `GET /lsp`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
|
||||
## Search
|
||||
|
||||
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
|
||||
- `src/components/command-palette.ts`
|
||||
- `src/components/dialog-command-palette-v2.tsx`
|
||||
|
||||
## PTY And Terminal
|
||||
|
||||
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
|
||||
- `src/context/terminal.tsx`
|
||||
- `src/components/terminal.tsx`
|
||||
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
|
||||
- `src/components/settings-general.tsx`
|
||||
- `src/components/settings-v2/general.tsx`
|
||||
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
|
||||
- `src/components/terminal.tsx`
|
||||
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
|
||||
- `src/components/terminal.tsx`
|
||||
|
||||
## Legacy Types And Adapters
|
||||
|
||||
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
|
||||
|
||||
- [ ] Replace the current-session-to-legacy-session adapter.
|
||||
- `src/utils/session.ts`
|
||||
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
|
||||
- `src/utils/session-message.ts`
|
||||
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
|
||||
- `src/context/global-sync/utils.ts`
|
||||
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
|
||||
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
|
||||
- `package.json`
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
- [ ] Replace V1 endpoint mocks with current API mocks.
|
||||
- `e2e/utils/mock-server.ts`
|
||||
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
|
||||
- `e2e/utils/sse-transport.ts`
|
||||
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
|
||||
- `e2e/performance/timeline-stability/fixture.ts`
|
||||
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
|
||||
@@ -2,17 +2,8 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import type {
|
||||
AssistantMessage,
|
||||
GlobalEvent,
|
||||
Message,
|
||||
Part,
|
||||
Session,
|
||||
SessionStatus,
|
||||
ToolPart,
|
||||
ToolState,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
@@ -27,18 +18,29 @@ export const assistantID = "msg_1001_timeline_assistant"
|
||||
export const title = "Timeline visual stability"
|
||||
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type TimelinePayload = Extract<
|
||||
GlobalEvent["payload"],
|
||||
{
|
||||
type:
|
||||
| "message.updated"
|
||||
| "message.removed"
|
||||
| "message.part.updated"
|
||||
| "message.part.removed"
|
||||
| "message.part.delta"
|
||||
| "session.status"
|
||||
type Session = SessionInfo
|
||||
type GlobalEvent = {
|
||||
directory: string
|
||||
project?: string
|
||||
workspace?: string
|
||||
payload: {
|
||||
id: string
|
||||
type: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
type TimelineProperties = {
|
||||
"message.updated": { sessionID: string; info: Message }
|
||||
"message.removed": { sessionID: string; messageID: string }
|
||||
"message.part.updated": { sessionID: string; part: Part; time: number }
|
||||
"message.part.removed": { sessionID: string; messageID: string; partID: string }
|
||||
"message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string }
|
||||
"session.status": { sessionID: string; status: SessionStatus }
|
||||
}
|
||||
type TimelinePayload = {
|
||||
[Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] }
|
||||
}[keyof TimelineProperties]
|
||||
|
||||
type DeepReadonly<Value> = Value extends readonly unknown[]
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
@@ -97,7 +99,6 @@ export async function setupTimeline(
|
||||
locale?: string
|
||||
deviceScaleFactor?: number
|
||||
seedHistory?: boolean
|
||||
protocol?: "v1" | "v2"
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
@@ -115,7 +116,7 @@ export async function setupTimeline(
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: input.protocol,
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
@@ -235,7 +236,7 @@ export function event(type: TimelinePayload["type"], properties: TimelinePayload
|
||||
}
|
||||
|
||||
export function validateTimelineEvent(input: unknown): TimelineEvent {
|
||||
return decodeEvent(input, decodeOptions)
|
||||
return decodeEvent(input, decodeOptions) as TimelineEvent
|
||||
}
|
||||
|
||||
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
|
||||
@@ -460,7 +461,7 @@ export function toolPart(
|
||||
input: Record<string, unknown>,
|
||||
options: ToolOptions<ToolStatus> = {},
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
|
||||
const base = { id, type: "tool" as const, callID: id, tool }
|
||||
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
|
||||
if (state === "running")
|
||||
return {
|
||||
@@ -529,11 +530,11 @@ export function project() {
|
||||
export function session(input: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: sessionID,
|
||||
slug: "timeline-stability",
|
||||
projectID,
|
||||
directory,
|
||||
location: { directory },
|
||||
title,
|
||||
version: "dev",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
...input,
|
||||
}
|
||||
|
||||
@@ -177,7 +177,8 @@ test("shows all and expands historical diff summary without overlap", async ({ p
|
||||
const firstUser = userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: Array.from({ length: 12 }, (_, index) => ({
|
||||
file: `src/diff-${index}.ts`,
|
||||
file: `src/diff-${index}.ts`,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
|
||||
@@ -1,6 +1,121 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("preserves current messages", () => {
|
||||
const message = {
|
||||
id: "msg_current",
|
||||
type: "user",
|
||||
time: { created: 1 },
|
||||
text: "current",
|
||||
files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }],
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
expect(currentMessage(message)).toBe(message)
|
||||
})
|
||||
|
||||
test("converts rich legacy messages to current message types", () => {
|
||||
expect(
|
||||
currentMessage({
|
||||
info: { id: "msg_user", role: "user", time: { created: 1 } },
|
||||
parts: [
|
||||
{ type: "text", text: "Use @src/a.ts with @explore" },
|
||||
{
|
||||
type: "file",
|
||||
mime: "application/json",
|
||||
filename: "data.json",
|
||||
url: "data:application/json;base64,e30=",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "a.ts",
|
||||
url: "src/a.ts",
|
||||
source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } },
|
||||
},
|
||||
{ type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
time: { created: 1 },
|
||||
text: "Use @src/a.ts with @explore",
|
||||
files: [
|
||||
{ data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } },
|
||||
{
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
name: "a.ts",
|
||||
source: { type: "uri", uri: "src/a.ts" },
|
||||
mention: { text: "@src/a.ts", start: 4, end: 13 },
|
||||
},
|
||||
],
|
||||
agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }],
|
||||
})
|
||||
|
||||
expect(
|
||||
currentMessage({
|
||||
info: {
|
||||
id: "msg_assistant",
|
||||
role: "assistant",
|
||||
time: { created: 2, completed: 5 },
|
||||
agent: "explore",
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
variant: "high",
|
||||
cost: 0.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
finish: "tool-calls",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
},
|
||||
parts: [
|
||||
{ type: "text", text: "Answer" },
|
||||
{ type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } },
|
||||
{
|
||||
id: "prt_tool",
|
||||
callID: "call_tool",
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/a.ts" },
|
||||
output: "contents",
|
||||
metadata: { title: "a.ts" },
|
||||
time: { start: 3, end: 4 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
time: { created: 2, completed: 5 },
|
||||
agent: "explore",
|
||||
model: { id: "model", providerID: "provider", variant: "high" },
|
||||
cost: 0.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
finish: "tool-calls",
|
||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||
content: [
|
||||
{ type: "text", text: "Answer" },
|
||||
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_tool",
|
||||
name: "read",
|
||||
time: { created: 3, ran: 3, completed: 4 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/a.ts" },
|
||||
content: [{ type: "text", text: "contents" }],
|
||||
metadata: { title: "a.ts" },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("applies message latency after a list response gate is released", async () => {
|
||||
const events: string[] = []
|
||||
@@ -30,7 +145,7 @@ test("applies message latency after a list response gate is released", async ()
|
||||
})
|
||||
|
||||
const response = handler!({
|
||||
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
|
||||
request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
|
||||
fulfill: () => {
|
||||
events.push("fulfill")
|
||||
return Promise.resolve()
|
||||
|
||||
@@ -85,21 +85,17 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const directoryA = "C:/server-a"
|
||||
const directoryB = "/home/server-b"
|
||||
@@ -32,7 +32,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
.poll(() =>
|
||||
permissionRequests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverB && url.searchParams.get("directory") === directoryB
|
||||
return url.origin === serverB && url.searchParams.get("location[directory]") === directoryB
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
@@ -67,7 +67,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
.poll(() =>
|
||||
permissionRequests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverA && url.searchParams.get("directory") === directoryA
|
||||
return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
@@ -99,10 +99,10 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
.toEqual([
|
||||
{
|
||||
origin: serverA,
|
||||
directory: directoryA,
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { response: "once" },
|
||||
body: { reply: "once" },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -127,17 +127,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
.toEqual([
|
||||
{
|
||||
origin: serverA,
|
||||
directory: directoryA,
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { response: "once" },
|
||||
body: { reply: "once" },
|
||||
},
|
||||
{
|
||||
origin: serverA,
|
||||
directory: directoryA,
|
||||
directory: undefined,
|
||||
sessionID: childSessionA.id,
|
||||
permissionID: "permission-background-a-child",
|
||||
body: { response: "once" },
|
||||
body: { reply: "once" },
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -168,8 +168,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
const remote = url.origin === serverB
|
||||
const directory = remote ? directoryB : directoryA
|
||||
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
|
||||
const requestDirectory = url.searchParams.get("directory")
|
||||
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
|
||||
const requestDirectory = url.searchParams.get("location[directory]")
|
||||
const response = url.pathname.match(/^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/)
|
||||
if (route.request().method() === "POST" && response) {
|
||||
permissionResponses.push({
|
||||
origin: url.origin,
|
||||
@@ -181,13 +181,21 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
return json(route, true)
|
||||
}
|
||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
|
||||
return json(route, { data: [] })
|
||||
if (url.pathname === "/api/model/default") return json(route, { data: null })
|
||||
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
|
||||
if (url.pathname === "/api/provider")
|
||||
return json(route, {
|
||||
location: { directory },
|
||||
data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
|
||||
})
|
||||
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
|
||||
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
|
||||
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
|
||||
if (url.pathname === "/api/permission/request") {
|
||||
permissionRequests.push(url.toString())
|
||||
return json(route, { location: { directory }, data: [] })
|
||||
}
|
||||
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
|
||||
return json(route, { location: { directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
@@ -211,8 +219,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||
if (current) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
@@ -222,7 +228,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
}
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
@@ -288,6 +293,25 @@ function provider(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function model(remote: boolean) {
|
||||
const id = remote ? "server-b" : "server-a"
|
||||
const name = remote ? "Server B" : "Server A"
|
||||
return {
|
||||
id,
|
||||
modelID: id,
|
||||
providerID: id,
|
||||
name: `${name} Model`,
|
||||
family: id,
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [],
|
||||
time: { released: Date.now() },
|
||||
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
}
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
|
||||
@@ -58,22 +58,18 @@ async function mockServers(page: Page) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route, url.pathname === "/api/event")
|
||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
|
||||
@@ -14,6 +14,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
||||
test("opens and searches project files inline", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -127,7 +128,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||
|
||||
@@ -19,36 +19,25 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
||||
await expectSessionTitle(page, titleA)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await selectFile(page, "beta.ts")
|
||||
await selectFile(page, "alpha.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await selectFile(page, "gamma.ts")
|
||||
|
||||
await switchSession(page, titleA)
|
||||
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
await selectMode(page, "Branch changes", "Git changes")
|
||||
await expectSelectedFile(page, "alpha.ts")
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "alpha.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "gamma.ts")
|
||||
})
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
||||
}
|
||||
|
||||
async function selectFile(page: Page, file: string) {
|
||||
await page.getByRole("button", { name: file }).click()
|
||||
await expectSelectedFile(page, file)
|
||||
@@ -65,7 +54,7 @@ async function switchSession(page: Page, title: string) {
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v1",
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -89,22 +78,27 @@ async function setup(page: Page) {
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||
await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: { branch: "feature", defaultBranch: "dev" },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) =>
|
||||
await page.route("**/api/vcs/diff**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
||||
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
||||
),
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data:
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
||||
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(
|
||||
|
||||
@@ -25,7 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
let detailFailures = 1
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v1",
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -62,33 +62,32 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
})
|
||||
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||
await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
branch: "review-pane-performance",
|
||||
default_branch: "dev",
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: { branch: "review-pane-performance", defaultBranch: "dev" },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) => {
|
||||
await page.route("**/api/vcs/diff**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
|
||||
const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
|
||||
const detail = scope?.endsWith("/src/branch/d00027")
|
||||
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
url.searchParams.get("mode") === "branch"
|
||||
? detail
|
||||
? branchDiffs
|
||||
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
||||
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
||||
: branchDiffs
|
||||
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
|
||||
),
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: detail
|
||||
? branchDiffs
|
||||
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
||||
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
||||
: branchDiffs,
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route("**/pty*", (route) =>
|
||||
@@ -109,7 +108,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty/pty_review_terminal*", (route) =>
|
||||
await page.route("**/api/pty/pty_review_terminal*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@@ -127,7 +126,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
|
||||
await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@@ -137,7 +136,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
@@ -149,9 +148,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
@@ -174,9 +171,9 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
expect(bottomGap).toBeLessThanOrEqual(16)
|
||||
const lazyDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
return (
|
||||
url.pathname === "/api/vcs/diff" &&
|
||||
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
await lastFile.click()
|
||||
@@ -190,59 +187,46 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
const refreshedDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
url.pathname === "/api/vcs/diff" &&
|
||||
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
sessionStatus[sessionID] = { type: "idle" }
|
||||
events.push(statusEvent("idle"))
|
||||
await refreshedDiff
|
||||
await expect(preview).toContainText("after-2")
|
||||
await selectMode(page, "Branch changes", "Git changes")
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
await page.getByRole("button", { name: "git-0.ts" }).click()
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
||||
await filter.fill("generated-2738")
|
||||
await expectTree(page, 1, "generated-2738.ts")
|
||||
await filter.fill("")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
|
||||
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toHaveCount(0)
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
await page.setViewportSize({ width: 1_000, height: 700 })
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
await expectStackGeometry(page)
|
||||
await page.setViewportSize({ width: 1_000, height: 120 })
|
||||
await page.setViewportSize({ width: 1_400, height: 900 })
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
await expectStackGeometry(page)
|
||||
})
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
const option = page.getByRole("option", { name: next })
|
||||
await expect(option).toBeVisible()
|
||||
await option.click()
|
||||
}
|
||||
|
||||
async function expectTree(page: Page, total: number, file: string) {
|
||||
await expectMountedTree(page, total)
|
||||
await expect(page.getByRole("button", { name: file })).toBeVisible()
|
||||
|
||||
@@ -52,7 +52,7 @@ const editPart = {
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "tool",
|
||||
callID: "call_edit_regression",
|
||||
callID: editPartID,
|
||||
tool: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
|
||||
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
|
||||
const sessionID = "ses_context_resize_regression"
|
||||
const title = "Context resize regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
|
||||
const followingTextID = "prt_0104_text"
|
||||
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
|
||||
const followingTextID = `${id("msg_assistant", 10)}:text:0`
|
||||
|
||||
type Message = {
|
||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
@@ -295,7 +295,7 @@ function contextTool(
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "tool",
|
||||
callID: `call_${partID}`,
|
||||
callID: partID,
|
||||
tool,
|
||||
state: {
|
||||
status,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
status,
|
||||
textPart,
|
||||
title,
|
||||
userID,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
@@ -19,18 +18,22 @@ import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
parentID: userID,
|
||||
created: 1700000001000 + index * 1_000,
|
||||
completed: index < initialPageSize,
|
||||
}),
|
||||
)
|
||||
const messages = [userMessage(), ...assistants]
|
||||
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
return [
|
||||
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
|
||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
parentID: id,
|
||||
created: 1700000001000 + index * 2_000,
|
||||
completed: index < initialPageSize,
|
||||
}),
|
||||
]
|
||||
}).flat()
|
||||
const assistants = messages.filter((message) => message.info.role === "assistant")
|
||||
const lastAssistant = assistants.at(-1)!
|
||||
const lastPartID = assistants.at(-1)!.parts[0]!.id
|
||||
const userPartID = `prt_${userID}_text`
|
||||
const lastPartID = `${assistants.at(-1)!.info.id}:text:0`
|
||||
const userPartID = `${messages.at(-2)!.info.id}:text:0`
|
||||
const completed = {
|
||||
...lastAssistant.info,
|
||||
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
|
||||
@@ -59,6 +62,7 @@ for (const scenario of scenarios) {
|
||||
retry: 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: project(),
|
||||
provider: {
|
||||
@@ -154,15 +158,23 @@ for (const scenario of scenarios) {
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
|
||||
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
|
||||
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await viewport.hover()
|
||||
const deadline = Date.now() + 10_000
|
||||
while (requests.filter((request) => request.phase === "start").length < 2) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
|
||||
await page.mouse.wheel(0, -240)
|
||||
await page.waitForTimeout(20)
|
||||
}
|
||||
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
||||
expect(sequence.slice(0, 4)).toEqual([
|
||||
expect(sequence.slice(0, 3)).toEqual([
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`message:${userID}`,
|
||||
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
||||
])
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
|
||||
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
|
||||
initialPageSize / 2,
|
||||
)
|
||||
await page.evaluate(() => {
|
||||
;(
|
||||
window as Window & {
|
||||
@@ -174,7 +186,9 @@ for (const scenario of scenarios) {
|
||||
expect(await visibleContentHidden(page)).toBe(false)
|
||||
const beforeHistory = await probeSamples(page)
|
||||
history.resolve()
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
|
||||
await expect
|
||||
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
|
||||
.toBeGreaterThan(initialPageSize / 2)
|
||||
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
@@ -182,7 +196,7 @@ for (const scenario of scenarios) {
|
||||
{ before: undefined, limit: initialPageSize },
|
||||
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
||||
])
|
||||
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
||||
expect(roots).toEqual([])
|
||||
|
||||
const message = messageUpdated(scenario.info)
|
||||
const idle = status("idle")
|
||||
|
||||
@@ -103,7 +103,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
|
||||
await timeline.send(status("idle"), 350)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response")
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
|
||||
@@ -89,7 +89,6 @@ test.describe("session timeline projection", () => {
|
||||
const aborted = assistantMessage(
|
||||
[
|
||||
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
|
||||
{ id: "prt_compaction", type: "compaction", auto: true },
|
||||
],
|
||||
{
|
||||
id: "msg_1001_assistant_aborted",
|
||||
@@ -122,13 +121,13 @@ test.describe("session timeline projection", () => {
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
|
||||
await expect(page.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Before interruption", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Visible provider failure")).toBeVisible()
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders comment strips and historical diff summary overflow", async ({ page }) => {
|
||||
test("renders legacy synthetic comments as ordinary V2 user text", async ({ page }) => {
|
||||
const user = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
@@ -159,10 +158,14 @@ test.describe("session timeline projection", () => {
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible()
|
||||
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.getByText(/show all/i)).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
@@ -70,7 +71,7 @@ for (const profile of profiles) {
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0)
|
||||
if (!profile.summaries && profile.reasoning.trim()) {
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
}
|
||||
@@ -89,5 +90,5 @@ test("does not infer reasoning visibility from provider identity", async ({ page
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -23,10 +23,11 @@ test("groups singleton and separated context operations at correct boundaries",
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
|
||||
})
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
|
||||
@@ -145,7 +145,6 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
|
||||
}),
|
||||
],
|
||||
})
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
|
||||
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
||||
|
||||
@@ -90,7 +90,7 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
@@ -107,10 +107,10 @@ test("passes through non-event fetches", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const health = await page.evaluate(async () => {
|
||||
const response = await fetch("/global/health")
|
||||
const response = await fetch("/api/health")
|
||||
return response.json()
|
||||
})
|
||||
|
||||
expect(health).toEqual({ healthy: true })
|
||||
expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 })
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ type EventPayload = {
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||
|
||||
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
||||
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
|
||||
test.setTimeout(90_000)
|
||||
const events: EventPayload[] = []
|
||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||
@@ -86,28 +86,8 @@ test("animates todo lifecycle without replaying it across session tabs", async (
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
const returningOpen = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
||||
expect(openSamples.length).toBeGreaterThan(0)
|
||||
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
||||
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
|
||||
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
||||
const closing = sampleDock(page, 1_000)
|
||||
todos[sourceID] = completedTodos
|
||||
events.push(todoEvent(sourceID, completedTodos))
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
todos[sourceID] = []
|
||||
events.push(todoEvent(sourceID, []))
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
const returningEmpty = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
||||
})
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
|
||||
@@ -89,23 +89,19 @@ async function mockServer(page: Page) {
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||
return new Promise(() => {})
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||
if (byId) return json(route, byId)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
|
||||
@@ -10,7 +10,6 @@ const title = "Hidden terminal regression"
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -66,7 +66,6 @@ async function readProbe(page: Page) {
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -21,7 +21,7 @@ const words = [
|
||||
"vector",
|
||||
]
|
||||
|
||||
const serverKey = "http://127.0.0.1:4096"
|
||||
const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sourceID = "ses_smoke_source"
|
||||
const targetID = "ses_smoke_target"
|
||||
const directory = "C:/OpenCode/SmokeProject"
|
||||
@@ -134,7 +134,7 @@ function toolPart(
|
||||
return {
|
||||
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
||||
type: "tool",
|
||||
callID: id("call", index * 10 + partIndex),
|
||||
callID: id("call", index * 100 + partIndex),
|
||||
tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
@@ -235,8 +235,17 @@ function renderable(part: MessagePart) {
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
function orderedParts(message: Message) {
|
||||
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
function currentPartIDs(message: Message) {
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.parts
|
||||
.flatMap((part) => {
|
||||
if (!renderable(part)) return []
|
||||
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
|
||||
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
|
||||
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
|
||||
return []
|
||||
})
|
||||
.sort()
|
||||
}
|
||||
|
||||
export const fixture = {
|
||||
@@ -290,12 +299,10 @@ export const fixture = {
|
||||
targetMessageIDs: targetMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) =>
|
||||
orderedParts(message)
|
||||
.filter(renderable)
|
||||
.map((part) => part.id),
|
||||
),
|
||||
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
|
||||
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
||||
expandedShellPartID: targetMessages
|
||||
.flatMap((message) => message.parts)
|
||||
.find((part) => part.tool === "bash")!.callID,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ test.describe("smoke: session timeline", () => {
|
||||
test("keeps the visible message fixed while prepending history", async ({ page }) => {
|
||||
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -91,6 +92,7 @@ test.describe("smoke: session timeline", () => {
|
||||
|
||||
test("preserves the timeline gap above the composer", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -117,6 +119,7 @@ test.describe("smoke: session timeline", () => {
|
||||
|
||||
test("paints cached session tabs at the latest message", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -125,20 +128,19 @@ test.describe("smoke: session timeline", () => {
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
await page.addInitScript(
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
({ server, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
server: "http://127.0.0.1:4096",
|
||||
dirBase64,
|
||||
server,
|
||||
sessionId,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
)
|
||||
|
||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
|
||||
@@ -243,6 +245,7 @@ test.describe("smoke: session timeline", () => {
|
||||
|
||||
test("paints a cold session tab at the latest message", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -251,20 +254,19 @@ test.describe("smoke: session timeline", () => {
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
await page.addInitScript(
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
({ server, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
server: "http://127.0.0.1:4096",
|
||||
dirBase64,
|
||||
server,
|
||||
sessionId,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
)
|
||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
@@ -322,6 +324,7 @@ test.describe("smoke: session timeline", () => {
|
||||
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
||||
const errors = trackPageErrors(page)
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./reproduction/timeline-suspense/**/*.ts",
|
||||
"./reproduction/timeline-suspense/**/*.tsx",
|
||||
"../src/types.ts",
|
||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
|
||||
@@ -4,12 +4,9 @@ import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/NewProject"
|
||||
|
||||
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
|
||||
let connectedGo = false
|
||||
let pendingGo = false
|
||||
const connections: Array<{ integrationID: string; body: unknown }> = []
|
||||
|
||||
test("creates a session in a new project and selects its model", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_model_selection_flow",
|
||||
@@ -46,17 +43,9 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
|
||||
connected: ["opencode", "opencode-go"],
|
||||
default: { providerID: "opencode", modelID: "free-model" },
|
||||
}),
|
||||
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
|
||||
onConnectKey: (input) => {
|
||||
connections.push(input)
|
||||
if (input.integrationID === "opencode-go") pendingGo = true
|
||||
},
|
||||
onInstanceDispose: () => {
|
||||
if (pendingGo) connectedGo = true
|
||||
},
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
fileList: (path) =>
|
||||
@@ -66,6 +55,17 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
user: [
|
||||
{ providerID: "opencode", modelID: "free-model", visibility: "show" },
|
||||
{ providerID: "opencode-go", modelID: "go-model-1", visibility: "show" },
|
||||
],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto("/")
|
||||
@@ -79,16 +79,7 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
|
||||
|
||||
const modelControl = page.locator('[data-action="prompt-model"]')
|
||||
await modelControl.click()
|
||||
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
|
||||
|
||||
await page.locator('[data-provider-id="opencode-go"]').click()
|
||||
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
|
||||
await page.locator('[data-action="provider-connect-submit"]').click()
|
||||
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
|
||||
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
|
||||
|
||||
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
|
||||
await modelControl.click()
|
||||
await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
|
||||
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||
await expect(goModel).toBeVisible()
|
||||
await goModel.click()
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import type {
|
||||
JsonValue,
|
||||
PromptAgentAttachment,
|
||||
PromptFileAttachment,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
} from "@opencode-ai/client/promise"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||
@@ -47,7 +55,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
"/vcs": { branch: "main", default_branch: "main" },
|
||||
"/session": config.sessions,
|
||||
}
|
||||
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
@@ -74,11 +81,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}
|
||||
if (path === "/global/health")
|
||||
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
||||
if (path === "/api/health" && config.protocol === "v2")
|
||||
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||
if (path === "/provider")
|
||||
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
|
||||
if (path === "/provider") return json(route, providerConfig(config))
|
||||
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
||||
if (legacyAuth && route.request().method() === "PUT") {
|
||||
@@ -134,7 +139,17 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
},
|
||||
],
|
||||
})
|
||||
if (path === "/api/provider")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: currentProviders(providerConfig(config)),
|
||||
})
|
||||
if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
|
||||
if (path === "/api/model/default")
|
||||
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
||||
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp/resource")
|
||||
return json(route, { location: location(config), data: { resources: [], templates: [] } })
|
||||
@@ -142,25 +157,31 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (integration && route.request().method() === "GET")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
||||
data: {
|
||||
id: integration,
|
||||
name: integration,
|
||||
methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
|
||||
connections: [],
|
||||
},
|
||||
})
|
||||
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
|
||||
if (integrationConnect && route.request().method() === "POST") {
|
||||
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path === "/api/project") return json(route, [config.project])
|
||||
if (path === "/api/project/current")
|
||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
|
||||
if (path === "/api/path")
|
||||
return json(route, {
|
||||
state: config.directory,
|
||||
config: config.directory,
|
||||
worktree: config.directory,
|
||||
directory: config.directory,
|
||||
home: "C:/OpenCode",
|
||||
})
|
||||
if (path === "/api/location") return json(route, location(config))
|
||||
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
|
||||
if (projectCopy && route.request().method() === "POST") {
|
||||
const input = route.request().postDataJSON() as { directory: string; name?: string }
|
||||
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
|
||||
}
|
||||
if (projectCopy && route.request().method() === "DELETE")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path === "/api/permission/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
@@ -177,11 +198,43 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
||||
if (path === "/api/fs/list" && config.fileList)
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: await config.fileList(url.searchParams.get("path") ?? ""),
|
||||
})
|
||||
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
|
||||
if (fileRead && config.fileContent) {
|
||||
const value = await config.fileContent(decodeURIComponent(fileRead))
|
||||
const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
||||
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
|
||||
}
|
||||
if (path === "/api/fs/find" && config.findFiles) {
|
||||
const entries = await config.findFiles({
|
||||
query: url.searchParams.get("query") ?? "",
|
||||
dirs: url.searchParams.get("type") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
})
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: Array.isArray(entries)
|
||||
? entries.map((entry) =>
|
||||
typeof entry === "string"
|
||||
? {
|
||||
name: entry.split(/[\\/]/).at(-1) ?? entry,
|
||||
path: entry,
|
||||
absolute: `${config.directory}/${entry}`,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
}
|
||||
: entry,
|
||||
)
|
||||
: entries,
|
||||
})
|
||||
}
|
||||
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
|
||||
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
||||
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path === "/api/session") {
|
||||
const directory = url.searchParams.get("directory")
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
@@ -208,7 +261,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
if (path === "/api/session/active") {
|
||||
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
|
||||
const statuses = (
|
||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
|
||||
) as Record<string, { type?: string }>
|
||||
return json(route, {
|
||||
data: Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([id, status]) =>
|
||||
@@ -226,12 +281,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") return json(route, true)
|
||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
|
||||
return json(route, true)
|
||||
}
|
||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
|
||||
return json(route, true)
|
||||
}
|
||||
if (
|
||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
route.request().method() === "POST"
|
||||
@@ -241,6 +293,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||
@@ -252,12 +306,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||
if (sessionMatch) {
|
||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||
return json(route, session ?? {})
|
||||
const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (currentMessageMatch) {
|
||||
config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!)
|
||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||
return json(route, { data: currentMessage(message) })
|
||||
}
|
||||
|
||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||
if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {})
|
||||
|
||||
const projectMatch = path.match(/^\/project\/([^/]+)$/)
|
||||
if (projectMatch) return json(route, config.project)
|
||||
|
||||
@@ -300,8 +360,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
if (!pageData.cursor) return json(route, pageData.items)
|
||||
const cursor = `cursor_${++nextCursor}`
|
||||
@@ -317,10 +376,75 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
function location(config: MockServerConfig) {
|
||||
return {
|
||||
directory: config.directory,
|
||||
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
||||
project: { id: (config.project as { id?: string }).id, directory: config.directory, canonical: config.directory },
|
||||
}
|
||||
}
|
||||
|
||||
function providerConfig(config: MockServerConfig) {
|
||||
return typeof config.provider === "function" ? config.provider() : config.provider
|
||||
}
|
||||
|
||||
function currentProviders(value: unknown) {
|
||||
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
|
||||
return value.all.filter(record).flatMap((provider) =>
|
||||
typeof provider.id === "string" && typeof provider.name === "string"
|
||||
? [{ id: provider.id, name: provider.name, package: provider.id }]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function currentModels(value: unknown) {
|
||||
if (!record(value) || !Array.isArray(value.all)) return []
|
||||
return value.all.filter(record).flatMap((provider) => {
|
||||
if (typeof provider.id !== "string" || !record(provider.models)) return []
|
||||
return Object.values(provider.models)
|
||||
.filter(record)
|
||||
.flatMap((model) => {
|
||||
if (typeof model.id !== "string" || typeof model.name !== "string") return []
|
||||
const limit = record(model.limit) ? model.limit : {}
|
||||
const cost = record(model.cost) ? model.cost : {}
|
||||
return [
|
||||
{
|
||||
id: model.id,
|
||||
modelID: model.id,
|
||||
providerID: provider.id,
|
||||
name: model.name,
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: record(model.variants)
|
||||
? Object.entries(model.variants).map(([id, settings]) => ({
|
||||
id,
|
||||
...(jsonRecord(settings) ? { settings: jsonRecord(settings) } : {}),
|
||||
}))
|
||||
: [],
|
||||
time: { released: Date.now() },
|
||||
cost: [
|
||||
{
|
||||
input: typeof cost.input === "number" ? cost.input : 0,
|
||||
output: typeof cost.output === "number" ? cost.output : 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: {
|
||||
context: typeof limit.context === "number" ? limit.context : 200_000,
|
||||
output: typeof limit.output === "number" ? limit.output : 32_000,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function currentDefaultModel(value: unknown) {
|
||||
if (!record(value) || !record(value.default)) return null
|
||||
const selected = value.default
|
||||
const models = currentModels(value)
|
||||
return models.find(
|
||||
(model) => model.providerID === selected.providerID && model.id === selected.modelID,
|
||||
) ?? null
|
||||
}
|
||||
|
||||
function currentPermission(value: unknown) {
|
||||
const permission = value as Record<string, unknown>
|
||||
if (permission.action) return permission
|
||||
@@ -364,65 +488,224 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function currentMessage(value: unknown) {
|
||||
const item = value as {
|
||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
|
||||
parts: Array<Record<string, unknown> & { type: string }>
|
||||
export function currentMessage(value: unknown): SessionMessageInfo {
|
||||
if (isCurrentMessage(value)) return value
|
||||
if (!record(value) || !record(value.info) || !Array.isArray(value.parts)) throw new Error("Invalid message fixture")
|
||||
|
||||
const info = value.info
|
||||
const parts = value.parts.filter(record)
|
||||
if (typeof info.id !== "string" || !record(info.time) || typeof info.time.created !== "number")
|
||||
throw new Error("Invalid legacy message fixture")
|
||||
|
||||
const time = {
|
||||
created: info.time.created,
|
||||
...(typeof info.time.completed === "number" ? { completed: info.time.completed } : {}),
|
||||
}
|
||||
if (item.info.role === "user") {
|
||||
if (info.role === "user") {
|
||||
return {
|
||||
id: item.info.id,
|
||||
id: info.id,
|
||||
type: "user",
|
||||
time: item.info.time,
|
||||
text: item.parts
|
||||
time: { created: time.created },
|
||||
text: parts
|
||||
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
||||
.join("\n"),
|
||||
files: parts.flatMap((part) => (part.type === "file" ? legacyFile(part) : [])),
|
||||
agents: parts.flatMap((part) => (part.type === "agent" ? legacyAgent(part) : [])),
|
||||
}
|
||||
}
|
||||
if (info.role !== "assistant") throw new Error("Invalid legacy message role")
|
||||
|
||||
return {
|
||||
id: item.info.id,
|
||||
id: info.id,
|
||||
type: "assistant",
|
||||
time: item.info.time,
|
||||
agent: item.info.agent ?? "build",
|
||||
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
|
||||
cost: item.info.cost,
|
||||
tokens: item.info.tokens,
|
||||
error: item.info.error,
|
||||
content: item.parts.flatMap<unknown>((part) => {
|
||||
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
|
||||
if (part.type !== "tool") return []
|
||||
const state = part.state as Record<string, unknown>
|
||||
return [
|
||||
{
|
||||
type: "tool",
|
||||
id: part.id,
|
||||
name: part.tool,
|
||||
time: state.time ?? { created: item.info.time.created },
|
||||
state:
|
||||
state.status === "pending"
|
||||
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
|
||||
: state.status === "completed"
|
||||
? {
|
||||
status: "completed",
|
||||
input: state.input ?? {},
|
||||
structured: state.metadata ?? {},
|
||||
content: [{ type: "text", text: state.output ?? "" }],
|
||||
}
|
||||
: state.status === "error"
|
||||
? {
|
||||
status: "error",
|
||||
input: state.input ?? {},
|
||||
structured: state.metadata ?? {},
|
||||
content: [],
|
||||
error: { type: "ToolError", message: state.error ?? "Tool failed" },
|
||||
}
|
||||
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
|
||||
},
|
||||
]
|
||||
}),
|
||||
time,
|
||||
agent: typeof info.agent === "string" ? info.agent : typeof info.mode === "string" ? info.mode : "build",
|
||||
model: {
|
||||
id: typeof info.modelID === "string" ? info.modelID : "model",
|
||||
providerID: typeof info.providerID === "string" ? info.providerID : "provider",
|
||||
...(typeof info.variant === "string" ? { variant: info.variant } : {}),
|
||||
},
|
||||
content: parts.flatMap((part) => legacyAssistantContent(part, time.created)),
|
||||
...(typeof info.cost === "number" ? { cost: info.cost } : {}),
|
||||
...(tokens(info.tokens) ? { tokens: tokens(info.tokens) } : {}),
|
||||
...(structuredError(info.error) ? { error: structuredError(info.error) } : {}),
|
||||
...(finish(info.finish) ? { finish: finish(info.finish) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentMessage(value: unknown): value is SessionMessageInfo {
|
||||
return record(value) && typeof value.id === "string" && typeof value.type === "string" && !record(value.info)
|
||||
}
|
||||
|
||||
function legacyFile(part: Record<string, unknown>): PromptFileAttachment[] {
|
||||
if (typeof part.mime !== "string" || typeof part.url !== "string") return []
|
||||
const data = part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? ""
|
||||
const source = record(part.source) ? part.source : undefined
|
||||
const sourceText = source && record(source.text) ? source.text : undefined
|
||||
const mention = mentionFrom(sourceText)
|
||||
const uri = source?.type === "resource" && typeof source.uri === "string" ? source.uri : part.url
|
||||
return [
|
||||
{
|
||||
data,
|
||||
mime: part.mime,
|
||||
source: part.url.startsWith("data:") ? { type: "inline" } : { type: "uri", uri },
|
||||
...(typeof part.filename === "string" ? { name: part.filename } : {}),
|
||||
...(mention ? { mention } : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
|
||||
if (typeof part.name !== "string") return []
|
||||
const mention = mentionFrom(record(part.source) ? part.source : undefined)
|
||||
return [{ name: part.name, ...(mention ? { mention } : {}) }]
|
||||
}
|
||||
|
||||
function mentionFrom(value: Record<string, unknown> | undefined) {
|
||||
if (
|
||||
!value ||
|
||||
typeof value.value !== "string" ||
|
||||
typeof value.start !== "number" ||
|
||||
typeof value.end !== "number"
|
||||
)
|
||||
return
|
||||
return { text: value.value, start: value.start, end: value.end }
|
||||
}
|
||||
|
||||
function legacyAssistantContent(
|
||||
part: Record<string, unknown>,
|
||||
created: number,
|
||||
): SessionMessageAssistant["content"] {
|
||||
if (part.type === "text" && typeof part.text === "string")
|
||||
return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }]
|
||||
if (part.type === "reasoning" && typeof part.text === "string") {
|
||||
const time = record(part.time) ? part.time : undefined
|
||||
return [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}),
|
||||
...(time && typeof time.start === "number"
|
||||
? {
|
||||
time: {
|
||||
created: time.start,
|
||||
...(typeof time.end === "number" ? { completed: time.end } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type !== "tool" || typeof part.id !== "string" || typeof part.tool !== "string" || !record(part.state))
|
||||
return []
|
||||
|
||||
const state = part.state
|
||||
const time = record(state.time) ? state.time : undefined
|
||||
const toolTime = {
|
||||
created: time && typeof time.start === "number" ? time.start : created,
|
||||
...(time && typeof time.start === "number" ? { ran: time.start } : {}),
|
||||
...(time && typeof time.end === "number" ? { completed: time.end } : {}),
|
||||
}
|
||||
const input = jsonRecord(state.input) ?? {}
|
||||
const metadata = jsonRecord(state.metadata)
|
||||
const base = {
|
||||
type: "tool" as const,
|
||||
id: typeof part.callID === "string" ? part.callID : part.id,
|
||||
name: part.tool,
|
||||
time: toolTime,
|
||||
...(typeof part.executed === "boolean" ? { executed: part.executed } : {}),
|
||||
...(jsonRecord(part.providerState) ? { providerState: jsonRecord(part.providerState) } : {}),
|
||||
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
||||
}
|
||||
if (state.status === "pending")
|
||||
return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }]
|
||||
if (state.status === "completed")
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
state: {
|
||||
status: "completed",
|
||||
input,
|
||||
content: [{ type: "text", text: typeof state.output === "string" ? state.output : "" }],
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
if (state.status === "error")
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
state: {
|
||||
status: "error",
|
||||
input,
|
||||
error: structuredError(state.error) ?? { type: "ToolError", message: "Tool failed" },
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
return [{ ...base, state: { status: "running", input, metadata: metadata ?? {} } }]
|
||||
}
|
||||
|
||||
function structuredError(value: unknown): SessionStructuredError | undefined {
|
||||
if (typeof value === "string") return { type: "Error", message: value }
|
||||
if (!record(value)) return
|
||||
if (typeof value.type === "string" && typeof value.message === "string")
|
||||
return { type: value.type, message: value.message }
|
||||
if (typeof value.name !== "string" || !record(value.data) || typeof value.data.message !== "string") return
|
||||
return { type: value.name, message: value.data.message }
|
||||
}
|
||||
|
||||
function tokens(value: unknown): SessionMessageAssistant["tokens"] | undefined {
|
||||
if (!record(value) || !record(value.cache)) return
|
||||
if (
|
||||
typeof value.input !== "number" ||
|
||||
typeof value.output !== "number" ||
|
||||
typeof value.reasoning !== "number" ||
|
||||
typeof value.cache.read !== "number" ||
|
||||
typeof value.cache.write !== "number"
|
||||
)
|
||||
return
|
||||
return {
|
||||
input: value.input,
|
||||
output: value.output,
|
||||
reasoning: value.reasoning,
|
||||
cache: { read: value.cache.read, write: value.cache.write },
|
||||
}
|
||||
}
|
||||
|
||||
function finish(value: unknown): SessionMessageAssistant["finish"] | undefined {
|
||||
if (
|
||||
value === "stop" ||
|
||||
value === "length" ||
|
||||
value === "tool-calls" ||
|
||||
value === "content-filter" ||
|
||||
value === "error" ||
|
||||
value === "unknown"
|
||||
)
|
||||
return value
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown): Record<string, JsonValue> | undefined {
|
||||
if (!record(value)) return
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
const next = jsonValue(item)
|
||||
return next === undefined ? [] : [[key, next]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function jsonValue(value: unknown): JsonValue | undefined {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
||||
if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null)
|
||||
return jsonRecord(value)
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
||||
export type SseConnectionRecord = {
|
||||
id: number
|
||||
url: string
|
||||
path: "/global/event" | "/event" | "/api/event"
|
||||
path: "/api/event"
|
||||
headers: Record<string, string>
|
||||
openedAt: number
|
||||
endedAt?: number
|
||||
@@ -174,10 +174,7 @@ export async function installSseTransport<T>(
|
||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (
|
||||
url.origin !== server ||
|
||||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
||||
)
|
||||
if (url.origin !== server || url.pathname !== "/api/event")
|
||||
return originalFetch(request)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
@@ -193,18 +190,9 @@ export async function installSseTransport<T>(
|
||||
record.controller = controller
|
||||
connections.push(record)
|
||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||
if (url.pathname === "/api/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
if (url.pathname === "/global/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
frame({
|
||||
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||
}),
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"happy-dom": "20.11.1",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
@@ -56,7 +57,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
@@ -155,7 +155,7 @@ function LegacyTargetSessionRedirect() {
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const directory = current()?.session.directory
|
||||
const directory = current()?.session.location.directory
|
||||
if (!directory) return
|
||||
navigate(legacySessionHref(directory, params.id), { replace: true })
|
||||
})
|
||||
@@ -238,26 +238,6 @@ function UiI18nBridge(props: ParentProps) {
|
||||
}
|
||||
|
||||
function LayoutCompatibility(props: ParentProps) {
|
||||
const global = useGlobal()
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const settings = useSettings()
|
||||
|
||||
createEffect(() => {
|
||||
if (settings.general.newLayoutDesigns()) return
|
||||
const current = server.current
|
||||
if (!current) return
|
||||
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
|
||||
if (protocol !== "v2") return
|
||||
const next = global.servers.list().find((s) => {
|
||||
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
|
||||
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
|
||||
})
|
||||
if (!next) return
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
|
||||
})
|
||||
|
||||
return <>{props.children}</>
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@@ -1,5 +1,5 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Project } from "@/types"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
@@ -14,7 +14,6 @@ import { useTabs } from "@/context/tabs"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
|
||||
export type CommandPaletteEntry = {
|
||||
id: string
|
||||
@@ -257,7 +256,6 @@ export function createServerSessionEntries(props: {
|
||||
.load(search, current.signal)
|
||||
.then((result) =>
|
||||
result.data
|
||||
.map(normalizeSessionInfo)
|
||||
.filter((session) => !session.time.archived)
|
||||
.map((session) => {
|
||||
const project =
|
||||
@@ -266,9 +264,9 @@ export function createServerSessionEntries(props: {
|
||||
id: `session:${props.server}:${session.id}`,
|
||||
type: "session" as const,
|
||||
title: session.title || props.untitled(),
|
||||
description: project ? displayName(project) : getFilename(session.directory),
|
||||
description: project ? displayName(project) : getFilename(session.location.directory),
|
||||
category: props.category(),
|
||||
directory: session.directory,
|
||||
directory: session.location.directory,
|
||||
sessionID: session.id,
|
||||
server: props.server,
|
||||
project,
|
||||
|
||||
@@ -130,26 +130,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
}
|
||||
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
if (result.key) {
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: result.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await serverSync().updateConfig({
|
||||
provider: { [result.providerID]: result.config },
|
||||
disabled_providers: nextDisabled,
|
||||
})
|
||||
return result
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => {
|
||||
// TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs.
|
||||
throw new Error(`Custom provider ${result.providerID} is unavailable`)
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
dialog.close()
|
||||
|
||||
@@ -146,7 +146,7 @@ export function DialogEditProjectV2(props: { project: LocalProject; server: Serv
|
||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
|
||||
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -160,7 +160,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
|
||||
<Button type="submit" variant="primary" size="large" disabled={!model.supported || model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { TextPart as SDKTextPart } from "@/types"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Path } from "@/types"
|
||||
import {
|
||||
absoluteTreePath,
|
||||
activeTreeNavigation,
|
||||
@@ -66,16 +66,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
let pathArea: HTMLDivElement | undefined
|
||||
let navigation = 0
|
||||
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async (): Promise<Path | undefined> => {
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
return sdk.client.path
|
||||
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
||||
() =>
|
||||
sdk.api.location
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
.then(
|
||||
(location): Path => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Path } from "@/types"
|
||||
|
||||
interface DialogSelectDirectoryProps {
|
||||
title?: string
|
||||
@@ -57,16 +57,21 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const [filter, setFilter] = createSignal("")
|
||||
let list: ListRef | undefined
|
||||
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async (): Promise<Path | undefined> => {
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
return sdk.client.path
|
||||
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
||||
() =>
|
||||
sdk.api.location
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
.then(
|
||||
(location): Path => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
@@ -264,13 +263,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
if (options.navigateOnAdd === false) {
|
||||
@@ -315,13 +307,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === input.original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
@@ -360,9 +345,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const raw = items()
|
||||
const list = settings.general.newLayoutDesigns()
|
||||
? raw
|
||||
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||
const list = raw
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
|
||||
@@ -9,6 +9,7 @@ import { type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const supported = !props.project.id || props.project.id === "global"
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
@@ -71,29 +72,9 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
if ((await serverCtx().sdk.protocol) !== "v1") return
|
||||
const project = await serverCtx()
|
||||
.sdk.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
})
|
||||
.then((result) => result.data)
|
||||
if (!project) return
|
||||
// const project = await serverCtx().sdk.api.project.update({
|
||||
// projectID: props.project.id,
|
||||
// name,
|
||||
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
// commands: { start },
|
||||
// })
|
||||
serverCtx().sync.set("project", (items) =>
|
||||
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||
)
|
||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
// TODO: Restore project edits when the V2 client exposes a project update API.
|
||||
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
|
||||
throw new Error(`Project ${props.project.id} cannot be updated`)
|
||||
}
|
||||
|
||||
serverCtx().sync.project.meta(props.project.worktree, {
|
||||
@@ -107,7 +88,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault()
|
||||
if (save.isPending) return
|
||||
if (!supported || save.isPending) return
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
@@ -117,6 +98,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
folderName,
|
||||
defaultName,
|
||||
save,
|
||||
supported,
|
||||
submit,
|
||||
drop,
|
||||
dragOver,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
|
||||
describe("buildFileTreeV2Model", () => {
|
||||
test("builds a sorted tree and flattens expanded directories", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
|
||||
export type FileTreeV2Model = {
|
||||
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ReferenceInfo } from "@opencode-ai/client/promise"
|
||||
import { createEffect, createMemo, on, Show } from "solid-js"
|
||||
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import type { Todo } from "@/types"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
@@ -81,7 +81,7 @@ import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/place
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ReferenceInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
export { createPromptInputHistory }
|
||||
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
||||
|
||||
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
||||
type PromptRequestPart =
|
||||
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||
|
||||
type ContextFile = {
|
||||
key: string
|
||||
|
||||
@@ -31,6 +31,12 @@ const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string
|
||||
const sentPrompts: string[] = []
|
||||
const promptInputs: unknown[] = []
|
||||
const sentCommands: unknown[] = []
|
||||
const switchedAgents: Array<{ sessionID: string; agent: string }> = []
|
||||
const switchedModels: Array<{
|
||||
sessionID: string
|
||||
model: { id: string; providerID: string; variant?: string }
|
||||
}> = []
|
||||
const sessionRequestOrder: string[] = []
|
||||
const commands: Array<{ name: string }> = []
|
||||
let serverSessionSyncs = 0
|
||||
|
||||
@@ -93,10 +99,22 @@ const clientFor = (directory: string) => {
|
||||
}
|
||||
},
|
||||
prompt: async (input: unknown) => {
|
||||
sessionRequestOrder.push("prompt")
|
||||
sentPrompts.push(directory)
|
||||
promptInputs.push(input)
|
||||
return { data: undefined }
|
||||
},
|
||||
switchAgent: async (input: { sessionID: string; agent: string }) => {
|
||||
sessionRequestOrder.push("agent")
|
||||
switchedAgents.push(input)
|
||||
},
|
||||
switchModel: async (input: {
|
||||
sessionID: string
|
||||
model: { id: string; providerID: string; variant?: string }
|
||||
}) => {
|
||||
sessionRequestOrder.push("model")
|
||||
switchedModels.push(input)
|
||||
},
|
||||
command: async (input: unknown) => {
|
||||
sentCommands.push(input)
|
||||
},
|
||||
@@ -125,13 +143,6 @@ beforeAll(async () => {
|
||||
useSearchParams: () => [search, () => undefined],
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: (input: { directory: string }) => {
|
||||
createdClients.push(input.directory)
|
||||
return clientFor(input.directory)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
showToast: () => 0,
|
||||
@@ -197,12 +208,8 @@ beforeAll(async () => {
|
||||
const sdk = {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
client: rootClient,
|
||||
api: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
createClient(opts: any) {
|
||||
return clientFor(opts.directory)
|
||||
},
|
||||
}
|
||||
return () => sdk
|
||||
},
|
||||
@@ -290,6 +297,9 @@ beforeEach(() => {
|
||||
sentPrompts.length = 0
|
||||
promptInputs.length = 0
|
||||
sentCommands.length = 0
|
||||
switchedAgents.length = 0
|
||||
switchedModels.length = 0
|
||||
sessionRequestOrder.length = 0
|
||||
commands.length = 0
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
params = {}
|
||||
@@ -332,7 +342,7 @@ describe("prompt submit worktree selection", () => {
|
||||
selected = "/repo/worktree-b"
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdClients).toEqual([])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sessionCreateInputs).toEqual([
|
||||
{
|
||||
@@ -447,13 +457,17 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
||||
})
|
||||
|
||||
test("includes the selected variant on optimistic prompts", async () => {
|
||||
test("switches the selected agent and model before prompting", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
info: () => ({
|
||||
id: "session-1",
|
||||
agent: "old-agent",
|
||||
model: { id: "old-model", providerID: "old-provider" },
|
||||
}),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
@@ -482,6 +496,14 @@ describe("prompt submit worktree selection", () => {
|
||||
},
|
||||
})
|
||||
expect(sentPrompts).toEqual(["/repo/main"])
|
||||
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
|
||||
expect(switchedModels).toEqual([
|
||||
{
|
||||
sessionID: "session-1",
|
||||
model: { id: "model", providerID: "provider", variant: "high" },
|
||||
},
|
||||
])
|
||||
expect(sessionRequestOrder).toEqual(["agent", "model", "prompt"])
|
||||
expect(promptInputs[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
text: "ls",
|
||||
@@ -489,9 +511,6 @@ describe("prompt submit worktree selection", () => {
|
||||
agents: [],
|
||||
})
|
||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
|
||||
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
|
||||
])
|
||||
})
|
||||
|
||||
test("submits slash commands through the current session API", async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message } from "@/types"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
@@ -15,12 +16,12 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
|
||||
type PendingPrompt = {
|
||||
@@ -44,6 +45,7 @@ type FollowupSendInput = {
|
||||
api: DirectorySDK["api"]["session"]
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
optimisticBusy?: boolean
|
||||
@@ -156,13 +158,28 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
const session = input.session()
|
||||
if (session?.agent !== input.draft.agent) {
|
||||
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
|
||||
}
|
||||
if (
|
||||
session?.model?.providerID !== input.draft.model.providerID ||
|
||||
session.model.id !== input.draft.model.modelID ||
|
||||
(session.model.variant ?? "default") !== (input.draft.variant ?? "default")
|
||||
) {
|
||||
await input.api.switchModel({
|
||||
sessionID: input.draft.sessionID,
|
||||
model: {
|
||||
id: input.draft.model.modelID,
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await input.api.prompt({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
variant: input.draft.variant,
|
||||
legacyParts: requestParts,
|
||||
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
files: requestParts.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
@@ -200,7 +217,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
|
||||
type PromptSubmitInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
info: Accessor<{ id: string } | undefined>
|
||||
info: Accessor<
|
||||
{ id: string; agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined
|
||||
>
|
||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||
commentCount: Accessor<number>
|
||||
autoAccept: Accessor<boolean>
|
||||
@@ -291,10 +310,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: Session) => {
|
||||
const seed = (dir: string, info: SessionInfo) => {
|
||||
serverSync().session.remember(info)
|
||||
const [, setStore] = serverSync().child(dir)
|
||||
setStore("session", (list: Session[]) => {
|
||||
setStore("session", (list: SessionInfo[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
if (result.found) {
|
||||
@@ -348,13 +367,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = sdk().client
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await client.worktree
|
||||
.create({ directory: projectDirectory })
|
||||
.then((x) => x.data)
|
||||
const createdWorktree = await sdk()
|
||||
.api.projectCopy.create({
|
||||
projectID: sync().data.project,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(projectDirectory),
|
||||
location: { directory: projectDirectory },
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
@@ -362,14 +383,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!createdWorktree?.directory) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: language.t("common.requestFailed"),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!createdWorktree) return
|
||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
@@ -379,10 +393,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = sdk().createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
serverSync().child(sessionDirectory)
|
||||
}
|
||||
|
||||
@@ -397,7 +407,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.then(normalizeSessionInfo)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
@@ -487,8 +496,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
agent,
|
||||
model,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -609,6 +616,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
session: () => input.info() ?? session,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
|
||||
|
||||
const user = (id: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part } from "@/types"
|
||||
|
||||
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message } from "@/types"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
|
||||
const assistant = (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AssistantMessage, Message } from "@/types"
|
||||
|
||||
type Provider = {
|
||||
id: string
|
||||
|
||||
@@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, UserMessage } from "@/types"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
|
||||
@@ -128,11 +128,7 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
const [shells] = createResource(
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.shells()).data ?? []
|
||||
}
|
||||
// return (await sdk.api.pty.shells()).data
|
||||
// TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
@@ -331,6 +327,7 @@ export const SettingsGeneral: Component = () => {
|
||||
>
|
||||
<Select
|
||||
data-action="settings-shell"
|
||||
disabled
|
||||
options={shellOptions()}
|
||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||
value={(o) => o.id}
|
||||
@@ -338,7 +335,8 @@ export const SettingsGeneral: Component = () => {
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
serverSync().updateConfig({ shell: option.value })
|
||||
// TODO: Restore config writes when the V2 client exposes a config API.
|
||||
// void serverSync().updateConfig({ shell: option.value })
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
@@ -39,7 +39,6 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const protocol = useServerProtocol()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(() => undefined)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
@@ -84,8 +83,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) =>
|
||||
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
@@ -98,7 +96,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
if (protocol() !== "v1") return
|
||||
return
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
@@ -121,17 +119,14 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
.then(async () => {
|
||||
await serverSDK().client.global.dispose()
|
||||
.api.integration.get({ integrationID: providerID })
|
||||
.then(async (integration) => {
|
||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||
await Promise.all(
|
||||
credentials.map((credential) => serverSDK().api.credential.remove({ credentialID: credential.id })),
|
||||
)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
@@ -221,7 +216,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Show when={false}>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
||||
data-component="custom-provider-section"
|
||||
|
||||
@@ -33,7 +33,7 @@ export const DialogSettings: Component<{
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return draft?.type === "draft" ? draft.directory : undefined
|
||||
}
|
||||
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
|
||||
if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory
|
||||
return undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
@@ -91,14 +90,13 @@ export const SettingsGeneralV2: Component<{
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
const dir = createMemo(() => {
|
||||
if (!props.sessionID) return undefined
|
||||
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
|
||||
return serverSync().session.lineage.peek(props.sessionID)?.session.location.directory
|
||||
})
|
||||
const accepting = createMemo(() => {
|
||||
const value = dir()
|
||||
@@ -123,10 +121,7 @@ export const SettingsGeneralV2: Component<{
|
||||
|
||||
const [shells] = createResource(
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.shells()).data ?? []
|
||||
}
|
||||
// TODO: Restore executable shell discovery when the V2 client exposes it.
|
||||
// return (await sdk.api.pty.shells()).data
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
@@ -36,7 +36,6 @@ export const SettingsProvidersV2: Component<{
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const protocol = useServerProtocol()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
@@ -81,8 +80,7 @@ export const SettingsProvidersV2: Component<{
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) =>
|
||||
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
@@ -95,7 +93,7 @@ export const SettingsProvidersV2: Component<{
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
if (protocol() !== "v1") return
|
||||
return
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
@@ -118,17 +116,17 @@ export const SettingsProvidersV2: Component<{
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
const location = props.directory() ? { directory: props.directory() } : undefined
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
.then(async () => {
|
||||
await serverSdk().client.global.dispose()
|
||||
.api.integration.get({ integrationID: providerID, location })
|
||||
.then(async (integration) => {
|
||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||
await Promise.all(
|
||||
credentials.map((credential) =>
|
||||
serverSdk().api.credential.remove({ credentialID: credential.id, location }),
|
||||
),
|
||||
)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
@@ -224,7 +222,7 @@ export const SettingsProvidersV2: Component<{
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Show when={false}>
|
||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -16,7 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { useServerProtocol } from "@/context/server-sdk"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -251,6 +251,7 @@ function ServerStatusList(props: { state: ServerStatusState }) {
|
||||
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
@@ -258,7 +259,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const settings = useSettings()
|
||||
const protocol = useServerProtocol()
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
@@ -279,9 +279,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
dialogRun += 1
|
||||
})
|
||||
const sortedServers = createMemo(() => {
|
||||
const list = settings.general.newLayoutDesigns()
|
||||
? global.servers.list()
|
||||
: global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||
const list = global.servers.list()
|
||||
return listServersByHealth(list, server.key, global.servers.health)
|
||||
})
|
||||
const toggleMcp = useMcpToggle()
|
||||
@@ -291,9 +289,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() =>
|
||||
(sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
||||
const [pluginList] = createResource(
|
||||
() => (props.shown() ? sdk().directory : undefined),
|
||||
(directory) => sdk().api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
@@ -322,7 +322,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Show when={true}>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.plugins")}
|
||||
@@ -487,7 +487,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Show when={true}>
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { LspStatus } from "@/types"
|
||||
import type { McpServer } from "@opencode-ai/client/promise"
|
||||
|
||||
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { matchKeybind, parseKeybind } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
@@ -175,15 +174,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
||||
const connection = useServerSDK()().server
|
||||
const directory = sdk().directory
|
||||
const url = sdk().url
|
||||
const auth = connection.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
const authToken = connection.type === "http" ? connection.authToken : false
|
||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, [
|
||||
"pty",
|
||||
@@ -241,16 +233,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const pushSize = async (cols: number, rows: number) => {
|
||||
if ((await sdk().protocol) === "v1") {
|
||||
return sdk()
|
||||
.client.pty.update({
|
||||
ptyID: id,
|
||||
size: { cols, rows },
|
||||
})
|
||||
.catch((err) => {
|
||||
debugTerminal("failed to sync terminal size", err)
|
||||
})
|
||||
}
|
||||
return sdk()
|
||||
.api.pty.update({
|
||||
ptyID: id,
|
||||
@@ -533,15 +515,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const gone = async () => {
|
||||
if ((await sdk().protocol) === "v1") {
|
||||
return sdk()
|
||||
.client.pty.get({ ptyID: id }, { throwOnError: false })
|
||||
.then((result) => result.response.status === 404)
|
||||
.catch((err) => {
|
||||
debugTerminal("failed to inspect terminal session", err)
|
||||
return false
|
||||
})
|
||||
}
|
||||
return sdk()
|
||||
.api.pty.get({ ptyID: id, location: { directory } })
|
||||
.then((result) => result.data.status === "exited")
|
||||
@@ -553,33 +526,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const connectToken = async () => {
|
||||
if ((await sdk().protocol) === "v1") {
|
||||
const result = await sdk()
|
||||
.client.pty.connectToken(
|
||||
{ ptyID: id, directory },
|
||||
{
|
||||
throwOnError: false,
|
||||
headers: { "x-opencode-ticket": "1" },
|
||||
},
|
||||
)
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
||||
throw err
|
||||
})
|
||||
if (!result) return
|
||||
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
||||
if (result.response.status === 404 || result.response.status === 405) return
|
||||
if (result.response.status === 403)
|
||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
||||
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
||||
}
|
||||
// return sdk()
|
||||
// .api.pty.connectToken({
|
||||
// ptyID: id,
|
||||
// location: { directory },
|
||||
// "x-opencode-ticket": "1",
|
||||
// })
|
||||
// .then((result) => result.data.ticket)
|
||||
// TODO: Add PTY tickets when the V2 client exposes a connect-token API.
|
||||
return undefined
|
||||
}
|
||||
|
||||
const retry = (err: unknown) => {
|
||||
@@ -609,23 +557,16 @@ export const Terminal = (props: TerminalProps) => {
|
||||
fail(err)
|
||||
return undefined
|
||||
})
|
||||
const protocol = await sdk().protocol
|
||||
// if (protocol === "v2" && !ticket) return
|
||||
if (once.value) return
|
||||
if (disposed) return
|
||||
|
||||
const socket = new WebSocket(
|
||||
terminalWebSocketURL({
|
||||
protocol,
|
||||
url,
|
||||
id,
|
||||
directory,
|
||||
cursor: seek,
|
||||
ticket,
|
||||
sameOrigin,
|
||||
username,
|
||||
password,
|
||||
authToken,
|
||||
}),
|
||||
)
|
||||
socket.binaryType = "arraybuffer"
|
||||
|
||||
@@ -4,12 +4,12 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMutation } from "@tanstack/solid-query"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { sessionLabel } from "@/utils/session-title"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||
import "./titlebar-tab-nav.css"
|
||||
@@ -21,7 +21,7 @@ export function TabNavItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: () => Session | undefined
|
||||
session: () => SessionInfo | undefined
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onClose: () => void
|
||||
@@ -57,19 +57,19 @@ export function TabNavItem(props: {
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session()
|
||||
return session ? displayLabel(session) : props.fallbackTitle
|
||||
return session ? sessionLabel(session) : props.fallbackTitle
|
||||
})
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
return displayName(project() ?? { worktree: session.directory })
|
||||
return displayName(project() ?? { worktree: session.location.directory })
|
||||
})
|
||||
const previewPath = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
const home = serverCtx()?.sync.data.path.home
|
||||
return home ? session.directory.replace(home, "~") : session.directory
|
||||
return home ? session.location.directory.replace(home, "~") : session.location.directory
|
||||
})
|
||||
// Only label the server when multiple servers are connected.
|
||||
const serverLabel = createMemo(() => {
|
||||
@@ -235,7 +235,7 @@ export function TabNavItem(props: {
|
||||
{(session) => (
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
directory={session().location.directory}
|
||||
sessionId={session().id}
|
||||
server={props.server}
|
||||
/>
|
||||
@@ -306,7 +306,7 @@ export function TabNavItem(props: {
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: title(),
|
||||
title: props.session()?.title,
|
||||
path: previewPath(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
@@ -27,7 +27,7 @@ function SessionTabSlot(props: {
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
forceTruncate: boolean
|
||||
session: () => Session | undefined
|
||||
session: () => SessionInfo | undefined
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
@@ -127,7 +127,7 @@ function SessionTabEntry(props: {
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync
|
||||
.ensureDirSyncContext(value.directory)
|
||||
.ensureDirSyncContext(value.location.directory)
|
||||
.session.sync(value.id)
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
@@ -144,7 +144,7 @@ function SessionTabEntry(props: {
|
||||
const current = sdk()
|
||||
if (!current) return
|
||||
createTabPromptState(tabs, props.tab, current.scope, {
|
||||
dir: base64Encode(value.directory),
|
||||
dir: base64Encode(value.location.directory),
|
||||
id: value.id,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,6 @@ import { tabKey, useTabs } from "@/context/tabs"
|
||||
import type { PromptSession } from "@/context/prompt"
|
||||
import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
|
||||
const legacyTitlebarHeight = 40
|
||||
const v2TitlebarHeight = 36
|
||||
@@ -194,7 +193,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
({ route, sdk }) =>
|
||||
sdk.api.session
|
||||
.get({ sessionID: route.sessionId })
|
||||
.then(normalizeSessionInfo)
|
||||
.catch(() => {}),
|
||||
)
|
||||
|
||||
@@ -256,7 +254,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
sessionId: activeSession.id,
|
||||
}
|
||||
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
|
||||
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
|
||||
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -248,11 +248,6 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
// KeybindV2 takes an array instead of a string
|
||||
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
|
||||
return formatKeybindParts(config, t)
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part } from "@/types"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createMemo } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
import type { createServerSyncContextInner } from "./server-sync"
|
||||
import type { State } from "./global-sync/types"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const sessionFields = new Set([
|
||||
@@ -26,7 +26,6 @@ export const createDirSyncContext = (
|
||||
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
||||
serverSDK: ReturnType<typeof createServerSdkContext>,
|
||||
) => {
|
||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const data = new Proxy({} as State, {
|
||||
@@ -47,7 +46,7 @@ export const createDirSyncContext = (
|
||||
|
||||
const index = (sessionID: string) => {
|
||||
const session = serverSync.session.get(sessionID)
|
||||
if (!session || session.directory !== directory) return
|
||||
if (!session || session.location.directory !== directory) return
|
||||
const [store, setStore] = current()
|
||||
const result = Binary.search(store.session, session.id, (item) => item.id)
|
||||
if (result.found) {
|
||||
@@ -75,13 +74,13 @@ export const createDirSyncContext = (
|
||||
if (match.found) return serverSync.data.project[match.index]
|
||||
},
|
||||
session: {
|
||||
remember(session: Session) {
|
||||
remember(session: SessionInfo) {
|
||||
serverSync.session.remember(session)
|
||||
index(session.id)
|
||||
},
|
||||
get(sessionID: string) {
|
||||
const session = serverSync.session.get(sessionID)
|
||||
if (session?.directory === directory) return session
|
||||
if (session?.location.directory === directory) return session
|
||||
},
|
||||
optimistic: {
|
||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
||||
@@ -126,7 +125,6 @@ export const createDirSyncContext = (
|
||||
setStore("limit", (value) => value + count)
|
||||
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
|
||||
const sessions = response.data
|
||||
.map(normalizeSessionInfo)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
sessions.forEach(serverSync.session.remember)
|
||||
@@ -134,15 +132,8 @@ export const createDirSyncContext = (
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
if ((await serverSDK.protocol) !== "v1") return
|
||||
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
|
||||
current()[1](
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (session) => session.id)
|
||||
if (match.found) draft.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
||||
void sessionID
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
|
||||
@@ -81,8 +81,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
normalizeDir: path.normalizeDir,
|
||||
list: (dir) =>
|
||||
sdk()
|
||||
.client.file.list({ path: dir })
|
||||
.then((x) => x.data ?? []),
|
||||
.api.file.list({ path: dir, location: { directory: scope() } })
|
||||
.then((x) =>
|
||||
x.data.map((entry) => ({
|
||||
...entry,
|
||||
name: entry.path.split("/").at(-1) ?? entry.path,
|
||||
absolute: `${scope()}/${entry.path}`,
|
||||
ignored: false,
|
||||
})),
|
||||
),
|
||||
onError: (message) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -181,10 +188,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
setLoading(file)
|
||||
|
||||
const promise = sdk()
|
||||
.client.file.read({ path: file })
|
||||
.then((x) => {
|
||||
.api.file.read({ path: file, location: { directory } })
|
||||
.then((data) => {
|
||||
if (scope() !== directory) return
|
||||
const content = x.data
|
||||
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
|
||||
setLoaded(file, content)
|
||||
|
||||
if (!content) return
|
||||
@@ -279,13 +286,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
children: tree.children,
|
||||
expand: tree.expandDir,
|
||||
collapse: tree.collapseDir,
|
||||
toggle(input: string) {
|
||||
if (tree.dirState(input)?.expanded) {
|
||||
tree.collapseDir(input)
|
||||
return
|
||||
}
|
||||
tree.expandDir(input)
|
||||
},
|
||||
},
|
||||
get,
|
||||
load,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import type { FileContent } from "@/types"
|
||||
|
||||
const MAX_FILE_CONTENT_ENTRIES = 40
|
||||
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
|
||||
type DirectoryState = {
|
||||
expanded: boolean
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import type { FileContent } from "@/types"
|
||||
|
||||
export type FileSelection = {
|
||||
startLine: number
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
|
||||
type WatcherEvent = {
|
||||
type: string
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadPathQuery,
|
||||
@@ -13,139 +10,19 @@ import {
|
||||
loadProvidersQuery,
|
||||
loadReferencesQuery,
|
||||
} from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
type ProjectApi = ServerApi["project"]
|
||||
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
const api = {
|
||||
agent: { list: async () => ({ location: {}, data: [] }) },
|
||||
provider: { list: async () => ({ location: {}, data: [] }) },
|
||||
model: {
|
||||
list: async () => ({ location: {}, data: [] }),
|
||||
default: async () => ({ location: {}, data: null }),
|
||||
},
|
||||
permission: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||
project: {
|
||||
list: async () => [],
|
||||
current: async () => ({ id: "project", directory: "/project" }),
|
||||
},
|
||||
question: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||
reference: { list: async () => ({ location: {}, data: [] }) },
|
||||
vcs: { get: async () => ({ location: {}, data: {} }) },
|
||||
} as unknown as ServerApi
|
||||
|
||||
function directoryState() {
|
||||
return createStore<State>({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
command: [],
|
||||
reference: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
provider_ready: true,
|
||||
provider,
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
lsp_ready: true,
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
}
|
||||
|
||||
describe("bootstrapDirectory", () => {
|
||||
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
|
||||
const mcpReads: string[] = []
|
||||
const [store, setStore] = directoryState()
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
scope: ServerScope.local,
|
||||
mcp: true,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: {
|
||||
list: async () => {
|
||||
mcpReads.push("command")
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: {
|
||||
status: async () => {
|
||||
mcpReads.push("status")
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
resource: {
|
||||
list: async () => {
|
||||
mcpReads.push("resource")
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient,
|
||||
api,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
loadSessions() {},
|
||||
translate: (key) => key,
|
||||
queryClient: new QueryClient(),
|
||||
protocol: Promise.resolve("v1"),
|
||||
})
|
||||
|
||||
expect(store.status).toBe("partial")
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
expect(store.status).toBe("complete")
|
||||
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const client = {} as Parameters<typeof loadPathQuery>[2]
|
||||
const api = {} as CatalogApi
|
||||
const location = {} as ServerApi["location"]
|
||||
const remote = "https://debian.example" as typeof ServerScope.local
|
||||
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
||||
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
|
||||
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
||||
})
|
||||
|
||||
@@ -180,6 +57,21 @@ describe("query keys", () => {
|
||||
expect(result.connected).toEqual(["openai"])
|
||||
})
|
||||
|
||||
test("loads current location metadata", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
get: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { directory: "/repo/subpath", project: { id: "project", directory: "/repo" } }
|
||||
},
|
||||
} as ServerApi["location"]
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadPathQuery(ServerScope.local, "/repo/subpath", api))
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo/subpath" } }])
|
||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||
})
|
||||
|
||||
test("loads agents from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import type {
|
||||
Config,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
} from "@/types"
|
||||
import type {
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
@@ -16,12 +11,18 @@ import type {
|
||||
CommandInfo,
|
||||
CommandListInput,
|
||||
CommandListOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
PermissionRequest,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectListOutput,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
ReferenceInfo,
|
||||
QuestionRequest,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
@@ -33,7 +34,6 @@ import type { ServerSession } from "../server-session"
|
||||
import {
|
||||
cmp,
|
||||
normalizeAgentList,
|
||||
normalizePermissionRequest,
|
||||
normalizeProjectInfo,
|
||||
normalizeProviderList,
|
||||
} from "./utils"
|
||||
@@ -42,8 +42,6 @@ import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import type { ServerProtocol } from "@/utils/server-protocol"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
type GlobalStore = {
|
||||
@@ -105,16 +103,18 @@ function showErrors(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "config"],
|
||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||
// TODO: Restore config loading when the V2 client exposes a config API.
|
||||
queryFn: async (): Promise<Config> => ({}),
|
||||
})
|
||||
|
||||
type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
}
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
type PermissionApi = ServerApi["permission"]
|
||||
@@ -138,9 +138,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
})
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
serverSDK: OpencodeClient
|
||||
serverAPI: CatalogApi & { readonly project: ProjectApi }
|
||||
protocol?: Promise<ServerProtocol>
|
||||
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
|
||||
scope: ServerScope
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
@@ -149,12 +147,12 @@ export async function bootstrapGlobal(input: {
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI),
|
||||
),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||
@@ -183,7 +181,7 @@ function projectID(directory: string, projects: Project[]) {
|
||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||
}
|
||||
|
||||
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
|
||||
function mergeSession(setStore: SetStoreFunction<State>, session: SessionInfo) {
|
||||
setStore("session", (list) => {
|
||||
const next = list.slice()
|
||||
const idx = next.findIndex((item) => item.id >= session.id)
|
||||
@@ -208,9 +206,7 @@ function warmSessions(input: {
|
||||
if (ids.length === 0) return Promise.resolve()
|
||||
return Promise.all(
|
||||
ids.map((sessionID) =>
|
||||
retry(() => input.api.get({ sessionID })).then((session) =>
|
||||
mergeSession(input.setStore, normalizeSessionInfo(session)),
|
||||
),
|
||||
retry(() => input.api.get({ sessionID })).then((session) => mergeSession(input.setStore, session)),
|
||||
),
|
||||
).then(() => undefined)
|
||||
}
|
||||
@@ -219,17 +215,11 @@ export const loadProvidersQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string | null,
|
||||
sdk: CatalogApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "providers"],
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) {
|
||||
const result = await legacy.provider.list()
|
||||
return normalizeProviderList(result.data!)
|
||||
}
|
||||
const location = directory ? { location: { directory } } : undefined
|
||||
const [providers, models, defaultModel] = await Promise.all([
|
||||
sdk.provider.list(location),
|
||||
@@ -256,71 +246,45 @@ export const loadAgentsQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
sdk: AgentListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "agents"],
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
|
||||
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
|
||||
}),
|
||||
retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
||||
})
|
||||
|
||||
export const loadCommands = (
|
||||
directory: string,
|
||||
api: CommandListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
): Promise<CommandInfo[]> =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) {
|
||||
return ((await legacy.command.list()).data ?? []).map((command) => {
|
||||
const [providerID, id] = command.model?.split("/") ?? []
|
||||
return {
|
||||
name: command.name,
|
||||
template: command.template,
|
||||
description: command.description,
|
||||
agent: command.agent,
|
||||
model: providerID && id ? { providerID, id } : undefined,
|
||||
subtask: command.subtask,
|
||||
// source: command.source === "skill" ? undefined : command.source,
|
||||
}
|
||||
})
|
||||
}
|
||||
return api.list({ location: { directory } }).then((result) => result.data)
|
||||
})
|
||||
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
||||
|
||||
export const loadPathQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string | null,
|
||||
sdk: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
api: LocationApi,
|
||||
) =>
|
||||
queryOptions<Path>({
|
||||
queryKey: [scope, directory, "path"],
|
||||
queryFn: async () => {
|
||||
if ((await protocol) !== "v1")
|
||||
return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" }
|
||||
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
|
||||
},
|
||||
queryFn: () =>
|
||||
api.get(directory ? { location: { directory } } : undefined).then((location) => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
})),
|
||||
})
|
||||
|
||||
export const loadReferencesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: ReferenceListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions<ReferenceInfo[]>({
|
||||
queryKey: [scope, directory, "references"] as const,
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
|
||||
return api.list({ location: { directory } }).then((result) => result.data)
|
||||
}).catch(() => []),
|
||||
retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
@@ -328,7 +292,6 @@ export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
scope: ServerScope
|
||||
mcp: boolean
|
||||
sdk: OpencodeClient
|
||||
api: CatalogApi & {
|
||||
readonly agent: AgentListApi
|
||||
readonly command: CommandListApi
|
||||
@@ -339,6 +302,7 @@ export async function bootstrapDirectory(input: {
|
||||
readonly reference: ReferenceListApi
|
||||
readonly session: SessionApi
|
||||
readonly vcs: VcsApi
|
||||
readonly location: LocationApi
|
||||
}
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
@@ -353,7 +317,6 @@ export async function bootstrapDirectory(input: {
|
||||
}
|
||||
queryClient: QueryClient
|
||||
session?: ServerSession
|
||||
protocol?: Promise<ServerProtocol>
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
@@ -373,37 +336,8 @@ export async function bootstrapDirectory(input: {
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
if ((await input.protocol) !== "v1") return
|
||||
const x = await input.sdk.session.status()
|
||||
if (!input.session) {
|
||||
input.setStore("session_status", x.data!)
|
||||
return
|
||||
}
|
||||
const statuses = x.data ?? {}
|
||||
input.session.set(
|
||||
"session_status",
|
||||
produce((draft) => {
|
||||
for (const sessionID of Object.keys(draft)) {
|
||||
if (statuses[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
})(),
|
||||
),
|
||||
!seededProject &&
|
||||
(() =>
|
||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||
@@ -412,37 +346,26 @@ export async function bootstrapDirectory(input: {
|
||||
!seededPath &&
|
||||
(() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol))
|
||||
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
() =>
|
||||
retry(async () => {
|
||||
if ((await input.protocol) !== "v1") return
|
||||
return input.sdk.vcs.get().then((result) => {
|
||||
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
})
|
||||
}),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
|
||||
loadCommands(input.directory, input.api.command).then((commands) =>
|
||||
input.setStore("command", commands),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
|
||||
loadReferencesQuery(input.scope, input.directory, input.api.reference),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
|
||||
return input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data.map(normalizePermissionRequest))
|
||||
})().then((permissions) => {
|
||||
input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
.then((permissions) => {
|
||||
const ids = permissions.map((permission) => permission.sessionID)
|
||||
const grouped = groupBySession(
|
||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||
@@ -455,7 +378,7 @@ export async function bootstrapDirectory(input: {
|
||||
const current = input.session?.data.permission ?? input.store.permission
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory !== input.directory) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("permission", sessionID, [])
|
||||
if (!input.session) input.setStore("permission", sessionID, [])
|
||||
}
|
||||
@@ -473,12 +396,10 @@ export async function bootstrapDirectory(input: {
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
||||
return input.api.question.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
})().then((questions) => {
|
||||
input.api.question.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
.then((questions) => {
|
||||
const ids = questions.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(
|
||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||
@@ -491,7 +412,7 @@ export async function bootstrapDirectory(input: {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory !== input.directory) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("question", sessionID, [])
|
||||
if (!input.session) input.setStore("question", sessionID, [])
|
||||
}
|
||||
@@ -511,16 +432,16 @@ export async function bootstrapDirectory(input: {
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||
loadMcpQuery(input.scope, input.directory, input.api.mcp),
|
||||
)),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { VcsInfo } from "@/types"
|
||||
import {
|
||||
DIR_IDLE_TTL_MS,
|
||||
MAX_DIR_STORES,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, Project } from "@/types"
|
||||
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||
@@ -13,7 +14,7 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
|
||||
updated: 1,
|
||||
archived: input.archived,
|
||||
},
|
||||
}) as Session
|
||||
}) as SessionInfo
|
||||
|
||||
const userMessage = (id: string, sessionID: string) =>
|
||||
({
|
||||
@@ -38,10 +39,10 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
permission: title,
|
||||
patterns: ["*"],
|
||||
action: title,
|
||||
resources: ["*"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
save: [],
|
||||
}) as PermissionRequest
|
||||
|
||||
const questionRequest = (id: string, sessionID: string, title = id) =>
|
||||
@@ -512,7 +513,7 @@ describe("applyDirectoryEvent", () => {
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated")
|
||||
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.action).toBe("updated")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
|
||||
|
||||
@@ -3,14 +3,10 @@ import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
} from "@/types"
|
||||
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
@@ -79,7 +75,7 @@ function cleanupSessionCaches(
|
||||
export function cleanupDroppedSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
next: Session[],
|
||||
next: SessionInfo[],
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
const keep = new Set(next.map((item) => item.id))
|
||||
@@ -128,7 +124,7 @@ export function applyDirectoryEvent(input: {
|
||||
return
|
||||
}
|
||||
case "session.created": {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const info = (event.properties as { info: SessionInfo }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
input.setStore("session", result.index, reconcile(info))
|
||||
@@ -143,7 +139,7 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.updated": {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const info = (event.properties as { info: SessionInfo }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (info.time.archived) {
|
||||
if (!result.found) break
|
||||
@@ -171,7 +167,7 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
const properties = event.properties as { sessionID?: string; info?: Session }
|
||||
const properties = event.properties as { sessionID?: string; info?: SessionInfo }
|
||||
const sessionID = properties.info?.id ?? properties.sessionID
|
||||
if (!sessionID) break
|
||||
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
|
||||
@@ -201,7 +197,7 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.usage.updated": {
|
||||
const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
|
||||
const properties = event.properties as Pick<SessionInfo, "cost" | "tokens"> & { sessionID: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
@@ -237,9 +233,8 @@ export function applyDirectoryEvent(input: {
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
projectID: properties.projectID ?? session.projectID,
|
||||
workspaceID: properties.location.workspaceID,
|
||||
directory: properties.location.directory,
|
||||
path: properties.subpath,
|
||||
location: properties.location,
|
||||
subpath: properties.subpath,
|
||||
time: { ...session.time, updated: Date.now() },
|
||||
}))
|
||||
break
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
applyHomeSessionEvent,
|
||||
appendHomeSessionEvent,
|
||||
@@ -33,7 +33,7 @@ describe("Home V2 session index", () => {
|
||||
const calls: unknown[] = []
|
||||
const result = await loadHomeSessionIndex(async (input) => {
|
||||
calls.push(input)
|
||||
return { data: { data: [session({ id: "root" })], cursor: {} } }
|
||||
return { data: [session({ id: "root" })], cursor: {} }
|
||||
})
|
||||
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
@@ -48,15 +48,11 @@ describe("Home V2 session index", () => {
|
||||
calls.push({ input, signal: options.signal })
|
||||
if (!("cursor" in input)) {
|
||||
return {
|
||||
data: {
|
||||
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
||||
session({ id: `page-1-${index}` }),
|
||||
),
|
||||
cursor: { next: "next-page" },
|
||||
},
|
||||
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session({ id: `page-1-${index}` })),
|
||||
cursor: { next: "next-page" },
|
||||
}
|
||||
}
|
||||
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
|
||||
return { data: [session({ id: "page-2" })], cursor: {} }
|
||||
},
|
||||
0,
|
||||
controller.signal,
|
||||
@@ -76,7 +72,7 @@ describe("Home V2 session index", () => {
|
||||
const activeNull = {
|
||||
...session({ id: "active-null", updated: 20 }),
|
||||
time: { created: 1, updated: 20, archived: null },
|
||||
} as unknown as SessionV2Info
|
||||
} as unknown as SessionInfo
|
||||
const result = parseHomeSessionIndex([
|
||||
session({ id: "root", updated: 30 }),
|
||||
activeNull,
|
||||
@@ -85,11 +81,9 @@ describe("Home V2 session index", () => {
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "root",
|
||||
slug: "root",
|
||||
version: "",
|
||||
directory: "/project",
|
||||
expect.objectContaining({
|
||||
id: "root",
|
||||
location: { directory: "/project" },
|
||||
projectID: "project",
|
||||
title: "root",
|
||||
time: { created: 1, updated: 30 },
|
||||
@@ -105,17 +99,17 @@ describe("Home V2 session index", () => {
|
||||
const now = 10 * 60 * 60 * 1000
|
||||
const sessions = Array.from({ length: 80 }, (_, index) => ({
|
||||
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
|
||||
directory: index % 2 === 0 ? "/one" : "/two",
|
||||
location: { directory: index % 2 === 0 ? "/one" : "/two" },
|
||||
}))
|
||||
|
||||
const retained = retainHomeSessions(sessions, 10, now)
|
||||
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
|
||||
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
|
||||
expect(retained.filter((item) => item.location.directory === "/one")).toHaveLength(10)
|
||||
expect(retained.filter((item) => item.location.directory === "/two")).toHaveLength(10)
|
||||
})
|
||||
|
||||
test("replays session events over the loaded index", () => {
|
||||
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
|
||||
const created = { ...initial[0], id: "new", title: "new", time: { created: 2, updated: 2 } }
|
||||
|
||||
const afterCreate = applyHomeSessionEvent(initial, {
|
||||
type: "session.created",
|
||||
@@ -145,10 +139,8 @@ describe("Home V2 session index", () => {
|
||||
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
|
||||
})
|
||||
|
||||
test("refetches after reconnect, disposal, and session moves", () => {
|
||||
test("refetches after reconnect", () => {
|
||||
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
|
||||
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
|
||||
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
|
||||
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Event } from "@/types"
|
||||
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
@@ -7,31 +8,29 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
||||
|
||||
export type HomeSessionEvent = {
|
||||
type: "session.created" | "session.updated" | "session.deleted"
|
||||
properties: { sessionID: string; info: Session }
|
||||
properties: { sessionID: string; info?: SessionInfo }
|
||||
}
|
||||
export type HomeSessionEvents = {
|
||||
sequence: number
|
||||
entries: Array<{ sequence: number; event: HomeSessionEvent }>
|
||||
}
|
||||
export type HomeSessionIndex = {
|
||||
sessions: Session[]
|
||||
sessions: SessionInfo[]
|
||||
eventSequence: number
|
||||
}
|
||||
|
||||
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
||||
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||
|
||||
type HomeSessionPage = { data?: V2SessionListResponse }
|
||||
|
||||
export async function loadHomeSessionIndex(
|
||||
list: (
|
||||
input: { limit: number; order: "desc"; cursor?: string },
|
||||
options: { signal?: AbortSignal },
|
||||
) => Promise<HomeSessionPage>,
|
||||
) => Promise<SessionsResponse>,
|
||||
eventSequence = 0,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const data: SessionV2Info[] = []
|
||||
const data: SessionInfo[] = []
|
||||
let cursor: string | undefined
|
||||
|
||||
for (;;) {
|
||||
@@ -43,7 +42,7 @@ export async function loadHomeSessionIndex(
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
const page = response.data!
|
||||
const page = response
|
||||
data.push(...page.data)
|
||||
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
||||
@@ -75,10 +74,7 @@ export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, ev
|
||||
|
||||
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
|
||||
if (event === "server.connected") return { connected: true, refetch: connected }
|
||||
return {
|
||||
connected,
|
||||
refetch: event === "global.disposed" || event === "session.next.moved",
|
||||
}
|
||||
return { connected, refetch: false }
|
||||
}
|
||||
|
||||
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
||||
@@ -129,23 +125,28 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
|
||||
// current V2 API orders by creation time and cannot filter roots, archives, or
|
||||
// multiple directories. A bounded page could omit an old session updated today.
|
||||
// Once released, use client.v2.project.list() and client.v2.session.list({
|
||||
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
|
||||
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
|
||||
// parentID: null, order: "desc" }) and replace this full-table scan.
|
||||
export function parseHomeSessionIndex(sessions: SessionInfo[]): SessionInfo[] {
|
||||
return sessions.flatMap((item) => {
|
||||
if (item.parentID || typeof item.time.archived === "number") return []
|
||||
return [toLegacySummary(item)]
|
||||
return [item]
|
||||
})
|
||||
}
|
||||
|
||||
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
|
||||
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
|
||||
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) {
|
||||
const grouped = Map.groupBy(sessions, (session) => pathKey(session.location.directory))
|
||||
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
|
||||
}
|
||||
|
||||
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
||||
export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessionEvent) {
|
||||
const info = event.properties.info
|
||||
const index = sessions.findIndex((session) => session.id === info.id)
|
||||
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
|
||||
const index = sessions.findIndex((session) => session.id === (info?.id ?? event.properties.sessionID))
|
||||
if (event.type === "session.deleted") {
|
||||
if (index === -1) return sessions
|
||||
return sessions.toSpliced(index, 1)
|
||||
}
|
||||
if (!info) return sessions
|
||||
if (info.parentID || typeof info.time.archived === "number") {
|
||||
if (index === -1) return sessions
|
||||
return sessions.toSpliced(index, 1)
|
||||
}
|
||||
@@ -153,22 +154,3 @@ export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEve
|
||||
if (index === -1) return [...sessions, info]
|
||||
return sessions.with(index, info)
|
||||
}
|
||||
|
||||
function toLegacySummary(session: SessionV2Info): Session {
|
||||
return {
|
||||
id: session.id,
|
||||
slug: session.id,
|
||||
projectID: session.projectID,
|
||||
workspaceID: session.location.workspaceID,
|
||||
directory: session.location.directory,
|
||||
path: session.subpath,
|
||||
parentID: session.parentID,
|
||||
cost: session.cost,
|
||||
tokens: session.tokens,
|
||||
title: session.title,
|
||||
agent: session.agent,
|
||||
model: session.model,
|
||||
version: "",
|
||||
time: session.time,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, Todo } from "@/types"
|
||||
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user