Compare commits

..

6 Commits

Author SHA1 Message Date
Brendan Allan d06546b7ea fix(app): sync session selection before prompts 2026-08-05 14:42:08 +08:00
Brendan Allan 2893d3f55c test(app): finalize v2-only e2e expectations 2026-08-05 14:41:59 +08:00
Brendan Allan aee34c9383 refactor(app): remove v1 compatibility 2026-08-05 14:41:59 +08:00
Brendan Allan 5d351406a1 fix(app): translate legacy question tool IDs 2026-08-05 14:41:35 +08:00
Brendan Allan 86d90eb4ef test(app): retain gated legacy projection coverage 2026-08-05 14:35:05 +08:00
Brendan Allan 31d1fae1e7 refactor(app): gate unsupported v2 capabilities 2026-08-05 14:35:05 +08:00
545 changed files with 32908 additions and 22779 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

+14 -2
View File
@@ -111,7 +111,6 @@
"@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:",
@@ -395,7 +394,6 @@
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
@@ -490,6 +488,18 @@
"@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",
@@ -2056,6 +2066,8 @@
"@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"],
+26 -206
View File
@@ -5,27 +5,8 @@
- 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.
- 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.
- Add explicit data operations to that migration where generated DDL is insufficient.
- Test the migration against a populated database at the exact `dev` schema.
## Preserve
@@ -34,35 +15,20 @@ 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.
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`.
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.
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
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.
Truncate these pre-launch V2 tables before applying schema changes:
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.
- `event`
- `event_sequence`
- `session_message`
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.
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.
## Message Backfill
@@ -70,23 +36,9 @@ 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`.
@@ -94,122 +46,15 @@ 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.
@@ -219,13 +64,9 @@ 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.
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.
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.
## Drop
@@ -233,7 +74,6 @@ 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`.
@@ -263,36 +103,16 @@ 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.
## Execution
## Verification
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.
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
credentials, permissions, shares, and workspaces. After migration, it should verify:
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.
- 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.
+5 -37
View File
@@ -25,20 +25,8 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
const ADAPTER = "gemini"
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
// retaining complete model responses, but OpenCode reconstructs durable history and may encounter an unsigned call
// from an older or external session. Model IDs are open-ended, so unknown Gemini aliases inherit current behavior.
const requiresThoughtSignatureFallback = (modelID: string) => {
if (!/(^|\/)gemini-/i.test(modelID)) return false
if (/(^|\/)gemini-(?:1|2)(?:[.-]|$)/i.test(modelID)) return false
if (/(^|\/)gemini-pro(?:-vision)?$/i.test(modelID)) return false
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
}
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
@@ -157,9 +145,6 @@ const GeminiGenerationConfig = Schema.Struct({
temperature: Schema.optional(Schema.Number),
topP: Schema.optional(Schema.Number),
topK: Schema.optional(Schema.Number),
frequencyPenalty: Schema.optional(Schema.Number),
presencePenalty: Schema.optional(Schema.Number),
seed: Schema.optional(Schema.Number),
stopSequences: optionalArray(Schema.String),
thinkingConfig: Schema.optional(GeminiThinkingConfig),
})
@@ -217,13 +202,11 @@ interface ParserState {
// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
//
// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
// drop empty root parameter schemas while preserving nested empty objects,
// expand type arrays into `anyOf`, derive `nullable: true` from null members,
// coerce `const` to `[const]` enum, recurse properties/items, and propagate
// drop empty objects, derive `nullable: true` from `type: [..., "null"]`,
// coerce `const` to `[const]` enum, recurse properties/items, propagate
// only an allowlisted set of keys (description, required, format, type,
// nullable, enum, properties, items, allOf, anyOf, oneOf, minLength).
// Anything outside the allowlist (e.g. `additionalProperties`, `$ref`) is
// silently dropped.
// properties, items, allOf, anyOf, oneOf, minLength). Anything outside the
// allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped.
//
// Sanitize runs first, then project. The implementation lives in
// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
@@ -299,8 +282,6 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (message.role === "assistant") {
const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
// Parallel Gemini 3 calls may carry one signature on the first call; unsigned sibling calls are valid.
let hasSignedToolCall = false
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
@@ -313,17 +294,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
continue
}
if (part.type === "tool-call") {
const lowered = lowerToolCall(part)
const signature = lowered.thoughtSignature
parts.push({
...lowered,
thoughtSignature:
signature ??
(requiresThoughtSignatureFallback(request.model.id) && !hasSignedToolCall
? SKIP_THOUGHT_SIGNATURE_VALIDATOR
: undefined),
})
if (signature !== undefined) hasSignedToolCall = true
parts.push(lowerToolCall(part))
continue
}
}
@@ -417,9 +388,6 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
temperature: generation?.temperature,
topP: generation?.topP,
topK: generation?.topK,
frequencyPenalty: generation?.frequencyPenalty,
presencePenalty: generation?.presencePenalty,
seed: generation?.seed,
stopSequences: generation?.stop,
thinkingConfig: options.thinkingConfig,
}
+56 -117
View File
@@ -90,15 +90,10 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
])
export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), id: Schema.optionalKey(Schema.String), content: Schema.String }),
Schema.Struct({
role: Schema.tag("user"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(OpenResponsesInputContent),
}),
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
role: Schema.tag("assistant"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
}),
@@ -106,23 +101,19 @@ export const InputItem = Schema.Union([
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
name: Schema.String,
arguments: Schema.String,
}),
Schema.Struct({
type: Schema.tag("function_call_output"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
output: OpenResponsesFunctionCallOutput,
}),
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type ProviderInputItem = Readonly<Record<string, unknown>> & { readonly type: string; readonly id?: string }
type LoweredInputItem =
| OpenResponsesInputItem
| ProviderInputItem
| {
readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
@@ -137,7 +128,7 @@ type OpenResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> & { id?: string }
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
export const Tool = Schema.Struct({
type: Schema.tag("function"),
@@ -263,11 +254,6 @@ export interface Extension {
readonly request: LLMRequest
}) => MediaInput | undefined
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
readonly lowerProviderItem?: (
part: ToolResultPart,
providerMetadataKey: string,
store: boolean | undefined,
) => ProviderInputItem | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -324,47 +310,35 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
const metadataItemID = (
part: { readonly itemId?: string; readonly providerMetadata?: ProviderMetadata },
providerMetadataKey: string,
) => {
if (part.itemId) return part.itemId
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const itemId = metadataItemID(part, providerMetadataKey)
return {
type: "function_call",
...(itemId === undefined ? {} : { id: itemId }),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
type: "function_call",
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
})
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const itemId = metadataItemID(part, providerMetadataKey)
if (!itemId) return undefined
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
const encryptedContent =
ProviderShared.isRecord(metadata) &&
(typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null)
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: itemId,
id: metadata.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) =>
metadataItemID(part, providerMetadataKey)
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
@@ -423,18 +397,17 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const options = OpenResponsesOptions.resolve(request)
const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system]
const store = options.store
const store = OpenResponsesOptions.resolve(request).store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) {
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user" && Array.isArray(previous.content))
if (previous && "role" in previous && previous.role === "user")
input[input.length - 1] = {
role: "user",
content: [...previous.content, { type: "input_text", text: part.text }],
@@ -454,24 +427,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
const hostedToolItems = new Set<string>()
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<
Array<{ phase: MessagePhase | null | undefined; itemId: string | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const itemId = metadataItemID(part, providerMetadataKey)
const group = groups.at(-1)
if (group && group.phase === phase && group.itemId === itemId) group.parts.push(part)
else groups.push({ phase, itemId, parts: [part] })
return groups
}, [])
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] })
return groups
},
[],
)
input.push(
...groups.map((group) => ({
role: "assistant" as const,
...(group.itemId === undefined ? {} : { id: group.itemId }),
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
@@ -487,6 +460,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
@@ -496,7 +474,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
const replay = {
type: reasoning.type,
id: reasoning.id,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
@@ -507,24 +484,22 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey))
input.push(lowerToolCall(part))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const itemID = hostedToolItemID(part, providerMetadataKey)
const providerItem = extension.lowerProviderItem?.(part, providerMetadataKey, store)
if (providerItem && itemID && !hostedToolItems.has(itemID)) input.push(providerItem)
if (!providerItem && store !== false && itemID && !hostedToolItems.has(itemID))
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (!providerItem && store === false && part.result.type === "content") {
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
})
}
if (itemID) hostedToolItems.add(itemID)
if (itemID) hostedToolReferences.add(itemID)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -666,9 +641,9 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata, id)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta, metadata, id) }, events]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
@@ -677,13 +652,7 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
}
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.textEnd(state.lifecycle, events, id, providerMetadata(state, { itemId: id }), id),
},
events,
]
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
@@ -694,14 +663,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(
state.lifecycle,
events,
id,
event.delta,
providerMetadata(state, { itemId: itemID }),
itemID,
),
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
},
events,
]
@@ -743,13 +705,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${item.id}:0`,
reasoningMetadata(state, item),
item.id,
),
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
@@ -768,7 +724,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle,
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id ?? item.id,
itemId: item.id,
name: item.name ?? "",
input: item.arguments ?? "",
providerMetadata: metadata,
@@ -776,12 +731,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
},
[
...events,
LLMEvent.toolInputStart({
id: item.call_id ?? item.id,
itemId: item.id,
name: item.name ?? "",
providerMetadata: metadata,
}),
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }),
],
]
}
@@ -800,7 +750,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events,
`${event.item_id}:0`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
event.item_id,
),
reasoningItems: {
...state.reasoningItems,
@@ -821,7 +770,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events,
`${event.item_id}:${entry[0]}`,
providerMetadata(state, { itemId: event.item_id }),
event.item_id,
),
state.lifecycle,
)
@@ -833,7 +781,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
event.item_id,
),
reasoningItems: {
...state.reasoningItems,
@@ -869,7 +816,6 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }),
event.item_id,
)
: state.lifecycle,
reasoningItems: {
@@ -924,8 +870,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle,
events,
item.id,
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }),
),
messageItems,
messagePhases,
@@ -936,15 +881,9 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
itemId: item.id,
name: item.name,
providerMetadata: metadata,
})
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
@@ -974,7 +913,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata, item.id),
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
state.lifecycle,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
@@ -982,12 +921,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, itemId: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, itemId: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, item.id) },
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
events,
] satisfies StepResult
}
+33 -97
View File
@@ -67,12 +67,6 @@ 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"),
@@ -151,33 +145,22 @@ 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.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 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 OpenAIChatToolCallDeltaFunction = Schema.Struct({
name: optionalNull(Schema.String),
@@ -185,7 +168,7 @@ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
})
const OpenAIChatToolCallDelta = Schema.Struct({
index: optionalNull(Schema.Number),
index: Schema.Number,
id: optionalNull(Schema.String),
function: optionalNull(OpenAIChatToolCallDeltaFunction),
})
@@ -239,8 +222,6 @@ export interface ParserState {
readonly reasoningDetails: Array<unknown>
readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean
readonly latestToolIndex?: number
readonly nextToolIndex: number
}
// =============================================================================
@@ -578,34 +559,22 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
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))
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))
return new Usage({
inputTokens: input,
outputTokens: output,
inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
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,
@@ -688,34 +657,17 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage
const choice = event.choices?.[0]
const rawFinishReason = choice?.finish_reason
const finishReason =
rawFinishReason !== undefined && rawFinishReason !== null
? { normalized: mapFinishReason(rawFinishReason), raw: choice?.native_finish_reason ?? rawFinishReason }
: state.finishReason
const finishReason = choice?.finish_reason
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
: 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)
@@ -742,37 +694,24 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
// 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]
for (const tool of toolDeltas) {
const current = tools[tool.index]
const pending = pendingTools[tool.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,
[index]: { id: id || undefined, name: name || undefined, input: text },
}
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
continue
}
if (pending) {
pendingTools = { ...pendingTools }
delete pendingTools[index]
delete pendingTools[tool.index]
}
const result = ToolStream.appendOrStart(
ADAPTER,
tools,
index,
tool.index,
{ id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name",
)
@@ -804,8 +743,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
reasoningDetails: state.reasoningDetails,
reasoningDetailsObserved,
reasoningEmitted,
latestToolIndex,
nextToolIndex,
},
events,
] as const
@@ -862,7 +799,6 @@ export const protocol = Protocol.make({
reasoningDetails: [],
reasoningDetailsObserved: false,
reasoningEmitted: false,
nextToolIndex: 0,
}),
step,
onHalt: finishEvents,
+3 -32
View File
@@ -38,14 +38,10 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
role: Schema.tag("assistant"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}),
OpenResponses.InputItem,
Schema.StructWithRest(Schema.Struct({ type: Schema.String, id: Schema.optionalKey(Schema.String) }), [
Schema.Record(Schema.String, Schema.Unknown),
]),
])
const OpenAIResponsesCoreFields = {
@@ -84,25 +80,6 @@ const extension = {
mime_type: media.mime,
}
},
lowerProviderItem: (part, providerMetadataKey, store) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || !ProviderShared.isRecord(metadata.item)) return undefined
if (typeof metadata.item.type !== "string") return undefined
const id = typeof metadata.item.id === "string" ? metadata.item.id : undefined
// The public API requires stored state to replay image-generation items. In
// stateless mode, lower the generated file through the existing user-image fallback.
if (metadata.item.type === "image_generation_call" && store === false) return undefined
if (metadata.item.type === "image_generation_call")
return {
type: metadata.item.type,
...(id === undefined ? {} : { id }),
...(typeof metadata.item.status === "string" ? { status: metadata.item.status } : {}),
...(typeof metadata.item.revised_prompt === "string" ? { revised_prompt: metadata.item.revised_prompt } : {}),
...(typeof metadata.item.result === "string" ? { result: metadata.item.result } : {}),
}
const item: Record<string, unknown> & { type: string } = { ...metadata.item, type: metadata.item.type }
return item
},
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -218,29 +195,23 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
item: HostedToolItem,
) {
const tool = HOSTED_TOOLS[item.type]
const callMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const resultMetadata = OpenResponses.providerMetadata(
state,
item.type === "image_generation_call" ? { itemId: item.id } : { itemId: item.id, item },
)
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.toolCall({
id: item.id,
itemId: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata: callMetadata,
providerMetadata,
}),
LLMEvent.toolResult({
id: item.id,
itemId: item.id,
name: tool.name,
result: yield* hostedToolResult(item),
providerExecuted: true,
providerMetadata: resultMetadata,
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
@@ -61,57 +61,37 @@ const emptyObjectSchema = (schema: Record<string, unknown>) =>
(!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
!schema.additionalProperties
const projectNode = (schema: unknown, nested = false): Record<string, unknown> | undefined => {
const projectNode = (schema: unknown): Record<string, unknown> | undefined => {
if (!isRecord(schema)) return undefined
if (!nested && emptyObjectSchema(schema)) return undefined
const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined
const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : undefined
const hasNullAnyOf = anyOf?.some((item) => isRecord(item) && item.type === "null") ?? false
const anyOfTypes = hasNullAnyOf ? anyOf?.filter((item) => !isRecord(item) || item.type !== "null") : anyOf
const flattenedAnyOf = hasNullAnyOf && anyOfTypes?.length === 1 ? projectNode(anyOfTypes[0], true) : undefined
const result = Object.fromEntries(
if (emptyObjectSchema(schema)) return undefined
return Object.fromEntries(
[
["description", schema.description],
["required", schema.required],
["format", schema.format],
["type", types ? (types.length === 0 ? "null" : undefined) : schema.type],
[
"nullable",
(Array.isArray(schema.type) && schema.type.includes("null") && types && types.length > 0) || hasNullAnyOf
? true
: undefined,
],
["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type],
["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined],
["enum", schema.const !== undefined ? [schema.const] : schema.enum],
[
"properties",
isRecord(schema.properties)
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value, true)]))
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)]))
: undefined,
],
[
"items",
Array.isArray(schema.items)
? schema.items.map((item) => projectNode(item, true))
? schema.items.map(projectNode)
: schema.items === undefined
? undefined
: projectNode(schema.items, true),
: projectNode(schema.items),
],
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined],
[
"anyOf",
anyOfTypes
? hasNullAnyOf && anyOfTypes.length === 1
? undefined
: anyOfTypes.map((item) => projectNode(item, true))
: types && types.length > 0
? types.map((type) => ({ type }))
: undefined,
],
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined],
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined],
["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined],
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined],
["minLength", schema.minLength],
].filter((entry) => entry[1] !== undefined),
)
return flattenedAnyOf ? { ...result, ...flattenedAnyOf } : result
}
export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))
+12 -40
View File
@@ -1,10 +1,4 @@
import {
LLMEvent,
type FinishReasonDetails,
type ProviderMetadata,
type ResponseItemID,
type Usage,
} from "../../schema"
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
export interface State {
readonly stepStarted: boolean
@@ -20,29 +14,16 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true }
}
export const textStart = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
events.push(LLMEvent.textStart({ id, providerMetadata }))
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const textDelta = (
state: State,
events: LLMEvent[],
id: string,
text: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
const started = textStart(state, events, id, providerMetadata, itemId)
events.push(LLMEvent.textDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const started = textStart(state, events, id)
events.push(LLMEvent.textDelta({ id, text }))
return started
}
@@ -51,11 +32,10 @@ export const reasoningStart = (
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
}
@@ -65,10 +45,9 @@ export const reasoningDelta = (
id: string,
text: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
const started = reasoningStart(state, events, id, providerMetadata, itemId)
events.push(LLMEvent.reasoningDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
const started = reasoningStart(state, events, id, providerMetadata)
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
return started
}
@@ -77,26 +56,19 @@ export const reasoningEnd = (
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
const reasoning = new Set(stepped.reasoning)
reasoning.delete(id)
return { ...stepped, reasoning }
}
export const textEnd = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (!state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
events.push(LLMEvent.textEnd({ id, providerMetadata }))
const text = new Set(stepped.text)
text.delete(id)
return { ...stepped, text }
+2 -23
View File
@@ -1,12 +1,5 @@
import { Effect } from "effect"
import {
AIError,
LLMEvent,
type ProviderMetadata,
type ResponseItemID,
type ToolCall,
type ToolInputError,
} from "../../schema"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number
@@ -17,7 +10,6 @@ type StreamKey = string | number
* so far, not the parsed object.
*/
export interface PendingTool extends ToolAccumulator {
readonly itemId?: ResponseItemID
readonly providerExecuted?: boolean
readonly providerMetadata?: ProviderMetadata
}
@@ -60,7 +52,6 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
@@ -69,7 +60,6 @@ const inputStart = (tool: PendingTool) =>
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
text,
})
@@ -80,7 +70,6 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
Effect.map((input): ToolCall | ToolInputError =>
LLMEvent.toolCall({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
@@ -93,7 +82,6 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
: Effect.succeed(
LLMEvent.toolInputError({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
raw,
}),
@@ -105,15 +93,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
event.type === "tool-input-error"
? [event]
: [
LLMEvent.toolInputEnd({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
providerMetadata: tool.providerMetadata,
}),
event,
]
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
/** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>(
@@ -168,7 +148,6 @@ export const appendOrStart = <K extends StreamKey>(
id,
name,
input: `${current?.input ?? ""}${delta.text}`,
itemId: current?.itemId,
providerExecuted: current?.providerExecuted,
providerMetadata: current?.providerMetadata,
}
+23 -22
View File
@@ -5,7 +5,6 @@ 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")
@@ -20,7 +19,7 @@ export type LanguageModelOptions = AzureURL &
ProviderAuthOption<"optional"> & {
readonly apiVersion?: string
readonly queryParams?: Record<string, string>
readonly useDeploymentBasedUrls?: boolean
readonly useCompletionUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Config = LanguageModelOptions
@@ -30,22 +29,27 @@ 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`
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
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]
@@ -55,7 +59,7 @@ const defaults = (input: Config) => {
apiKey: _,
apiVersion: _apiVersion,
resourceName: _resourceName,
useDeploymentBasedUrls: _useDeploymentBasedUrls,
useCompletionUrls: _useCompletionUrls,
baseURL: _baseURL,
queryParams: _queryParams,
...rest
@@ -76,39 +80,37 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
route.with({
auth: auth(input),
endpoint: endpoint(input, modelID),
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,
},
},
})
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) =>
configuredRoute(responsesRoute, input, modelID)
configuredResponsesRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredRoute(chatRoute, input, modelID)
configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
model: responses,
model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
responses,
chat,
configure,
@@ -129,7 +131,6 @@ 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,7 +15,8 @@ export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const
export const id = ProviderID.make("google-vertex")
// models.dev uses this provider id even though the API contract is Anthropic Messages.
export const id = ProviderID.make("google-vertex-anthropic")
export type Config = RouteDefaultsInput &
GoogleVertexShared.OAuthOptions & {
+24 -72
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProviderMetadata, ResponseItemID, ToolCallID } from "./ids"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors"
@@ -84,7 +84,6 @@ export type StepStart = Schema.Schema.Type<typeof StepStart>
export const TextStart = Schema.Struct({
type: Schema.tag("text-start"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextStart" })
export type TextStart = Schema.Schema.Type<typeof TextStart>
@@ -93,7 +92,6 @@ export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"),
id: ContentBlockID,
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
@@ -101,7 +99,6 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
@@ -109,7 +106,6 @@ export type TextEnd = Schema.Schema.Type<typeof TextEnd>
export const ReasoningStart = Schema.Struct({
type: Schema.tag("reasoning-start"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
@@ -118,7 +114,6 @@ export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"),
id: ContentBlockID,
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
@@ -126,7 +121,6 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
@@ -135,7 +129,6 @@ export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
name: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
@@ -144,7 +137,6 @@ export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
export const ToolInputDelta = Schema.Struct({
type: Schema.tag("tool-input-delta"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
text: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
@@ -154,7 +146,6 @@ export const ToolInputEnd = Schema.Struct({
type: Schema.tag("tool-input-end"),
id: ToolCallID,
name: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
@@ -163,7 +154,6 @@ export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
export const ToolInputError = Schema.Struct({
type: Schema.tag("tool-input-error"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
raw: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputError" })
@@ -172,7 +162,6 @@ export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -183,7 +172,6 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
export const ToolResult = Schema.Struct({
type: Schema.tag("tool-result"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
result: ToolResultValue,
output: Schema.optional(ToolOutput),
@@ -195,7 +183,6 @@ export type ToolResult = Schema.Schema.Type<typeof ToolResult>
export const ToolError = Schema.Struct({
type: Schema.tag("tool-error"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
message: Schema.String,
error: Schema.optional(Schema.Defect()),
@@ -347,14 +334,12 @@ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
interface ContentAssembly {
readonly contentIndex: number
readonly text: string
readonly itemId?: ResponseItemID
readonly providerMetadata?: ProviderMetadata
}
interface ToolInputAssembly {
readonly name: string
readonly text: string
readonly itemId?: ResponseItemID
readonly providerMetadata?: ProviderMetadata
}
@@ -400,27 +385,11 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
}
}
const textContent = (
text: string,
itemId: ResponseItemID | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart => ({
type: "text",
text,
...(itemId === undefined ? {} : { itemId }),
...(providerMetadata === undefined ? {} : { providerMetadata }),
})
const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata }
const reasoningContent = (
text: string,
itemId: ResponseItemID | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart => ({
type: "reasoning",
text,
...(itemId === undefined ? {} : { itemId }),
...(providerMetadata === undefined ? {} : { providerMetadata }),
})
const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata }
const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({
...state,
@@ -435,32 +404,26 @@ const replaceContent = (state: ResponseState, index: number, part: ContentPart)
state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)),
)
const ensureText = (
state: ResponseState,
id: string,
itemId?: ResponseItemID,
providerMetadata?: ProviderMetadata,
): ResponseState => {
const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
if (state.textParts[id]) return state
return {
...appendContent(state, textContent("", itemId, providerMetadata)),
...appendContent(state, textContent("", providerMetadata)),
textParts: {
...state.textParts,
[id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
},
}
}
const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => {
const started = ensureText(state, event.id, event.itemId, event.providerMetadata)
const started = ensureText(state, event.id, event.providerMetadata)
const current = started.textParts[event.id]
if (!current) return started
const text = current.text + event.text
const itemId = event.itemId ?? current.itemId
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(started, current.contentIndex, textContent(text, itemId, providerMetadata)),
textParts: { ...started.textParts, [event.id]: { ...current, text, itemId, providerMetadata } },
...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)),
textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } },
}
}
@@ -468,39 +431,32 @@ const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
const current = state.textParts[event.id]
if (!current) return state
const providerMetadata = event.providerMetadata ?? current.providerMetadata
const itemId = event.itemId ?? current.itemId
return {
...replaceContent(state, current.contentIndex, textContent(current.text, itemId, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, itemId, providerMetadata } },
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
}
}
const ensureReasoning = (
state: ResponseState,
id: string,
itemId?: ResponseItemID,
providerMetadata?: ProviderMetadata,
): ResponseState => {
const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
if (state.reasoningParts[id]) return state
return {
...appendContent(state, reasoningContent("", itemId, providerMetadata)),
...appendContent(state, reasoningContent("", providerMetadata)),
reasoningParts: {
...state.reasoningParts,
[id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
},
}
}
const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => {
const started = ensureReasoning(state, event.id, event.itemId, event.providerMetadata)
const started = ensureReasoning(state, event.id, event.providerMetadata)
const current = started.reasoningParts[event.id]
if (!current) return started
const text = current.text + event.text
const itemId = event.itemId ?? current.itemId
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(started, current.contentIndex, reasoningContent(text, itemId, providerMetadata)),
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, itemId, providerMetadata } },
...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)),
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
}
}
@@ -508,10 +464,9 @@ const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): Response
const current = state.reasoningParts[event.id]
if (!current) return state
const providerMetadata = event.providerMetadata ?? current.providerMetadata
const itemId = event.itemId ?? current.itemId
return {
...replaceContent(state, current.contentIndex, reasoningContent(current.text, itemId, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, itemId, providerMetadata } },
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
}
}
@@ -519,7 +474,7 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
...state,
toolInputs: {
...state.toolInputs,
[event.id]: { name: event.name, text: "", itemId: event.itemId, providerMetadata: event.providerMetadata },
[event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata },
},
})
@@ -540,7 +495,6 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
[event.id]: {
...current,
name: event.name,
itemId: event.itemId ?? current.itemId,
providerMetadata: event.providerMetadata ?? current.providerMetadata,
},
},
@@ -550,7 +504,6 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
const toolCallContent = (event: ToolCall): ContentPart =>
ToolCallPart.make({
id: event.id,
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
name: event.name,
input: event.input,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -560,7 +513,6 @@ const toolCallContent = (event: ToolCall): ContentPart =>
const toolResultContent = (event: ToolResult): ContentPart =>
ToolResultPart.make({
id: event.id,
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
name: event.name,
result: event.result,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -576,13 +528,13 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
const next = appendEvent(state, event)
switch (event.type) {
case "text-start":
return ensureText(next, event.id, event.itemId, event.providerMetadata)
return ensureText(next, event.id, event.providerMetadata)
case "text-delta":
return reduceTextDelta(next, event)
case "text-end":
return reduceTextEnd(next, event)
case "reasoning-start":
return ensureReasoning(next, event.id, event.itemId, event.providerMetadata)
return ensureReasoning(next, event.id, event.providerMetadata)
case "reasoning-delta":
return reduceReasoningDelta(next, event)
case "reasoning-end":
-3
View File
@@ -21,9 +21,6 @@ export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
export const ResponseItemID = Schema.String
export type ResponseItemID = Schema.Schema.Type<typeof ResponseItemID>
export const ContentBlockID = Schema.String
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
+2 -7
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata, ResponseItemID } from "./ids"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record"
@@ -25,7 +25,6 @@ export const SystemPart = Object.assign(systemPartSchema, {
export const TextPart = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -122,7 +121,6 @@ export const ToolCallPart = Object.assign(
Schema.Struct({
type: Schema.Literal("tool-call"),
id: Schema.String,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -140,7 +138,6 @@ export const ToolResultPart = Object.assign(
Schema.Struct({
type: Schema.Literal("tool-result"),
id: Schema.String,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -157,7 +154,6 @@ export const ToolResultPart = Object.assign(
): ToolResultPart => ({
type: "tool-result",
id: input.id,
...(input.itemId === undefined ? {} : { itemId: input.itemId }),
name: input.name,
result: ToolResultValue.make(input.result, input.resultType),
providerExecuted: input.providerExecuted,
@@ -172,7 +168,6 @@ export type ToolResultPart = Schema.Schema.Type<typeof ToolResultPart>
export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"),
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
@@ -186,7 +181,7 @@ export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, Tool
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
export class Message extends Schema.Class<Message>("LLM.Message")({
id: Schema.optional(ResponseItemID),
id: Schema.optional(Schema.String),
role: MessageRole,
content: Schema.Array(ContentPart),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
-1
View File
@@ -178,7 +178,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
+2 -2
View File
@@ -79,7 +79,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
id: call.id,
name: call.name,
result: settlement.result,
...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
providerMetadata: call.providerMetadata,
}),
]
: [
@@ -88,7 +88,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
name: call.name,
result: settlement.result,
output: settlement.output,
...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
providerMetadata: call.providerMetadata,
}),
],
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5 -14
View File
@@ -8,6 +8,7 @@ import {
type ProviderMetadata,
type ToolCallPart,
ToolResultPart,
type ToolResultValue,
type Usage,
} from "../../src/schema"
import { type Tools, toDefinitions } from "../../src/tool"
@@ -60,10 +61,9 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
...dispatched.map(([call, dispatched]) =>
Message.tool({
id: call.id,
itemId: dispatched.events.find(LLMEvent.is.toolResult)?.itemId,
name: call.name,
result: dispatched.result,
providerMetadata: dispatched.events.find(LLMEvent.is.toolResult)?.providerMetadata,
providerMetadata: call.providerMetadata,
}),
),
],
@@ -89,15 +89,9 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
for (const event of events) {
if (event.type === "text-delta" || event.type === "reasoning-delta") {
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text, event.itemId)
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text)
} else if (event.type === "text-end" || event.type === "reasoning-end") {
appendText(
assistantContent,
event.type === "text-end" ? "text" : "reasoning",
"",
event.itemId,
event.providerMetadata,
)
appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata)
} else if (event.type === "tool-call") {
assistantContent.push(event)
if (!event.providerExecuted) toolCalls.push(event)
@@ -105,7 +99,6 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
assistantContent.push(
ToolResultPart.make({
id: event.id,
itemId: event.itemId,
name: event.name,
result: event.result,
providerExecuted: true,
@@ -125,7 +118,6 @@ const appendText = (
content: ContentPart[],
type: "text" | "reasoning",
text: string,
itemId?: string,
providerMetadata?: ProviderMetadata,
) => {
const last = content.at(-1)
@@ -133,12 +125,11 @@ const appendText = (
content[content.length - 1] = {
...last,
text: `${last.text}${text}`,
itemId: itemId ?? last.itemId,
providerMetadata: providerMetadata ?? last.providerMetadata,
}
return
}
content.push({ type, text, itemId, providerMetadata })
content.push({ type, text, providerMetadata })
}
const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {
+2 -2
View File
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
const updated = LanguageModel.update(base, {
route: responsesRoute,
defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini", requireFinishReason: false },
compatibility: { toolSchema: "gemini" },
})
const updatedInput = LanguageModel.input(updated)
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
expect(updatedInput.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake")
-21
View File
@@ -209,27 +209,6 @@ 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", {
-172
View File
@@ -16,13 +16,6 @@ const model = Gemini.route
})
.model({ id: "gemini-2.5-flash" })
const gemini3 = Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-3-flash-preview" })
const request = LLM.request({
id: "req_1",
model,
@@ -93,39 +86,6 @@ describe("Gemini route", () => {
}),
)
it.effect("forwards standard Gemini generation options", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
generation: {
maxTokens: 40,
temperature: 0.2,
topP: 0.8,
topK: 12,
frequencyPenalty: 0.3,
presencePenalty: 0.4,
seed: 42,
stop: ["done"],
},
}),
)
expect(prepared.body.generationConfig).toEqual({
maxOutputTokens: 40,
temperature: 0.2,
topP: 0.8,
topK: 12,
frequencyPenalty: 0.3,
presencePenalty: 0.4,
seed: 42,
stopSequences: ["done"],
thinkingConfig: undefined,
})
}),
)
it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -390,100 +350,6 @@ describe("Gemini route", () => {
}),
)
it.effect("preserves nested empty object tool schemas", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Use the tool.",
tools: [
{
name: "configure",
description: "Configure the operation",
inputSchema: {
type: "object",
required: ["options"],
properties: {
options: { type: "object", description: "Optional provider settings", properties: {} },
},
},
},
],
}),
)
expect(prepared.body.tools).toEqual([
{
functionDeclarations: [
{
name: "configure",
description: "Configure the operation",
parameters: {
type: "object",
required: ["options"],
properties: {
options: { type: "object", description: "Optional provider settings", properties: {} },
},
},
},
],
},
])
}),
)
it.effect("projects Gemini type arrays without narrowing their allowed values", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Use the tool.",
tools: [
{
name: "filter",
description: "Filter values",
inputSchema: {
type: "object",
properties: {
status: { type: ["number", "string"], description: "Status filter" },
maybe: { type: ["string", "null"] },
nothing: { type: ["null"] },
explicit: { anyOf: [{ type: "string" }, { type: "null" }] },
choice: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] },
},
},
},
],
}),
)
expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parameters).toEqual({
type: "object",
properties: {
status: {
description: "Status filter",
anyOf: [{ type: "number" }, { type: "string" }],
},
maybe: {
nullable: true,
anyOf: [{ type: "string" }],
},
nothing: {
type: "null",
},
explicit: {
type: "string",
nullable: true,
},
choice: {
anyOf: [{ type: "string" }, { type: "number" }],
nullable: true,
},
},
})
}),
)
it.effect("parses text, reasoning, and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -670,44 +536,6 @@ describe("Gemini route", () => {
}),
)
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "tool_0", name: "lookup", result: "done", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator",
},
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
])
}),
)
it.effect("emits streamed tool calls and maps finish reason", () =>
Effect.gen(function* () {
const body = sseEvents({
@@ -56,14 +56,13 @@ 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,
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
}).model("claude-sonnet-4-6"),
prompt: "Say hello.",
}),
).pipe(
@@ -98,7 +97,6 @@ describe("Google Vertex providers", () => {
),
)
expect(model.provider).toBe("google-vertex")
expect(response.text).toBe("Hello.")
}),
)
@@ -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, fixedResponse } from "../lib/http"
import { dynamicResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
const Json = Schema.fromJsonString(Schema.Unknown)
@@ -253,106 +253,4 @@ 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")
}),
)
})
@@ -45,7 +45,7 @@ describe("Open Responses-compatible route", () => {
},
},
})
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "example-model",
input: [
{ role: "system", content: "You are concise." },
@@ -53,8 +53,6 @@ describe("Open Responses-compatible route", () => {
],
stream: true,
})
expect(prepared.body.input[0]).not.toHaveProperty("id")
expect(prepared.body.input[1]).not.toHaveProperty("id")
}),
)
@@ -69,9 +69,6 @@ describe("OpenAI Responses route", () => {
stream: true,
max_output_tokens: 20,
temperature: 0,
tool_choice: undefined,
tools: undefined,
top_p: undefined,
})
}),
)
@@ -332,7 +329,7 @@ describe("OpenAI Responses route", () => {
yield* LLMClient.generate(
LLMRequest.update(request, {
model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/",
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"),
@@ -413,7 +410,7 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "gpt-4.1-mini",
input: [
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
@@ -428,65 +425,6 @@ describe("OpenAI Responses route", () => {
tools: undefined,
top_p: undefined,
})
const call = prepared.body.input.find((item) => "type" in item && item.type === "function_call")
const output = prepared.body.input.find((item) => "type" in item && item.type === "function_call_output")
expect(call?.id).toBeUndefined()
expect(output?.id).toBeUndefined()
}),
)
it.effect("does not generate response item ids for client-created history", () =>
Effect.sync(() => {
const canonical = LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Working." },
{ type: "reasoning", text: "Thinking." },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "done" }),
],
})
expect(canonical.messages[0]?.id).toBeUndefined()
expect(canonical.messages[1]?.id).toBeUndefined()
expect(canonical.messages[0]?.content.every((part) => part.type === "media" || part.itemId === undefined)).toBe(
true,
)
expect(canonical.messages[1]?.content[0]).not.toHaveProperty("itemId")
}),
)
it.effect("replays provider function call item ids without assigning output ids", () =>
Effect.gen(function* () {
const canonical = LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Calling.", itemId: "plain-text" },
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: {},
providerMetadata: { openai: { itemId: "plain-call" } },
}),
]),
Message.tool({ id: "call_1", itemId: "plain-output", name: "lookup", result: "done" }),
],
})
const prepared = yield* compileRequest(canonical)
expect(canonical.messages[0]?.content.map((part) => (part.type === "media" ? undefined : part.itemId))).toEqual([
"plain-text",
undefined,
])
expect(canonical.messages[1]?.content[0]).toMatchObject({ itemId: "plain-output" })
expect(prepared.body.input).toEqual([
{ role: "assistant", id: "plain-text", content: [{ type: "output_text", text: "Calling." }] },
{ type: "function_call", id: "plain-call", call_id: "call_1", name: "lookup", arguments: "{}" },
{ type: "function_call_output", call_id: "call_1", output: '"done"' },
])
}),
)
@@ -926,21 +864,9 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!")
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{
type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "Hello",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{
type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "!",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
{ type: "text-end", id: "msg_1" },
{
type: "step-finish",
@@ -997,20 +923,17 @@ describe("OpenAI Responses route", () => {
{
type: "text",
text: "Checking.",
itemId: "msg_commentary",
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
providerMetadata: { openai: { phase: "commentary" } },
},
{
type: "text",
text: "Finished.",
itemId: "msg_final",
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
providerMetadata: { openai: { phase: "final_answer" } },
},
{
type: "text",
text: "Unclassified.",
itemId: "msg_null",
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
providerMetadata: { openai: { phase: null } },
},
])
@@ -1018,19 +941,16 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
role: "assistant",
id: "msg_commentary",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
{
role: "assistant",
id: "msg_final",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
{
role: "assistant",
id: "msg_null",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
@@ -1123,24 +1043,12 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{
type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "First",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{ type: "text-end", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{
type: "text-delta",
id: "msg_2",
itemId: "msg_2",
text: "Second",
providerMetadata: { openai: { itemId: "msg_2" } },
},
{ type: "text-end", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2" },
])
}),
)
@@ -1160,15 +1068,9 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-delta",
id: "rs_1",
itemId: "rs_1",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" },
@@ -1177,8 +1079,8 @@ describe("OpenAI Responses route", () => {
])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "text", text: "Hello", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
])
}),
)
@@ -1209,7 +1111,6 @@ describe("OpenAI Responses route", () => {
expect.objectContaining({
type: "reasoning-end",
id: "rs_1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
)
@@ -1250,34 +1151,19 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning-start",
id: "rs_1:0",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{
type: "reasoning-delta",
id: "rs_1:0",
itemId: "rs_1",
text: "First",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{
type: "reasoning-delta",
id: "rs_1:1",
itemId: "rs_1",
text: "Second",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{
type: "reasoning-end",
id: "rs_1:1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
@@ -1315,8 +1201,8 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
])
}),
)
@@ -1364,7 +1250,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
})
expect(body.input[1]).toHaveProperty("id", "rs_1")
expect(body.input[1]).not.toHaveProperty("id")
return input.respond(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -1411,7 +1297,6 @@ describe("OpenAI Responses route", () => {
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
@@ -1420,7 +1305,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("replays complete stored reasoning items with their id", () =>
it.effect("references stored reasoning items by id", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -1438,14 +1323,7 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: undefined,
},
])
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
}),
)
@@ -1554,7 +1432,6 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
@@ -1634,7 +1511,6 @@ describe("OpenAI Responses route", () => {
outputTokens: 1,
nonCachedInputTokens: 5,
cacheReadInputTokens: undefined,
cacheWriteInputTokens: undefined,
reasoningTokens: undefined,
totalTokens: 6,
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
@@ -1645,35 +1521,30 @@ describe("OpenAI Responses route", () => {
{
type: "tool-input-start",
id: "call_1",
itemId: "item_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-input-delta",
id: "call_1",
itemId: "item_1",
name: "lookup",
text: '{"query"',
},
{
type: "tool-input-delta",
id: "call_1",
itemId: "item_1",
name: "lookup",
text: ':"weather"}',
},
{
type: "tool-input-end",
id: "call_1",
itemId: "item_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
id: "call_1",
itemId: "item_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
@@ -1693,17 +1564,6 @@ describe("OpenAI Responses route", () => {
usage,
},
])
expect(response.message.content).toEqual([
{
type: "tool-call",
id: "call_1",
itemId: "item_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
])
}),
)
@@ -1736,7 +1596,6 @@ describe("OpenAI Responses route", () => {
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
type: "tool-input-error",
id: "call_1",
itemId: "item_1",
name: "lookup",
raw: '{"query":"partial',
})
@@ -1793,7 +1652,6 @@ describe("OpenAI Responses route", () => {
{
type: "tool-call",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
input: { type: "search", query: "effect 4" },
providerExecuted: true,
@@ -1802,35 +1660,11 @@ describe("OpenAI Responses route", () => {
{
type: "tool-result",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1", item } },
output: undefined,
},
])
expect(response.message.content).toEqual([
{
type: "tool-call",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
input: { type: "search", query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
{
type: "tool-result",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1", item } },
metadata: undefined,
cache: undefined,
},
])
}),
)
@@ -1908,7 +1742,6 @@ describe("OpenAI Responses route", () => {
expect(toolCall).toEqual({
type: "tool-call",
id: "ci_1",
itemId: "ci_1",
name: "code_interpreter",
input: { code: "print(1+1)", container_id: "cnt_xyz" },
providerExecuted: true,
@@ -1918,12 +1751,10 @@ describe("OpenAI Responses route", () => {
expect(toolResult).toEqual({
type: "tool-result",
id: "ci_1",
itemId: "ci_1",
name: "code_interpreter",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ci_1", item } },
output: undefined,
providerMetadata: { openai: { itemId: "ci_1" } },
})
}),
)
-37
View File
@@ -49,43 +49,6 @@ describe("LLMResponse reducer", () => {
expect(state.message.content).toEqual([{ type: "text", text: "partial" }])
})
test("assembles response item identity and provider metadata", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "text-block", itemId: "msg_existing" }),
LLMEvent.textDelta({
id: "text-block",
itemId: "msg_existing",
text: "Answer",
providerMetadata: { openai: { itemId: "msg_existing" } },
}),
LLMEvent.textEnd({ id: "text-block", itemId: "msg_existing" }),
LLMEvent.reasoningStart({ id: "reasoning-block", itemId: "rs_existing" }),
LLMEvent.reasoningDelta({
id: "reasoning-block",
itemId: "rs_existing",
text: "Thought",
providerMetadata: { openai: { itemId: "rs_existing" } },
}),
LLMEvent.reasoningEnd({ id: "reasoning-block", itemId: "rs_existing" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([
{
type: "text",
text: "Answer",
itemId: "msg_existing",
providerMetadata: { openai: { itemId: "msg_existing" } },
},
{
type: "reasoning",
text: "Thought",
itemId: "rs_existing",
providerMetadata: { openai: { itemId: "rs_existing" } },
},
])
})
test("does not complete ended content without a terminal finish", () => {
const state = reduce([
LLMEvent.textStart({ id: "t1" }),
+5 -29
View File
@@ -172,7 +172,7 @@ describe("LLMClient tools", () => {
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).toMatchObject([
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_projected",
name: "projected",
@@ -180,7 +180,6 @@ describe("LLMClient tools", () => {
output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
}),
])
expect(dispatched.events[0]?.itemId).toBeUndefined()
}),
)
@@ -198,7 +197,7 @@ describe("LLMClient tools", () => {
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
)
expect(dispatched.events).toMatchObject([
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_1",
name: "tool",
@@ -207,13 +206,12 @@ describe("LLMClient tools", () => {
providerMetadata,
}),
])
expect(dispatched.events[0]?.itemId).toBeUndefined()
const failed = yield* ToolRuntime.dispatch(
{},
LLMEvent.toolCall({ id: "call_2", itemId: "fc_failed", name: "missing", input: {}, providerMetadata }),
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
)
expect(failed.events).toMatchObject([
expect(failed.events).toEqual([
LLMEvent.toolError({
id: "call_2",
name: "missing",
@@ -227,27 +225,6 @@ describe("LLMClient tools", () => {
providerMetadata,
}),
])
const errorItemID = failed.events.find(LLMEvent.is.toolError)?.itemId
const resultItemID = failed.events.find(LLMEvent.is.toolResult)?.itemId
expect(errorItemID).toBeUndefined()
expect(resultItemID).toBeUndefined()
}),
)
it.effect("does not derive a function output item id from the function call item id", () =>
Effect.gen(function* () {
const tool = Tool.make({
description: "Return text.",
parameters: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.succeed("hello"),
})
const dispatched = yield* ToolRuntime.dispatch(
{ tool },
LLMEvent.toolCall({ id: "call_1", itemId: "fc_existing", name: "tool", input: {} }),
)
expect(dispatched.events.find(LLMEvent.is.toolResult)?.itemId).toBeUndefined()
}),
)
@@ -460,7 +437,7 @@ describe("LLMClient tools", () => {
)
expect(dispatched.result).toEqual(callerOwned)
expect(dispatched.events).toMatchObject([
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_1",
name: "eventful",
@@ -468,7 +445,6 @@ describe("LLMClient tools", () => {
output: { structured: { ok: true }, content: [] },
}),
])
expect(dispatched.events[0]?.itemId).toBeUndefined()
}),
)
@@ -2,7 +2,7 @@ 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 { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { SessionV1Info, 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"
@@ -18,7 +18,7 @@ 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 Session = SessionInfo
type Session = SessionV1Info
type GlobalEvent = {
directory: string
project?: string
@@ -530,11 +530,11 @@ export function project() {
export function session(input: Partial<Session> = {}): Session {
return {
id: sessionID,
slug: "timeline-stability",
projectID,
location: { directory },
directory,
title,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
...input,
}
@@ -177,8 +177,7 @@ 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`,
status: "modified",
file: `src/diff-${index}.ts`,
additions: 1,
deletions: 1,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
@@ -1,18 +1,19 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const draftID = "draft_removed_layout_preference"
const directory = "C:/OpenCode/RemovedLayoutPreference"
const draftID = "draft_legacy_new_session"
const directory = "C:/OpenCode/LegacyNewSession"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("ignores persisted old layout preferences when opening drafts", async ({ page }) => {
test("redirects a draft to the legacy new-session route", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_removed_layout_preference",
id: "proj_legacy_new_session",
worktree: directory,
vcs: "git",
name: "removed-layout-preference",
name: "legacy-new-session",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
@@ -23,6 +24,7 @@ test("ignores persisted old layout preferences when opening drafts", async ({ pa
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
@@ -33,7 +35,7 @@ test("ignores persisted old layout preferences when opening drafts", async ({ pa
await page.goto(`/new-session?draftId=${draftID}`)
await expect(page).toHaveURL(`/new-session?draftId=${draftID}`)
await expect(page.locator("body")).toHaveAttribute("data-new-layout", "")
await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible()
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
})
@@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
{
id: followingTextID,
id: "prt_0104_text",
sessionID,
messageID: assistantID,
type: "text",
+1 -1
View File
@@ -20,7 +20,7 @@
<meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head>
<body data-new-layout class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep">
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
<script src="/src/entry.tsx" type="module"></script>
-1
View File
@@ -40,7 +40,6 @@
"@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:",
+161 -46
View File
@@ -14,11 +14,14 @@ import {
Navigate,
Route,
Router,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import { base64Encode } from "@opencode-ai/core/util/encode"
import {
type Component,
createEffect,
@@ -40,7 +43,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
@@ -51,36 +54,58 @@ import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { SettingsProvider, useSettings } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider } from "@/context/sdk"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { decode64 } from "@/utils/base64"
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { createSessionLineage } from "@/pages/session/session-lineage"
import { TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome } from "@/pages/home"
import { LegacyHome } from "@/pages/home/legacy-home"
const NewSession = lazy(() => import("@/pages/new-session"))
const DirectoryDraftRedirect = () => {
const SessionRoute = () => {
const settings = useSettings()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer()
const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
const sessionID = params.id
return (
<Show when={tabs.ready()}>
{(_) => {
const persisted = tabs.store.filter((item) => item.type === "session")
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
}}
</Show>
)
}
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => {
if (search.draftId || !tabs.ready()) return
const directory = decode64(params.dir)
if (!directory) return
tabs.newDraft({ server: server.key, directory }, search.prompt)
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk().directory) return
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return null
return (
<SessionRouteErrorBoundary sessionID={params.id}>
<SessionPage />
</SessionRouteErrorBoundary>
)
}
function TargetServerRoute(props: ParentProps) {
@@ -92,7 +117,9 @@ function TargetServerRoute(props: ParentProps) {
})
return (
// Owns the server-identity remount. Session changes must not remount this subtree.
// Owns the server-identity remount. Session changes must NOT remount this
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
// re-resolves reactively instead); both rely on this key for server changes.
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
@@ -107,6 +134,35 @@ const TargetSessionRoute = () => (
</TargetServerRoute>
)
function LegacyTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<TargetServerRoute>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<LegacyTargetSessionRedirect />
</SessionRouteErrorBoundary>
</TargetServerRoute>
)
}
function LegacyTargetSessionRedirect() {
const params = useParams<{ id: string }>()
const navigate = useNavigate()
const sync = useServerSync()
const current = createSessionLineage(
() => params.id,
() => sync().session.lineage,
)
createEffect(() => {
const directory = current()?.session.directory
if (!directory) return
navigate(legacySessionHref(directory, params.id), { replace: true })
})
return null
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell for that server.
function SelectedServerProviders(props: ParentProps) {
@@ -119,8 +175,17 @@ function SelectedServerProviders(props: ParentProps) {
)
}
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
@@ -129,7 +194,14 @@ function DraftRoute() {
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
{(draft) => (
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
>
<ResolvedDraftRoute draft={draft} />
</Show>
)}
</Show>
</Show>
)
@@ -165,6 +237,10 @@ function UiI18nBridge(props: ParentProps) {
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
}
function LayoutCompatibility(props: ParentProps) {
return <>{props.children}</>
}
declare global {
interface Window {
__OPENCODE__?: {
@@ -191,11 +267,17 @@ function QueryProvider(props: ParentProps) {
}
function BodyDesignClass() {
const settings = useSettings()
createRenderEffect(() => {
if (typeof document === "undefined") return
document.body.toggleAttribute("data-new-layout", true)
document.body.classList.remove("text-12-regular")
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
const enabled = settings.general.newLayoutDesigns()
document.body.toggleAttribute("data-new-layout", enabled)
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
document.body.classList.toggle("font-[440]", enabled)
})
return null
@@ -238,6 +320,7 @@ function DesktopCommands() {
return null
}
// Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
serverScoped?: JSX.Element
@@ -252,11 +335,19 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
)
}
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<ServerScopedProviders serverScoped={props.serverScoped}>
<Layout>{props.children}</Layout>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
</SelectedServerProviders>
)
@@ -445,7 +536,7 @@ export function AppInterface(props: {
startup?: Promise<void>
serverScoped?: JSX.Element
}) {
// The visual layout lives in the router root so it remains mounted across
// The visual new layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const ServerShell = (shellProps: ParentProps) => (
@@ -466,22 +557,26 @@ export function AppInterface(props: {
<GlobalProvider>
<SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
</TabsProvider>
)}
>
<Routes />
</Dynamic>
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
</TabsProvider>
)}
>
<Routes serverScoped={props.serverScoped} />
</Dynamic>
</Show>
</ConnectionGate>
</SettingsProvider>
</GlobalProvider>
@@ -489,20 +584,40 @@ export function AppInterface(props: {
)
}
function Routes() {
function Routes(props: { serverScoped?: JSX.Element }) {
const settings = useSettings()
return (
<>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryDraftRedirect} />
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
<Route
component={(routeProps) => (
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
)}
>
<Show when={!settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={LegacyHome} />
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
</>
}
</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} />
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Show>
<Route path="/new-session" component={DraftRoute} />
</>
)
}
function LegacySessionRedirect() {
function NewLayoutLegacySessionRedirect() {
const server = useServer()
const tabs = useTabs()
const params = useParams<{ id: string }>()
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

@@ -14,6 +14,7 @@ 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
@@ -256,6 +257,7 @@ export function createServerSessionEntries(props: {
.load(search, current.signal)
.then((result) =>
result.data
.map(normalizeSessionInfo)
.filter((session) => !session.time.archived)
.map((session) => {
const project =
@@ -264,9 +266,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.location.directory),
description: project ? displayName(project) : getFilename(session.directory),
category: props.category(),
directory: session.location.directory,
directory: session.directory,
sessionID: session.id,
server: props.server,
project,
@@ -0,0 +1,170 @@
import { Button } from "@opencode-ai/ui/button"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { Icon } from "@opencode-ai/ui/icon"
import { For, Show } from "solid-js"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server"
import { createEditProjectModel } from "./edit-project"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage()
const model = createEditProjectModel(props)
return (
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
<div class="flex flex-col gap-4">
<TextField
autofocus
type="text"
label={language.t("dialog.project.edit.name")}
placeholder={model.folderName()}
value={model.store.name}
onChange={(v) => model.setStore("name", v)}
/>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
<div class="flex gap-3 items-start">
<div
class="relative"
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
>
<div
class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
"border-border-base hover:border-border-strong": !model.store.dragOver,
"overflow-hidden": !!model.store.iconOverride,
}}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<Show
when={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
fallback={
<div class="size-full flex items-center justify-center">
<Avatar
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(model.store.color)}
class="size-full text-[32px]"
/>
</div>
}
>
{(src) => (
<img
src={src()}
alt={language.t("dialog.project.edit.icon.alt")}
class="size-full object-cover"
/>
)}
</Show>
</div>
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": model.store.iconHover && !model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
}}
>
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div>
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
}}
>
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div>
</div>
<input
id="icon-upload"
ref={(el) => {
model.setIconInput(el)
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
<div class="flex gap-1.5">
<For each={AVATAR_COLOR_KEYS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={model.store.color === color}
classList={{
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
model.store.color === color,
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
model.store.color !== color,
}}
onClick={() => {
if (model.store.color === color && !props.project.icon?.url) return
model.setStore("color", model.store.color === color ? undefined : color)
}}
>
<Avatar
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(color)}
class="size-full rounded"
/>
</button>
)}
</For>
</div>
</div>
</Show>
<TextField
multiline
label={language.t("dialog.project.edit.worktree.startup")}
description={language.t("dialog.project.edit.worktree.startup.description")}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
value={model.store.startup}
onChange={(v) => model.setStore("startup", v)}
spellcheck={false}
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" size="large" onClick={model.close}>
{language.t("common.cancel")}
</Button>
<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>
</form>
</Dialog>
)
}
@@ -10,6 +10,7 @@ const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth",
needs_client_registration: "mcp.status.needs_client_registration",
disabled: "mcp.status.disabled",
} as const
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
}
const error = () => {
const s = mcpStatus()
if (s?.status === "failed") return s.error
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
}
const enabled = () => status() === "connected"
return (
@@ -0,0 +1,94 @@
import { Component, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsServers } from "./settings-servers"
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const showProviders = () => {
void dialog.show(() => <DialogSettings defaultValue="providers" />)
}
return (
<Dialog size="x-large" transition>
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="h-full settings-dialog"
>
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full gap-4">
<div class="flex flex-col gap-3 w-full pt-3">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<Tabs.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.general")}
</Tabs.Trigger>
<Tabs.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</Tabs.Trigger>
<Tabs.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<Tabs.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
</Tabs.Trigger>
<Tabs.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
</Tabs.Trigger>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
<span>{language.t("app.name.desktop")}</span>
<span class="text-11-regular">v{platform.version}</span>
</div>
</div>
</Tabs.List>
<Tabs.Content value="general" class="no-scrollbar">
<SettingsGeneral />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="no-scrollbar">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="servers" class="no-scrollbar">
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders onBack={showProviders} />
</Tabs.Content>
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
</Tabs>
</Dialog>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { createSignal, Show } from "solid-js"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
// TODO: wire to changelog / seen-state when available
const showPopover = () => true
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
const settings = useSettings()
const platform = usePlatform()
const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows"
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={settings.general.shouldDisplayTabsToast()}>
<div
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
>
<button
type="button"
aria-label="Dismiss Tabs information"
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={settings.general.dismissTabsToast}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
</svg>
</button>
<button
type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
onClick={() => {
settings.general.dismissTabsToast()
setDrawerOpen(true)
}}
>
<video
src={introducingTabsVideo}
class="absolute inset-0 h-full w-full object-cover"
loop
muted
autoplay
playsinline
aria-hidden="true"
onContextMenu={(event) => event.preventDefault()}
/>
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
Introducing Tabs
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
Organize your work and active sessions with tabs
</p>
</div>
</button>
</div>
</Show>
<DrawerContent
style={
windows()
? {
inset: "0 0 0 auto",
"max-height": "100vh",
"max-width": "100vw",
"border-radius": "0",
}
: undefined
}
>
<Show when={windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="neutral"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]"
/>
</Show>
<div
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
classList={{
"h-[40px] px-4": windows(),
"h-[52px] p-4": !windows(),
}}
>
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
July 14
</p>
<Show when={!windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
/>
</Show>
</div>
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs
</p>
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<p>OpenCode Desktop is now built around tabs.</p>
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
you're starting something new, and close it when you're done.
</p>
<p>
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
memorable if you plan to keep them around.
</p>
<p>
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>When you reopen the app, your tabs are still open.</p>
<p>
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
will become permanent in a few weeks.
</p>
</div>
</div>
</DrawerContent>
</Drawer>
)
}
@@ -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/client/promise"
import type { ReferenceInfo } from "@/types"
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 -1
View File
@@ -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/client/promise"
import type { ReferenceInfo } from "@/types"
export { createPromptInputHistory }
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
@@ -1,15 +1,12 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
import type { AgentPartInput, FilePartInput, Part, TextPartInput } 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 =
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
type ContextFile = {
key: string
@@ -1,5 +1,4 @@
import type { Message } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Message, Session } from "@/types"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
@@ -22,6 +21,7 @@ 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 = {
@@ -310,10 +310,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const seed = (dir: string, info: SessionInfo) => {
const seed = (dir: string, info: Session) => {
serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir)
setStore("session", (list: SessionInfo[]) => {
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
if (result.found) {
@@ -407,6 +407,7 @@ 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"),
@@ -0,0 +1,793 @@
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
type ThemeOption = {
id: string
name: string
}
type ShellOption = {
path: string
name: string
acceptable: boolean
}
type ShellSelectOption = {
id: string
value: string
label: string
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.cleanup = undefined
}
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!id) return
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
}
export const SettingsGeneral: Component = () => {
const theme = useTheme()
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const dialog = useDialog()
const params = useParams()
const settings = useSettings()
const updater = useUpdaterAction()
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
const accepting = createMemo(() => {
const value = dir()
if (!value) return false
if (!params.id) return permission.isAutoAcceptingDirectory(value)
return permission.isAutoAccepting(params.id, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value) return
if (!params.id) {
if (permission.isAutoAcceptingDirectory(value) === checked) return
permission.toggleAutoAcceptDirectory(value)
return
}
if (checked) {
permission.enableAutoAccept(params.id, value)
return
}
permission.disableAutoAccept(params.id, value)
}
const desktop = createMemo(() => platform.platform === "desktop")
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const [shells] = createResource(
async () => {
// TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
() => (linux() && platform.getDisplayBackend ? true : false),
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
{ initialValue: null as DisplayBackend | null },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false },
)
onMount(() => {
void theme.loadThemes()
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync().data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
}
const options = [
autoOption,
...list.map((s) => {
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
const text = ambiguousName ? s.path : s.name
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
return {
id: s.path,
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
value: ambiguousName ? s.path : s.name,
label,
}
}),
]
if (current && !options.some((o) => o.value === current)) {
options.push({ id: current, value: current, label: current })
}
return options
})
const onDisplayBackendChange = (checked: boolean) => {
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
if (!update) return
void update.finally(() => {
void refetchDisplayBackend()
})
}
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
if (!update) return
void update.catch(() => setPinchZoom(!checked))
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
current: () => string,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
options: soundOptions,
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
stopDemoSound()
return
}
setEnabled(true)
set(option.id)
playDemoSound(option.id)
},
variant: "secondary" as const,
size: "small" as const,
triggerVariant: "settings" as const,
})
const InterfaceSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={
<span class="flex items-center gap-2">
{language.t("settings.general.row.newInterface.title")}
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
</span>
}
description={language.t("settings.general.row.newInterface.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (!checked) return
void import("@/components/settings-v2").then((module) => {
void dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const InterfaceNoticeSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
>
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
</Button>
</SettingsRow>
</SettingsList>
</div>
)
const GeneralSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<Select
data-action="settings-language"
options={languageOptions()}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
disabled
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
// TODO: Restore config writes when the V2 client exposes a config API.
// void serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-feed-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const AdvancedSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
>
<div data-action="settings-show-search">
<Switch
checked={settings.general.showSearch()}
onChange={(checked) => settings.general.setShowSearch(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<Select
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "220px" }}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
</>
}
>
<Select
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-ui-font"
label={language.t("settings.general.row.uiFont.title")}
hideLabel
type="text"
value={sans()}
onChange={(value) => settings.appearance.setUIFont(value)}
placeholder={sansDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-code-font"
label={language.t("settings.general.row.font.title")}
hideLabel
type="text"
value={mono()}
onChange={(value) => settings.appearance.setFont(value)}
placeholder={monoDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.terminalFont.title")}
description={language.t("settings.general.row.terminalFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-terminal-font"
label={language.t("settings.general.row.terminalFont.title")}
hideLabel
type="text"
value={terminal()}
onChange={(value) => settings.appearance.setTerminalFont(value)}
placeholder={terminalDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const NotificationsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const SoundsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<Select
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agentEnabled(),
() => settings.sounds.agent(),
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<Select
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissionsEnabled(),
() => settings.sounds.permissions(),
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<Select
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errorsEnabled(),
() => settings.sounds.errors(),
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
)}
/>
</SettingsRow>
</SettingsList>
</div>
)
const UpdatesSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
{language.t(updater.action().label)}
</Button>
</SettingsRow>
</SettingsList>
</div>
)
const DisplaySection = () => (
<Show when={desktop()}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
<Show when={linux()}>
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRow>
</Show>
</SettingsList>
</div>
</Show>
)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
</div>
</div>
<div class="flex flex-col gap-8 w-full">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection />
<AppearanceSection />
<NotificationsSection />
<SoundsSection />
<UpdatesSection />
<DisplaySection />
<Show when={desktop()}>
<AdvancedSection />
</Show>
</div>
</div>
)
}
interface SettingsRowProps {
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
</div>
)
}
@@ -0,0 +1,149 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const ListLoadingState: Component<{ label: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.label}</span>
</div>
)
}
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.message}</span>
<Show when={props.filter}>
<span class="text-14-regular text-text-strong mt-1">&quot;{props.filter}&quot;</span>
</Show>
</div>
)
}
export const SettingsModels: Component = () => {
return (
<SettingsServerScope>
<SettingsModelsContent />
</SettingsServerScope>
)
}
const SettingsModelsContent: Component = () => {
const language = useLanguage()
const models = useModels()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aIndex = popularProviders.indexOf(a.category)
const bIndex = popularProviders.indexOf(b.category)
const aPopular = aIndex >= 0
const bPopular = bIndex >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
if (aPopular && bPopular) return aIndex - bIndex
const aName = a.items[0].provider.name
const bName = b.items[0].provider.name
return aName.localeCompare(bName)
},
})
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<SettingsServerPicker />
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={list.filter()}
onChange={list.onInput}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={list.filter()}>
<IconButton icon="circle-x" variant="ghost" onClick={list.clear} />
</Show>
</div>
</div>
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<Show
when={!list.grouped.loading}
fallback={
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
}
>
<Show
when={list.flat().length > 0}
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 pb-2">
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
</div>
<SettingsList>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="min-w-0">
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
</div>
<div class="flex-shrink-0">
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</div>
)
}}
</For>
</SettingsList>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
)
}
@@ -0,0 +1,259 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag"
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 { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
const PROVIDER_NOTES = [
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const
export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => {
return (
<SettingsServerScope>
<SettingsProvidersContent onBack={props.onBack} />
</SettingsServerScope>
)
}
const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders(() => undefined)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
}
const connected = createMemo(() => {
return providers
.connected()
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
})
const popular = createMemo(() => {
const connectedIDs = new Set(connected().map((p) => p.id))
const items = providers
.popular()
.filter((p) => !connectedIDs.has(p.id))
.slice()
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
return items
})
const source = (item: ProviderItem): ProviderSource | undefined => {
if (!("source" in item)) return
const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
return
}
const type = (item: ProviderItem) => {
const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "config") {
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.config")
}
if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync().data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
return true
}
const disableProvider = async (providerID: string, name: string) => {
return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
await serverSync()
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (providerID: string, name: string) => {
await serverSDK()
.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",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
<SettingsServerPicker />
</div>
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<div class="flex flex-col gap-1" data-component="connected-providers-section">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
<SettingsList>
<Show
when={connected().length > 0}
fallback={
<div class="py-4 text-14-regular text-text-weak">
{language.t("settings.providers.connected.empty")}
</div>
}
>
<For each={connected()}>
{(item) => (
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
<div class="flex items-center gap-3 min-w-0">
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
<Tag>{type(item)}</Tag>
</div>
<Show
when={canDisconnect(item)}
fallback={
<span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
</Show>
</div>
)}
</For>
</Show>
</SettingsList>
</div>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
<SettingsList>
<For each={popular()}>
{(item) => (
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-x-3">
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{item.name}</span>
<Show when={item.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={item.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
<Show when={note(item.id)}>
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
</Show>
</div>
<Button size="large" variant="secondary" icon="plus-small" onClick={() => connect(item.id)}>
{language.t("common.connect")}
</Button>
</div>
)}
</For>
<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"
>
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<span class="text-12-regular text-text-weak pl-8">
{language.t("settings.providers.custom.description")}
</span>
</div>
<Button
size="large"
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</Button>
</div>
</Show>
</SettingsList>
<Button
variant="ghost"
class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent"
onClick={() => connect()}
>
{language.t("dialog.provider.viewAll")}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,106 @@
import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { QueryClientProvider } from "@tanstack/solid-query"
import { createMemo, For, type ParentProps, Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { ModelsProvider } from "@/context/models"
import { ServerConnection } from "@/context/server"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
export function SettingsServerScope(props: ParentProps) {
const global = useGlobal()
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={props.children}>
<Show when={global.settings.server.selected()}>
{(server) => <SettingsServerDataProviders server={server()}>{props.children}</SettingsServerDataProviders>}
</Show>
</Show>
)
}
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
const global = useGlobal()
const serverCtx = () => global.ensureServerCtx(props.server)
return (
<QueryClientProvider client={serverCtx().queryClient}>
<ServerSDKProvider server={() => props.server}>
<ServerSyncProvider>
<ModelsProvider>{props.children}</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryClientProvider>
)
}
export function SettingsServerPicker() {
const global = useGlobal()
const settings = useSettings()
const selected = createMemo(() =>
settings.general.newLayoutDesigns() ? global.settings.server.selected() : undefined,
)
return (
<Show when={selected()}>
{(conn) => (
<DropdownMenu gutter={4} placement="bottom-end">
<DropdownMenu.Trigger
as={Button}
variant="secondary"
size="large"
class="h-8 max-w-[260px] gap-2 px-2 py-1.5 data-[expanded]:bg-surface-base-active"
>
<ServerHealthIndicator health={global.servers.health[ServerConnection.key(conn())]} />
<ServerRow
conn={conn()}
status={global.servers.health[ServerConnection.key(conn())]}
class="flex items-center gap-2 min-w-0 flex-1"
nameClass="text-14-regular text-text-base truncate"
versionClass="hidden"
/>
<Icon name="chevron-down" size="small" class="text-icon-weak shrink-0" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="w-[320px] mt-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-2 [&_[data-slot=dropdown-menu-radio-item]]:pr-2">
<DropdownMenu.RadioGroup
value={global.settings.server.key}
onChange={(key) => {
if (typeof key === "string") global.settings.server.set(ServerConnection.Key.make(key))
}}
>
<For each={global.servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
const blocked = () => global.servers.health[key]?.healthy === false
return (
<DropdownMenu.RadioItem value={key} disabled={blocked()}>
<ServerHealthIndicator health={global.servers.health[key]} />
<ServerRow
conn={item}
dimmed={blocked()}
status={global.servers.health[key]}
class="flex items-center gap-2 min-w-0 flex-1"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
/>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)
}}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)}
</Show>
)
}
@@ -0,0 +1,33 @@
import { Show, type Component } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
export const SettingsServers: Component = () => {
const language = useLanguage()
const controller = useServerManagementController()
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
<Show
when={controller.isFormMode()}
fallback={
<>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
</div>
</div>
<ServerConnectionList controller={controller} />
</>
}
>
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
<ServerConnectionForm controller={controller} />
</div>
</Show>
</div>
</div>
)
}
@@ -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)?.location.directory
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
return undefined
})
@@ -5,6 +5,7 @@ import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
@@ -26,6 +27,7 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import "./settings-v2.css"
let demoSoundState = {
@@ -85,6 +87,7 @@ export const SettingsGeneralV2: Component<{
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const dialog = useDialog()
const settings = useSettings()
const serverSync = useServerSync()
const mobile = createMediaQuery("(max-width: 767px)")
@@ -93,7 +96,7 @@ export const SettingsGeneralV2: Component<{
const dir = createMemo(() => {
if (!props.sessionID) return undefined
return serverSync().session.lineage.peek(props.sessionID)?.session.location.directory
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
})
const accepting = createMemo(() => {
const value = dir()
@@ -222,6 +225,31 @@ export const SettingsGeneralV2: Component<{
},
})
const InterfaceSection = () => (
<LayoutTransitionToggle
title={language.t("settings.general.row.newInterface.title")}
badge={language.t("settings.general.row.newInterface.badge")}
description={language.t("settings.general.row.newInterface.description")}
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (checked) return
void import("@/components/dialog-settings").then((module) => {
void dialog.show(() => <module.DialogSettings />)
})
}}
/>
)
const InterfaceNoticeSection = () => (
<LayoutRetirementNotice
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
onDismiss={settings.general.dismissNewInterfaceNotice}
/>
)
const GeneralSection = () => (
<div class="settings-v2-section">
<SettingsListV2>
@@ -661,6 +689,14 @@ export const SettingsGeneralV2: Component<{
</div>
<div class="settings-v2-tab-body">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection />
<AppearanceSection />
@@ -0,0 +1,76 @@
// @ts-nocheck
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
const copy = {
title: "New layout",
badge: "New",
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
noticeTitle: "You're now using new layout",
noticeDescription: "The previous layout is no longer available",
dismiss: "Dismiss",
}
function Frame(props) {
return <div class="w-[640px] max-w-full">{props.children}</div>
}
function ToggleExample(props) {
const [state, setState] = createStore({ checked: props.checked })
return (
<Frame>
<LayoutTransitionToggle
title={copy.title}
badge={copy.badge}
description={copy.description}
checked={state.checked}
onChange={(checked) => setState("checked", checked)}
/>
</Frame>
)
}
function NoticeExample() {
const [state, setState] = createStore({ dismissed: false })
return (
<Frame>
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
<LayoutRetirementNotice
title={copy.noticeTitle}
description={copy.noticeDescription}
dismiss={copy.dismiss}
onDismiss={() => setState("dismissed", true)}
/>
</Show>
</Frame>
)
}
export default {
title: "App/Settings/Layout transition",
id: "app-settings-layout-transition",
component: LayoutTransitionToggle,
}
export const NewLayoutEnabled = {
render: () => <ToggleExample checked />,
}
export const PreviousLayoutEnabled = {
render: () => <ToggleExample checked={false} />,
}
export const PreviousLayoutRetired = {
render: () => <NoticeExample />,
}
export const AllStates = {
render: () => (
<div class="flex flex-col gap-8">
<ToggleExample checked />
<ToggleExample checked={false} />
<NoticeExample />
</div>
),
}
@@ -0,0 +1,54 @@
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
export function LayoutTransitionToggle(props: {
title: string
badge: string
description: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<div class="settings-v2-section">
<div class="settings-v2-interface-feature">
<SettingsListV2>
<SettingsRowV2
title={
<span class="flex items-center gap-2">
{props.title}
<Tag variant="accent">{props.badge}</Tag>
</span>
}
description={props.description}
>
<div data-action="settings-new-layout-designs">
<Switch checked={props.checked} onChange={props.onChange} />
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
</div>
)
}
export function LayoutRetirementNotice(props: {
title: string
description: string
dismiss: string
onDismiss: () => void
}) {
return (
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2 title={props.title} description={props.description}>
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
{props.dismiss}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
</div>
)
}
@@ -426,7 +426,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base": status() === "needs_auth",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<span class="flex flex-col min-w-0 flex-1">
@@ -35,6 +35,7 @@ describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
})
@@ -47,6 +48,7 @@ describe("hasNonBlockingServiceIssue", () => {
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth")
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
}
export function hasNonBlockingServiceIssue(input: {
@@ -8,8 +8,7 @@ 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 { SessionInfo } from "@opencode-ai/client/promise"
import { sessionLabel } from "@/utils/session-title"
import type { Session } from "@/types"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
import "./titlebar-tab-nav.css"
@@ -21,7 +20,7 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement>
href: string
server: ServerConnection.Key
session: () => SessionInfo | undefined
session: () => Session | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onClose: () => void
@@ -55,21 +54,18 @@ export function TabNavItem(props: {
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const title = createMemo(() => {
const session = props.session()
return session ? sessionLabel(session) : props.fallbackTitle
})
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
const projectName = createMemo(() => {
const session = props.session()
if (!session) return
return displayName(project() ?? { worktree: session.location.directory })
return displayName(project() ?? { worktree: session.directory })
})
const previewPath = createMemo(() => {
const session = props.session()
if (!session) return
const home = serverCtx()?.sync.data.path.home
return home ? session.location.directory.replace(home, "~") : session.location.directory
return home ? session.directory.replace(home, "~") : session.directory
})
// Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => {
@@ -235,7 +231,7 @@ export function TabNavItem(props: {
{(session) => (
<SessionTabAvatar
project={project()}
directory={session().location.directory}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
@@ -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 { SessionInfo } from "@opencode-ai/client/promise"
import type { Session } from "@/types"
function SessionTabSlot(props: {
tab: SessionTab
@@ -27,7 +27,7 @@ function SessionTabSlot(props: {
index: () => number
active: () => boolean
forceTruncate: boolean
session: () => SessionInfo | undefined
session: () => Session | 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.location.directory)
.ensureDirSyncContext(value.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.location.directory),
dir: base64Encode(value.directory),
id: value.id,
})
})
+3 -1
View File
@@ -27,6 +27,7 @@ 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
@@ -193,6 +194,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
({ route, sdk }) =>
sdk.api.session
.get({ sessionID: route.sessionId })
.then(normalizeSessionInfo)
.catch(() => {}),
)
@@ -254,7 +256,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.location.directory }, "", model)
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
return
}
+5
View File
@@ -248,6 +248,11 @@ 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
+6 -5
View File
@@ -1,11 +1,11 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Message, Part, Session } from "@/types"
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([
@@ -46,7 +46,7 @@ export const createDirSyncContext = (
const index = (sessionID: string) => {
const session = serverSync.session.get(sessionID)
if (!session || session.location.directory !== directory) return
if (!session || session.directory !== directory) return
const [store, setStore] = current()
const result = Binary.search(store.session, session.id, (item) => item.id)
if (result.found) {
@@ -74,13 +74,13 @@ export const createDirSyncContext = (
if (match.found) return serverSync.data.project[match.index]
},
session: {
remember(session: SessionInfo) {
remember(session: Session) {
serverSync.session.remember(session)
index(session.id)
},
get(sessionID: string) {
const session = serverSync.session.get(sessionID)
if (session?.location.directory === directory) return session
if (session?.directory === directory) return session
},
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
@@ -125,6 +125,7 @@ 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)
+7
View File
@@ -286,6 +286,13 @@ 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,8 +1,12 @@
import type {
Config,
Path,
PermissionRequest,
Project,
ProviderAuthResponse,
QuestionRequest,
ReferenceInfo,
Session,
} from "@/types"
import type {
AgentListInput,
@@ -13,16 +17,12 @@ import type {
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"
@@ -34,6 +34,7 @@ import type { ServerSession } from "../server-session"
import {
cmp,
normalizeAgentList,
normalizePermissionRequest,
normalizeProjectInfo,
normalizeProviderList,
} from "./utils"
@@ -42,6 +43,7 @@ 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 { ServerApi } from "@/utils/server"
type GlobalStore = {
@@ -181,7 +183,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: SessionInfo) {
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
setStore("session", (list) => {
const next = list.slice()
const idx = next.findIndex((item) => item.id >= session.id)
@@ -206,7 +208,9 @@ 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, session)),
retry(() => input.api.get({ sessionID })).then((session) =>
mergeSession(input.setStore, normalizeSessionInfo(session)),
),
),
).then(() => undefined)
}
@@ -364,7 +368,7 @@ export async function bootstrapDirectory(input: {
retry(() =>
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((result) => result.data.map(normalizePermissionRequest))
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
@@ -378,7 +382,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)?.location.directory !== input.directory) continue
if (input.session?.get(sessionID)?.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, [])
}
@@ -412,7 +416,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)?.location.directory !== input.directory) continue
if (input.session?.get(sessionID)?.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, [])
}
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Project } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@/types"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -14,7 +13,7 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
updated: 1,
archived: input.archived,
},
}) as SessionInfo
}) as Session
const userMessage = (id: string, sessionID: string) =>
({
@@ -39,10 +38,10 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
({
id,
sessionID,
action: title,
resources: ["*"],
permission: title,
patterns: ["*"],
metadata: {},
save: [],
always: [],
}) as PermissionRequest
const questionRequest = (id: string, sessionID: string, title = id) =>
@@ -513,7 +512,7 @@ describe("applyDirectoryEvent", () => {
directory: "/tmp",
loadLsp() {},
})
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.action).toBe("updated")
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated")
applyDirectoryEvent({
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
@@ -3,10 +3,14 @@ import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/
import type {
Message,
Part,
PermissionRequest,
Project,
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
@@ -75,7 +79,7 @@ function cleanupSessionCaches(
export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
next: SessionInfo[],
next: Session[],
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
const keep = new Set(next.map((item) => item.id))
@@ -124,7 +128,7 @@ export function applyDirectoryEvent(input: {
return
}
case "session.created": {
const info = (event.properties as { info: SessionInfo }).info
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) {
input.setStore("session", result.index, reconcile(info))
@@ -139,7 +143,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.updated": {
const info = (event.properties as { info: SessionInfo }).info
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) {
if (!result.found) break
@@ -167,7 +171,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.deleted": {
const properties = event.properties as { sessionID?: string; info?: SessionInfo }
const properties = event.properties as { sessionID?: string; info?: Session }
const sessionID = properties.info?.id ?? properties.sessionID
if (!sessionID) break
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
@@ -197,7 +201,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.usage.updated": {
const properties = event.properties as Pick<SessionInfo, "cost" | "tokens"> & { sessionID: string }
const properties = event.properties as Pick<Session, "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) => ({
@@ -233,8 +237,9 @@ export function applyDirectoryEvent(input: {
input.setStore("session", result.index, (session) => ({
...session,
projectID: properties.projectID ?? session.projectID,
location: properties.location,
subpath: properties.subpath,
workspaceID: properties.location.workspaceID,
directory: properties.location.directory,
path: properties.subpath,
time: { ...session.time, updated: Date.now() },
}))
break
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { SessionV2Info } from "@/types"
import {
applyHomeSessionEvent,
appendHomeSessionEvent,
@@ -48,7 +48,9 @@ describe("Home V2 session index", () => {
calls.push({ input, signal: options.signal })
if (!("cursor" in input)) {
return {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session({ id: `page-1-${index}` })),
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
session({ id: `page-1-${index}` }),
),
cursor: { next: "next-page" },
}
}
@@ -72,7 +74,7 @@ describe("Home V2 session index", () => {
const activeNull = {
...session({ id: "active-null", updated: 20 }),
time: { created: 1, updated: 20, archived: null },
} as unknown as SessionInfo
} as unknown as SessionV2Info
const result = parseHomeSessionIndex([
session({ id: "root", updated: 30 }),
activeNull,
@@ -81,9 +83,11 @@ describe("Home V2 session index", () => {
])
expect(result).toEqual([
expect.objectContaining({
id: "root",
location: { directory: "/project" },
expect.objectContaining({
id: "root",
slug: "root",
version: "",
directory: "/project",
projectID: "project",
title: "root",
time: { created: 1, updated: 30 },
@@ -99,17 +103,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],
location: { directory: index % 2 === 0 ? "/one" : "/two" },
directory: index % 2 === 0 ? "/one" : "/two",
}))
const retained = retainHomeSessions(sessions, 10, now)
expect(retained.filter((item) => item.location.directory === "/one")).toHaveLength(10)
expect(retained.filter((item) => item.location.directory === "/two")).toHaveLength(10)
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
})
test("replays session events over the loaded index", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })])
const created = { ...initial[0], id: "new", title: "new", time: { created: 2, updated: 2 } }
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
const afterCreate = applyHomeSessionEvent(initial, {
type: "session.created",
@@ -1,5 +1,5 @@
import type { Event } from "@/types"
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@/types"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import type { QueryClient } from "@tanstack/solid-query"
import { trimSessions } from "./session-trim"
import { pathKey } from "@/utils/path-key"
@@ -8,14 +8,14 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export type HomeSessionEvent = {
type: "session.created" | "session.updated" | "session.deleted"
properties: { sessionID: string; info?: SessionInfo }
properties: { sessionID: string; info: Session }
}
export type HomeSessionEvents = {
sequence: number
entries: Array<{ sequence: number; event: HomeSessionEvent }>
}
export type HomeSessionIndex = {
sessions: SessionInfo[]
sessions: Session[]
eventSequence: number
}
@@ -26,11 +26,11 @@ export async function loadHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<SessionsResponse>,
) => Promise<V2SessionListResponse>,
eventSequence = 0,
signal?: AbortSignal,
) {
const data: SessionInfo[] = []
const data: SessionV2Info[] = []
let cursor: string | undefined
for (;;) {
@@ -125,28 +125,23 @@ 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" }) and replace this full-table scan.
export function parseHomeSessionIndex(sessions: SessionInfo[]): SessionInfo[] {
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
return sessions.flatMap((item) => {
if (item.parentID || typeof item.time.archived === "number") return []
return [item]
return [toLegacySummary(item)]
})
}
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) {
const grouped = Map.groupBy(sessions, (session) => pathKey(session.location.directory))
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
}
export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessionEvent) {
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
const info = event.properties.info
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") {
const index = sessions.findIndex((session) => session.id === info.id)
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
@@ -154,3 +149,22 @@ export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessio
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: withTimestampedFallback(session),
agent: session.agent,
model: session.model,
version: "",
time: session.time,
}
}
@@ -13,6 +13,7 @@ export async function toggleMcp(input: {
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
needs_client_registration: input.connect,
}[input.status]()
await input.refresh()
}
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Todo } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -1,5 +1,4 @@
import type { Message, Part, Todo } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -1,4 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise"
import { normalizeSessionInfo } from "@/utils/session"
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
const result = await input.api.list({
@@ -8,7 +9,7 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
order: "desc",
})
return {
data: result.data,
data: result.data.map(normalizeSessionInfo),
limit: input.limit,
limited: true,
} as const
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, Session } from "@/types"
import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
@@ -11,7 +11,7 @@ const session = (input: { id: string; parentID?: string; created: number; update
updated: input.updated,
archived: input.archived,
},
}) as SessionInfo
}) as Session
describe("trimSessions", () => {
test("keeps base roots and recent roots beyond the limit", () => {
@@ -1,22 +1,21 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import type { PermissionRequest, Session } from "@/types"
import { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
export function sessionUpdatedAt(session: SessionInfo) {
export function sessionUpdatedAt(session: Session) {
return session.time.updated ?? session.time.created
}
export function compareSessionRecent(a: SessionInfo, b: SessionInfo) {
export function compareSessionRecent(a: Session, b: Session) {
const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated
return cmp(a.id, b.id)
}
export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutoff: number) {
if (limit <= 0) return [] as SessionInfo[]
const selected: SessionInfo[] = []
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
if (limit <= 0) return [] as Session[]
const selected: Session[] = []
const seen = new Set<string>()
for (const session of sessions) {
if (!session?.id) continue
@@ -32,7 +31,7 @@ export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutof
}
export function trimSessions(
input: SessionInfo[],
input: Session[],
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
) {
const limit = Math.max(0, options.limit)
@@ -5,17 +5,15 @@ import type {
Message,
Part,
Path,
Todo,
VcsInfo,
} from "@/types"
import type {
FileDiffInfo,
PermissionRequest,
QuestionRequest,
ReferenceInfo,
SessionInfo,
Session,
SessionStatus,
} from "@opencode-ai/client/promise"
Todo,
VcsInfo,
} from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js"
@@ -44,7 +42,7 @@ export type State = {
provider: NormalizedProviderListResponse
config: Config
path: Path
session: SessionInfo[]
session: Session[]
sessionTotal: number
session_status: {
[sessionID: string]: SessionStatus
@@ -5,7 +5,7 @@ import type {
ModelListOutput,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
@@ -43,6 +43,30 @@ describe("normalizeAgentList", () => {
})
})
describe("normalizePermissionRequest", () => {
test("adapts the current permission request to app state", () => {
expect(
normalizePermissionRequest({
id: "permission-1",
sessionID: "session-1",
action: "read",
resources: ["README.md"],
save: ["*.md"],
metadata: { path: "README.md" },
source: { type: "tool", messageID: "message-1", id: "call-1" },
}),
).toEqual({
id: "permission-1",
sessionID: "session-1",
permission: "read",
patterns: ["README.md"],
always: ["*.md"],
metadata: { path: "README.md" },
tool: { messageID: "message-1", callID: "call-1" },
})
})
})
describe("normalizeProviderList", () => {
test("groups current models into the app provider catalog", () => {
const result = normalizeProviderList(
+30 -1
View File
@@ -2,9 +2,10 @@ import type {
AgentListOutput,
ModelDefaultOutput,
ModelListOutput,
PermissionRequest,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { Agent, Project, Provider, ProviderListResponse } from "@/types"
import type { Agent, Event, Project, Provider, ProviderListResponse } from "@/types"
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
@@ -35,6 +36,22 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
}))
}
type LegacyPermissionRequest = Extract<Event, { type: "permission.asked" }>["properties"]
export function normalizePermissionRequest(input: PermissionRequest | LegacyPermissionRequest): LegacyPermissionRequest {
if ("permission" in input) return input
return {
id: input.id,
sessionID: input.sessionID,
permission: input.action,
patterns: input.resources,
always: input.save ?? [],
metadata: input.metadata ?? {},
tool:
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.id } : undefined,
}
}
export function normalizeProviderList(
providers: ProviderListOutput["data"] | ProviderListResponse,
models?: ModelListOutput["data"],
@@ -123,6 +140,18 @@ export function normalizeProviderList(
}
}
export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project
return {
...project,
icon: {
...project.icon,
url: undefined,
override: undefined,
},
}
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return {
...project,
+30
View File
@@ -744,6 +744,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
mobileSidebar: {
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
show() {
setStore("mobileSidebar", "opened", true)
},
hide() {
setStore("mobileSidebar", "opened", false)
},
@@ -949,6 +952,33 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (current.reviewOpen.includes(path)) return
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
},
closePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current) return
const index = current.indexOf(path)
if (index === -1) return
setStore(
"sessionView",
session,
"reviewOpen",
produce((draft) => {
if (!draft) return
draft.splice(index, 1)
}),
)
},
togglePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current || !current.includes(path)) {
this.openPath(path)
return
}
this.closePath(path)
},
},
}
},
+3 -3
View File
@@ -360,7 +360,7 @@ function createServerNotificationState(input: {
const handleSessionError = (
directory: string,
event: { properties: EventSessionError["properties"] },
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
@@ -372,7 +372,7 @@ function createServerNotificationState(input: {
void playSoundById(settings.sounds.errors())
}
const error = event.properties.error
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
@@ -393,7 +393,7 @@ function createServerNotificationState(input: {
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.execution.failed") return
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, Session } from "@/types"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
@@ -7,7 +7,7 @@ const session = (input: { id: string; parentID?: string }) =>
({
id: input.id,
parentID: input.parentID,
}) as SessionInfo
}) as Session
const permission = (sessionID: string) =>
({
+6 -3
View File
@@ -1,7 +1,7 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import type { PermissionRequest } from "@/types"
import { Persist, persisted } from "@/utils/persist"
import type { ServerSDK } from "@/context/server-sdk"
import type { ServerSync } from "./server-sync"
@@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
import { normalizePermissionRequest } from "./global-sync/utils"
import {
acceptKey,
directoryAcceptKey,
@@ -132,7 +133,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (draft) return draft.directory
if (!params.id) return
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
return selected().sync.session.lineage.peek(params.id)?.session.location.directory
return selected().sync.session.lineage.peek(params.id)?.session.directory
})
createEffect(() => {
@@ -255,7 +256,9 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}
const list = async (directory: string) => {
return input.sdk.api.permission.request.list({ location: { directory } }).then((result) => result.data)
return input.sdk.api.permission.request
.list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest))
}
function respondOnce(permission: PermissionRequest, directory?: string) {
+9 -5
View File
@@ -15,7 +15,7 @@ describe("resumeStreamAfterPageShow", () => {
})
describe("adaptServerEvent", () => {
test("preserves current permission requests", () => {
test("preserves current events while adapting permission requests for existing consumers", () => {
const current = {
id: "evt_1",
created: 1,
@@ -35,9 +35,9 @@ describe("adaptServerEvent", () => {
properties: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
permission: "read",
patterns: ["src/**"],
tool: { messageID: "msg_1", callID: "call_1" },
},
current,
})
@@ -87,7 +87,11 @@ describe("current event buffering", () => {
test("preserves boundaries between distinct delta streams", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual([
"evt_1",
"evt_2",
"evt_3",
])
})
test("preserves current event order when enqueuing", () => {
+20 -1
View File
@@ -1,5 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@/types"
import type { Event, PermissionRequest } from "@/types"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -24,6 +24,25 @@ type CurrentDelta = Extract<
>
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
if (event.type === "permission.asked") {
return {
id: event.id,
type: "permission.asked",
properties: {
id: event.data.id,
sessionID: event.data.sessionID,
permission: event.data.action,
patterns: event.data.resources,
always: event.data.save ?? [],
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.id }
: undefined,
} satisfies PermissionRequest,
current: event,
}
}
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
@@ -153,88 +153,4 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
test("removes cancelled input from the pending promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_cancelled",
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ missing: "msg_user" })
})
test("keeps steered input available to the promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_steered",
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_queued",
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
})
})
@@ -29,9 +29,6 @@ export function createV2SessionReducer() {
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
@@ -8,19 +8,19 @@ import type {
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import type { Message, Part } from "@/types"
import type { Message, Part, Session } from "@/types"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
type MessageApi = ServerApi["message"]
const session = (id: string, parentID?: string): SessionInfo => ({
const session = (id: string, parentID?: string): Session => ({
id,
slug: id,
projectID: "project",
location: { directory: "/repo" },
directory: "/repo",
title: id,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
version: "1",
parentID,
time: { created: 1, updated: 1 },
})
@@ -35,8 +35,18 @@ type MessageResponse = {
}
type SingleMessageResponse = { data: MessageResponse["data"][number] }
function sessionInfo(value: SessionInfo): SessionInfo {
return value
function sessionInfo(value: Session): SessionInfo {
return {
id: value.id,
parentID: value.parentID,
projectID: value.projectID,
cost: value.cost ?? 0,
tokens: value.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: value.time,
title: value.title,
location: { directory: value.directory, workspaceID: value.workspaceID },
subpath: value.path,
}
}
function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] {
@@ -262,7 +272,7 @@ const retryImmediately: typeof retry = async (task, options = {}) => {
}
}
function setup(sessions: Record<string, SessionInfo>) {
function setup(sessions: Record<string, Session>) {
const get: unknown[] = []
const messages: unknown[] = []
const client = {
@@ -1677,7 +1687,7 @@ describe("server session", () => {
ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } })
ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
expect(ctx.store.get("root")?.location.directory).toBe("/repo")
expect(ctx.store.get("root")?.directory).toBe("/repo")
expect(ctx.store.data.session_working("root")).toBe(true)
expect(ctx.get).toEqual([])
})
+17 -12
View File
@@ -1,15 +1,20 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
Message,
Part,
PermissionRequest,
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { rootSession } from "@/utils/session-route"
import { normalizeSessionInfo } from "@/utils/session"
import { normalizeSessionMessages } from "@/utils/session-message"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
@@ -187,7 +192,7 @@ export function createServerSession(
const messageApi = bundled ? api.message : (messageApiOrOptions as MessageApi)
const options = bundled ? (messageApiOrOptions as ServerSessionOptions | undefined) : currentOptions
const [data, setData] = createStore({
info: {} as Record<string, SessionInfo | undefined>,
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, FileDiffInfo[]>,
todo: {} as Record<string, Todo[]>,
@@ -201,7 +206,7 @@ export function createServerSession(
return (this.session_status[id]?.type ?? "idle") !== "idle"
},
})
const requests = new Map<string, Promise<SessionInfo>>()
const requests = new Map<string, Promise<Session>>()
const inflight = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
@@ -250,7 +255,7 @@ export function createServerSession(
)
}
const remember = (session: SessionInfo) => {
const remember = (session: Session) => {
setData("info", session.id, reconcile(session))
infoSeen.delete(session.id)
infoSeen.add(session.id)
@@ -300,7 +305,7 @@ export function createServerSession(
const pending = requests.get(sessionID)
if (pending) return pending
const active = generation(sessionID)
const request = sessionApi.get({ sessionID })
const request = sessionApi.get({ sessionID }).then(normalizeSessionInfo)
const resolved = request.then((result) => {
if (generations.get(sessionID) !== active) return result
return remember(result)
@@ -916,8 +921,9 @@ export function createServerSession(
remember({
...info,
projectID: event.data.projectID ?? info.projectID,
location: event.data.location,
subpath: event.data.subpath,
workspaceID: event.data.location.workspaceID,
directory: event.data.location.directory,
path: event.data.subpath,
time: { ...info.time, updated: event.created },
})
if (event.type === "session.usage.updated" && info)
@@ -963,17 +969,16 @@ export function createServerSession(
}
switch (event.type) {
case "session.created":
if ((event.properties as { info?: SessionInfo }).info)
remember((event.properties as { info: SessionInfo }).info)
remember((event.properties as { info: Session }).info)
return
case "session.updated": {
const info = (event.properties as { info: SessionInfo }).info
const info = (event.properties as { info: Session }).info
remember(info)
if (info.time.archived) evict([info.id])
return
}
case "session.deleted": {
const properties = event.properties as { sessionID?: string; info?: SessionInfo }
const properties = event.properties as { sessionID?: string; info?: Session }
const sessionID = properties.info?.id ?? properties.sessionID
if (!sessionID) return
infoSeen.delete(sessionID)

Some files were not shown because too many files have changed in this diff Show More