mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d06546b7ea | |||
| 2893d3f55c | |||
| aee34c9383 | |||
| 5d351406a1 | |||
| 86d90eb4ef | |||
| 31d1fae1e7 |
@@ -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:",
|
||||
@@ -489,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",
|
||||
@@ -2055,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"],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -155,7 +155,7 @@ function LegacyTargetSessionRedirect() {
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const directory = current()?.session.location.directory
|
||||
const directory = current()?.session.directory
|
||||
if (!directory) return
|
||||
navigate(legacySessionHref(directory, params.id), { replace: true })
|
||||
})
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -96,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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) =>
|
||||
({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("pickDirectoriesToEvict", () => {
|
||||
})
|
||||
|
||||
describe("loadRootSessions", () => {
|
||||
test("loads a limited page of root sessions", async () => {
|
||||
test("loads and normalizes a limited page of root sessions", async () => {
|
||||
const calls: SessionListInput[] = []
|
||||
|
||||
const result = await loadRootSessions({
|
||||
@@ -137,7 +137,9 @@ describe("loadRootSessions", () => {
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([expect.objectContaining({ id: "session-1", location: { directory: "dir" } })])
|
||||
expect(result.data).toEqual([
|
||||
expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
|
||||
])
|
||||
expect(result.limited).toBe(true)
|
||||
expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
SessionStatus,
|
||||
} from "@/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
@@ -54,7 +55,6 @@ import type {
|
||||
McpResourceCatalogOutput,
|
||||
McpServer,
|
||||
SessionActiveOutput,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
@@ -451,7 +451,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
}
|
||||
|
||||
const indexSession = (info: Parameters<typeof session.remember>[0]) => {
|
||||
const key = directoryKey(info.location.directory)
|
||||
const key = directoryKey(info.directory)
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
applyDirectoryEvent({
|
||||
@@ -476,24 +476,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (event.current?.type === "session.created")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
.then((info) => {
|
||||
if (!session.get(info.id)) return
|
||||
indexSession(info)
|
||||
homeSessions.apply({
|
||||
type: "session.created",
|
||||
properties: { sessionID: info.id, info },
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
if (event.current?.type === "session.deleted")
|
||||
homeSessions.apply({
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: event.current.data.sessionID },
|
||||
})
|
||||
if (event.type === "session.created" || event.type === "session.deleted") {
|
||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
|
||||
}
|
||||
homeSessions.refresh(event.type)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@/types"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||
@@ -45,7 +45,7 @@ export const tabHref = (tab: Tab) =>
|
||||
|
||||
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
|
||||
|
||||
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: SessionInfo) {
|
||||
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
|
||||
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
|
||||
}
|
||||
|
||||
@@ -349,11 +349,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
for (const key of removed) memory.remove(key)
|
||||
for (const key of removed) removeInfo(key)
|
||||
},
|
||||
rememberSessionInfo(tab: SessionTab, session: SessionInfo) {
|
||||
rememberSessionInfo(tab: SessionTab, session: Session) {
|
||||
const key = tabKey(tab)
|
||||
const next = { title: session.title, directory: session.location.directory }
|
||||
const next = { title: session.title, directory: session.directory }
|
||||
const current = info[key]
|
||||
console.log({ tab, session, current })
|
||||
if (current?.title === next.title && current.directory === next.directory) return
|
||||
setInfo(key, next)
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", location: { directory: "/workspace" } },
|
||||
session: { id: "ses_1", directory: "/workspace" },
|
||||
archive: async () => undefined,
|
||||
remove: () => {
|
||||
removed = true
|
||||
@@ -36,7 +36,7 @@ test("reports archive failures without removing the session", async () => {
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", location: { directory: "/workspace" } },
|
||||
session: { id: "ses_1", directory: "/workspace" },
|
||||
archive: async () => Promise.reject(failure),
|
||||
remove: () => {
|
||||
removed = true
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
type HomeSession = Pick<SessionInfo, "id" | "location">
|
||||
type HomeSession = {
|
||||
id: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
export async function archiveHomeSession(input: {
|
||||
server: ServerConnection.Key
|
||||
@@ -17,7 +19,7 @@ export async function archiveHomeSession(input: {
|
||||
input.remove()
|
||||
notifySessionTabsRemoved({
|
||||
server: input.server,
|
||||
directory: input.session.location.directory,
|
||||
directory: input.session.directory,
|
||||
sessionIDs: [input.session.id],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { serverName } from "@/context/server"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
import { sessionLabel } from "@/utils/session-title"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
@@ -24,7 +23,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
if (!value) return []
|
||||
return sessions.data
|
||||
.searchRecords()
|
||||
.filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
|
||||
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
|
||||
})
|
||||
const active = createMemo(() => {
|
||||
const records = results()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@/types"
|
||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
@@ -26,7 +26,7 @@ import type { HomeController } from "./home-controller"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
export type HomeSessionRecord = {
|
||||
session: SessionInfo
|
||||
session: Session
|
||||
project: LocalProject
|
||||
projectName: string
|
||||
}
|
||||
@@ -180,8 +180,8 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
server: () => home.selection.value().server,
|
||||
canCreate: () => !!home.project.newSession(),
|
||||
create: home.project.openNewSession,
|
||||
open: (session: SessionInfo, options?: OpenSessionOptions) => {
|
||||
const directoryKey = pathKey(session.location.directory)
|
||||
open: (session: Session, options?: OpenSessionOptions) => {
|
||||
const directoryKey = pathKey(session.directory)
|
||||
const project =
|
||||
home.project
|
||||
.list()
|
||||
@@ -192,7 +192,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
) ?? projectForSession(session, home.project.list(), projectByID())
|
||||
const conn = home.server.focused()
|
||||
if (!conn) return
|
||||
const directory = project?.worktree ?? session.location.directory
|
||||
const directory = project?.worktree ?? session.directory
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return
|
||||
ctx.projects.open(directory)
|
||||
@@ -206,11 +206,11 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
tabs.select(tab)
|
||||
})
|
||||
},
|
||||
archive: async (session: SessionInfo) => {
|
||||
archive: async (session: Session) => {
|
||||
const conn = home.server.focused()
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!conn || !ctx) return
|
||||
const [, setStore] = ctx.sync.child(session.location.directory)
|
||||
const [, setStore] = ctx.sync.child(session.directory)
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
session,
|
||||
@@ -243,17 +243,17 @@ function directories(project: LocalProject) {
|
||||
}
|
||||
|
||||
function buildHomeSessionRecords(input: {
|
||||
sessions: () => SessionInfo[]
|
||||
sessions: () => Session[]
|
||||
projectDirectories: () => string[]
|
||||
projects: () => LocalProject[]
|
||||
projectByID: () => Map<string, LocalProject>
|
||||
}) {
|
||||
const directories = new Set(input.projectDirectories().map(pathKey))
|
||||
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.location.directory)))
|
||||
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.flatMap((session) => {
|
||||
const directory = pathKey(session.location.directory)
|
||||
const directory = pathKey(session.directory)
|
||||
const project =
|
||||
input
|
||||
.projects()
|
||||
@@ -267,7 +267,7 @@ function buildHomeSessionRecords(input: {
|
||||
}
|
||||
|
||||
export function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.location.directory)}:${record.session.id}`
|
||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
||||
@@ -304,7 +304,7 @@ export function HomeSessionStatusController(props: {
|
||||
}) {
|
||||
const avatar = useSessionTabAvatarState(
|
||||
props.server,
|
||||
() => props.record.session.location.directory,
|
||||
() => props.record.session.directory,
|
||||
() => props.record.session.id,
|
||||
)
|
||||
return props.render({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@/types"
|
||||
import { type Accessor, createMemo, For, Show } from "solid-js"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
@@ -9,7 +9,7 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
||||
import { sessionLabel } from "@/utils/session-title"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { shouldOpenSessionInBackground } from "../home-session-open"
|
||||
import {
|
||||
HomeSessionStatusController,
|
||||
@@ -53,8 +53,8 @@ export type HomeSessionsViewProps = {
|
||||
titleOpacity: (id: HomeSessionGroup["id"]) => number
|
||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||
onCreateSession: () => void
|
||||
onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void
|
||||
onArchiveSession: (session: SessionInfo) => Promise<void>
|
||||
onOpenSession: (session: Session, options?: OpenSessionOptions) => void
|
||||
onArchiveSession: (session: Session) => Promise<void>
|
||||
onSetHoverTarget: (element: HTMLElement) => void
|
||||
onSetThumbTrack: (element: HTMLDivElement) => void
|
||||
onSetContent: (element: HTMLDivElement) => void
|
||||
@@ -192,7 +192,7 @@ function HomeSessionLeading(props: {
|
||||
</Show>
|
||||
<SessionTabAvatarView
|
||||
project={props.record.project}
|
||||
directory={props.record.session.location.directory}
|
||||
directory={props.record.session.directory}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
unread={props.unread}
|
||||
loading={props.loading}
|
||||
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
|
||||
selected: boolean
|
||||
},
|
||||
) {
|
||||
const title = createMemo(() => sessionLabel(props.record.session))
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
|
||||
}
|
||||
|
||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||
const title = createMemo(() => sessionLabel(props.record.session))
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@/types"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
@@ -603,9 +603,9 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const currentSessions = createMemo(() => {
|
||||
const now = Date.now()
|
||||
const dirs = visibleSessionDirs()
|
||||
if (dirs.length === 0) return [] as SessionInfo[]
|
||||
if (dirs.length === 0) return [] as Session[]
|
||||
|
||||
const result: SessionInfo[] = []
|
||||
const result: Session[] = []
|
||||
for (const dir of dirs) {
|
||||
const [dirStore] = serverSync().child(dir, { bootstrap: true })
|
||||
const dirSessions = sortedRootSessions(dirStore, now)
|
||||
@@ -717,8 +717,8 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const prefetchSession = (session: SessionInfo, priority: "high" | "low" = "low") => {
|
||||
const directory = session.location.directory
|
||||
const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
|
||||
const directory = session.directory
|
||||
if (!directory) return
|
||||
|
||||
const cached = untrack(() => !serverSync().session.shouldPrefetch(session.id, prefetchChunk))
|
||||
@@ -753,7 +753,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
pumpPrefetch(directory)
|
||||
}
|
||||
|
||||
const warm = (sessions: SessionInfo[], index: number) => {
|
||||
const warm = (sessions: Session[], index: number) => {
|
||||
for (let offset = 1; offset <= span; offset++) {
|
||||
const next = sessions[index + offset]
|
||||
if (next) prefetchSession(next, offset === 1 ? "high" : "low")
|
||||
@@ -855,11 +855,11 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveSession(session: SessionInfo) {
|
||||
async function archiveSession(session: Session) {
|
||||
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
||||
void session
|
||||
return
|
||||
const [store, setStore] = serverSync().child(session.location.directory)
|
||||
const [store, setStore] = serverSync().child(session.directory)
|
||||
const sessions = store.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||
@@ -1192,10 +1192,10 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
.sync(target.id)
|
||||
.then(() => sync.session.get(target.id))
|
||||
.catch(() => undefined)
|
||||
if (!resolved?.location.directory) return false
|
||||
if (!canOpen(resolved.location.directory)) return false
|
||||
setStore("lastProjectSession", root, { directory: resolved.location.directory, id: resolved.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(resolved.location.directory)}/session/${resolved.id}`)
|
||||
if (!resolved?.directory) return false
|
||||
if (!canOpen(resolved.directory)) return false
|
||||
setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1211,7 +1211,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]),
|
||||
Date.now(),
|
||||
)
|
||||
if (latest && (await openSession({ directory: latest.location.directory, id: latest.id }))) {
|
||||
if (latest && (await openSession(latest))) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1228,16 +1228,16 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
),
|
||||
Date.now(),
|
||||
)
|
||||
if (fetched && (await openSession({ directory: fetched.location.directory, id: fetched.id }))) {
|
||||
if (fetched && (await openSession(fetched))) {
|
||||
return
|
||||
}
|
||||
|
||||
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||
}
|
||||
|
||||
function navigateToSession(session: SessionInfo | undefined) {
|
||||
function navigateToSession(session: Session | undefined) {
|
||||
if (!session) return
|
||||
navigateWithSidebarReset(`/${base64Encode(session.location.directory)}/session/${session.id}`)
|
||||
navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
|
||||
}
|
||||
|
||||
function openProject(directory: string, navigate = true) {
|
||||
@@ -1540,7 +1540,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const [state, setState] = createStore({
|
||||
status: "loading" as "loading" | "ready" | "error",
|
||||
dirty: false,
|
||||
sessions: [] as SessionInfo[],
|
||||
sessions: [] as Session[],
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
parseDeepLink,
|
||||
parseNewSessionDeepLink,
|
||||
} from "./deep-links"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@/types"
|
||||
import {
|
||||
childSessionOnPath,
|
||||
closeHomeProject,
|
||||
@@ -25,18 +25,16 @@ import { ServerConnection } from "@/context/server"
|
||||
|
||||
const serverKey = ServerConnection.Key.make
|
||||
|
||||
const session = (input: Partial<SessionInfo> & Pick<SessionInfo, "id"> & { directory: string }) =>
|
||||
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
||||
({
|
||||
projectID: "project",
|
||||
title: "",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
version: "v2",
|
||||
parentID: undefined,
|
||||
messageCount: 0,
|
||||
permissions: { session: {}, share: {} },
|
||||
time: { created: 0, updated: 0, archived: undefined },
|
||||
...input,
|
||||
location: { directory: input.directory },
|
||||
directory: undefined,
|
||||
}) as SessionInfo
|
||||
}) as Session
|
||||
|
||||
describe("layout deep links", () => {
|
||||
test("parses open-project deep links", () => {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@/types"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import type { HomeProjectSelection } from "@/context/layout"
|
||||
|
||||
type SessionStore = {
|
||||
session?: SessionInfo[]
|
||||
session?: Session[]
|
||||
path: { directory: string }
|
||||
}
|
||||
|
||||
function sortSessions(now: number) {
|
||||
const oneMinuteAgo = now - 60 * 1000
|
||||
return (a: SessionInfo, b: SessionInfo) => {
|
||||
return (a: Session, b: Session) => {
|
||||
const aUpdated = a.time.updated ?? a.time.created
|
||||
const bUpdated = b.time.updated ?? b.time.created
|
||||
const aRecent = aUpdated > oneMinuteAgo
|
||||
@@ -23,8 +23,8 @@ function sortSessions(now: number) {
|
||||
}
|
||||
}
|
||||
|
||||
const isRootVisibleSession = (session: SessionInfo, directory: string) =>
|
||||
pathKey(session.location.directory) === pathKey(directory) && !session.parentID && !session.time.archived
|
||||
const isRootVisibleSession = (session: Session, directory: string) =>
|
||||
pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived
|
||||
|
||||
export const roots = (store: SessionStore) =>
|
||||
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
|
||||
@@ -41,7 +41,7 @@ export function hasProjectPermissions<T>(
|
||||
return Object.values(request ?? {}).some((list) => list?.some(include))
|
||||
}
|
||||
|
||||
export const childSessionOnPath = (sessions: SessionInfo[] | undefined, rootID: string, activeID?: string) => {
|
||||
export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
|
||||
if (!activeID || activeID === rootID) return
|
||||
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
|
||||
let id = activeID
|
||||
@@ -102,13 +102,13 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
|
||||
}
|
||||
|
||||
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
|
||||
session: SessionInfo,
|
||||
session: Session,
|
||||
projects: T[],
|
||||
byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
) {
|
||||
const direct = byID.get(session.projectID)
|
||||
if (direct) return direct
|
||||
const directory = pathKey(session.location.directory)
|
||||
const directory = pathKey(session.directory)
|
||||
return projects.find(
|
||||
(project) =>
|
||||
pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@/types"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
@@ -14,7 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionLabel } from "@/utils/session-title"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ProjectIcon = (props: {
|
||||
const hasPermissions = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
return hasProjectPermissions(serverSync().session.data.permission, (item) => {
|
||||
if (serverSync().session.get(item.sessionID)?.location.directory !== directory) return false
|
||||
if (serverSync().session.get(item.sessionID)?.directory !== directory) return false
|
||||
return !permission.autoResponds(item, directory)
|
||||
})
|
||||
}),
|
||||
@@ -74,9 +74,9 @@ export const ProjectIcon = (props: {
|
||||
}
|
||||
|
||||
export type SessionItemProps = {
|
||||
session: SessionInfo
|
||||
list: SessionInfo[]
|
||||
navList?: Accessor<SessionInfo[]>
|
||||
session: Session
|
||||
list: Session[]
|
||||
navList?: Accessor<Session[]>
|
||||
slug: string
|
||||
mobile?: boolean
|
||||
dense?: boolean
|
||||
@@ -85,12 +85,12 @@ export type SessionItemProps = {
|
||||
level?: number
|
||||
sidebarExpanded: Accessor<boolean>
|
||||
clearHoverProjectSoon: () => void
|
||||
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
|
||||
archiveSession: (session: SessionInfo) => Promise<void>
|
||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||
archiveSession: (session: Session) => Promise<void>
|
||||
}
|
||||
|
||||
const SessionRow = (props: {
|
||||
session: SessionInfo
|
||||
session: Session
|
||||
slug: string
|
||||
mobile?: boolean
|
||||
dense?: boolean
|
||||
@@ -104,7 +104,7 @@ const SessionRow = (props: {
|
||||
warmPress: () => void
|
||||
warmFocus: () => void
|
||||
}): JSX.Element => {
|
||||
const title = () => sessionLabel(props.session)
|
||||
const title = () => sessionTitle(props.session.title)
|
||||
|
||||
return (
|
||||
<A
|
||||
@@ -152,14 +152,14 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const serverSync = useServerSync()
|
||||
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
|
||||
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
|
||||
const [sessionStore] = serverSync().child(props.session.location.directory)
|
||||
const [sessionStore] = serverSync().child(props.session.directory)
|
||||
const hasPermissions = createMemo(() => {
|
||||
return !!sessionPermissionRequest(
|
||||
sessionStore.session,
|
||||
serverSync().session.data.permission,
|
||||
props.session.id,
|
||||
(item) => {
|
||||
return !permission.autoResponds(item, props.session.location.directory)
|
||||
return !permission.autoResponds(item, props.session.directory)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -179,17 +179,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
|
||||
const warm = (span: number, priority: "high" | "low") => {
|
||||
const nav = props.navList?.()
|
||||
const list = nav?.some(
|
||||
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
|
||||
)
|
||||
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
|
||||
? nav
|
||||
: props.list
|
||||
|
||||
props.prefetchSession(props.session, priority)
|
||||
|
||||
const idx = list.findIndex(
|
||||
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
|
||||
)
|
||||
const idx = list.findIndex((item) => item.id === props.session.id && item.directory === props.session.directory)
|
||||
if (idx === -1) return
|
||||
|
||||
for (let step = 1; step <= span; step++) {
|
||||
@@ -233,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={sessionLabel(props.session)}
|
||||
value={sessionTitle(props.session.title)}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
|
||||
@@ -305,7 +305,7 @@ export const SortableProject = (props: {
|
||||
const isWorking = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
return Object.keys(serverSync().session.data.session_status).some((id) => {
|
||||
if (serverSync().session.get(id)?.location.directory !== directory) return false
|
||||
if (serverSync().session.get(id)?.directory !== directory) return false
|
||||
return serverSync().session.data.session_working(id)
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -14,7 +14,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@/types"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { useServerSync, useQueryOptions } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -36,12 +36,12 @@ type InlineEditorComponent = (props: {
|
||||
|
||||
export type WorkspaceSidebarContext = {
|
||||
currentDir: Accessor<string>
|
||||
navList: Accessor<SessionInfo[]>
|
||||
navList: Accessor<Session[]>
|
||||
sidebarExpanded: Accessor<boolean>
|
||||
sidebarHovering: Accessor<boolean>
|
||||
clearHoverProjectSoon: () => void
|
||||
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
|
||||
archiveSession: (session: SessionInfo) => Promise<void>
|
||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||
archiveSession: (session: Session) => Promise<void>
|
||||
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
||||
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
||||
editorOpen: (id: string) => boolean
|
||||
@@ -243,7 +243,7 @@ const WorkspaceSessionList = (props: {
|
||||
ctx: WorkspaceSidebarContext
|
||||
showNew: Accessor<boolean>
|
||||
loading: Accessor<boolean>
|
||||
sessions: Accessor<SessionInfo[]>
|
||||
sessions: Accessor<Session[]>
|
||||
hasMore: Accessor<boolean>
|
||||
loadMore: () => Promise<void>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FilePart, Project, UserMessage } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { FilePart, Project, UserMessage, VcsFileDiff } from "@/types"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
@@ -160,7 +159,7 @@ export function SessionPage() {
|
||||
export function TargetSessionRouteContent() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const serverSync = useServerSync()
|
||||
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.location.directory)
|
||||
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
|
||||
return (
|
||||
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
||||
// when session content falls back to the route error boundary.
|
||||
@@ -253,7 +252,7 @@ function ResolvedTargetSessionRoute() {
|
||||
() => params.id,
|
||||
() => sync().session.lineage,
|
||||
)
|
||||
const directory = createMemo(() => current()?.session.location.directory)
|
||||
const directory = createMemo(() => current()?.session.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
@@ -719,13 +718,13 @@ export default function Page() {
|
||||
if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending
|
||||
return true
|
||||
}
|
||||
const loadReviewDiff = async (file: string, version?: number): Promise<FileDiffInfo | undefined> => {
|
||||
const loadReviewDiff = async (file: string, version?: number): Promise<VcsFileDiff | undefined> => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory)
|
||||
const directory = reviewDiffDirectory(root, file)
|
||||
const source = reviewDiffs().find((diff) => diff.file === file)
|
||||
const valid = (diff: FileDiffInfo | undefined) => {
|
||||
const valid = (diff: VcsFileDiff | undefined) => {
|
||||
if (!diff || !source) return
|
||||
if (diff.additions !== source.additions || diff.deletions !== source.deletions) return
|
||||
if (reviewDiffNeedsLoad(diff)) return
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@/types"
|
||||
import { todoDockAtBoundary, todoState } from "./session-composer-state"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
@@ -7,7 +7,7 @@ const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
}) as SessionInfo
|
||||
}) as Session
|
||||
|
||||
const permission = (id: string, sessionID: string) =>
|
||||
({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@/types"
|
||||
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
|
||||
import type { PermissionRequest, QuestionRequest, Todo } from "@/types"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import type { PermissionRequest } from "@opencode-ai/client/promise"
|
||||
import type { PermissionRequest } from "@/types"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -13,7 +13,7 @@ export function SessionPermissionDock(props: {
|
||||
const language = useLanguage()
|
||||
|
||||
const toolDescription = () => {
|
||||
const key = `settings.permissions.tool.${props.request.action}.description`
|
||||
const key = `settings.permissions.tool.${props.request.permission}.description`
|
||||
const value = language.t(key as Parameters<typeof language.t>[0])
|
||||
if (value === key) return ""
|
||||
return value
|
||||
@@ -59,11 +59,11 @@ export function SessionPermissionDock(props: {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.request.resources.length > 0}>
|
||||
<Show when={props.request.patterns.length > 0}>
|
||||
<div data-slot="permission-row">
|
||||
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||
<div data-slot="permission-patterns">
|
||||
<For each={props.request.resources}>
|
||||
<For each={props.request.patterns}>
|
||||
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/client/promise"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@/types"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@/types"
|
||||
|
||||
function sessionTreeRequest<T>(
|
||||
session: SessionInfo[],
|
||||
session: Session[],
|
||||
request: Record<string, T[] | undefined>,
|
||||
sessionID?: string,
|
||||
include: (item: T) => boolean = () => true,
|
||||
@@ -35,7 +34,7 @@ function sessionTreeRequest<T>(
|
||||
}
|
||||
|
||||
export function sessionPermissionRequest(
|
||||
session: SessionInfo[],
|
||||
session: Session[],
|
||||
request: Record<string, PermissionRequest[] | undefined>,
|
||||
sessionID?: string,
|
||||
include?: (item: PermissionRequest) => boolean,
|
||||
@@ -44,7 +43,7 @@ export function sessionPermissionRequest(
|
||||
}
|
||||
|
||||
export function sessionQuestionRequest(
|
||||
session: SessionInfo[],
|
||||
session: Session[],
|
||||
request: Record<string, QuestionRequest[] | undefined>,
|
||||
sessionID?: string,
|
||||
include?: (item: QuestionRequest) => boolean,
|
||||
|
||||
@@ -22,6 +22,8 @@ type TabsInput = {
|
||||
fileBrowser?: Accessor<boolean>
|
||||
}
|
||||
|
||||
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
|
||||
|
||||
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
||||
return input.opened && input.visible
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||
import type {
|
||||
@@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments"
|
||||
|
||||
export type DiffStyle = "unified" | "split"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
export interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -56,8 +57,8 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
type RenderDiff = FileDiffInfo
|
||||
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
|
||||
type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: ReviewDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
|
||||
@@ -298,7 +298,7 @@ export function MessageTimeline(props: {
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = (): string | undefined => undefined
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
// TODO: Restore these actions when the V2 client exposes session sharing.
|
||||
// const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const shareEnabled = () => false
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { Window } from "happy-dom"
|
||||
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
|
||||
test("matches only the scroll element or an ancestor containing it", () => {
|
||||
@@ -17,15 +16,14 @@ test("matches only the scroll element or an ancestor containing it", () => {
|
||||
})
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const targetWindow = new Window()
|
||||
const route = targetWindow.document.createElement("section")
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
const unrelated = targetWindow.document.createElement("div")
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
const unrelated = document.createElement("div")
|
||||
route.append(viewport)
|
||||
targetWindow.document.body.append(route)
|
||||
document.body.append(route)
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow,
|
||||
targetWindow: window,
|
||||
scrollOffset: 79_400,
|
||||
options: {
|
||||
horizontal: false,
|
||||
@@ -40,24 +38,25 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
targetWindow.document.body.append(unrelated)
|
||||
document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2, targetWindow)
|
||||
await frames(2)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await waitFor(() => calls.length === 1, targetWindow)
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3, targetWindow)
|
||||
await frames(3)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
route.remove()
|
||||
})
|
||||
|
||||
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||
@@ -193,18 +192,8 @@ test("cleanup cancels reconnect checks and delegated offset observation", async
|
||||
route.remove()
|
||||
})
|
||||
|
||||
type FrameWindow = {
|
||||
requestAnimationFrame(callback: () => void): unknown
|
||||
performance: { now(): number }
|
||||
}
|
||||
|
||||
async function frames(count: number, targetWindow: FrameWindow = window) {
|
||||
async function frames(count: number) {
|
||||
for (let index = 0; index < count; index++) {
|
||||
await new Promise<void>((resolve) => targetWindow.requestAnimationFrame(() => resolve()))
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, targetWindow: FrameWindow = window) {
|
||||
const deadline = targetWindow.performance.now() + 1_000
|
||||
while (!condition() && targetWindow.performance.now() < deadline) await frames(1, targetWindow)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Message, Part, UserMessage } from "@/types"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@/types"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { Timeline, TimelineRow } from "./rows"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Part, UserMessage } from "@/types"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Part, SessionStatus, UserMessage } from "@/types"
|
||||
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
import { uniqueSummaryDiffs } from "./summary-diffs"
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { SnapshotFileDiff } from "@/types"
|
||||
import { uniqueSummaryDiffs } from "./summary-diffs"
|
||||
|
||||
const diff = (file: string, additions: number) =>
|
||||
({
|
||||
file,
|
||||
patch: "",
|
||||
additions,
|
||||
deletions: 0,
|
||||
status: "modified",
|
||||
}) satisfies FileDiffInfo
|
||||
}) satisfies SnapshotFileDiff
|
||||
|
||||
describe("uniqueSummaryDiffs", () => {
|
||||
test("drops entries without files and preserves unique input", () => {
|
||||
const alpha = diff("alpha.ts", 1)
|
||||
const beta = diff("beta.ts", 1)
|
||||
const invalid = { additions: 1, deletions: 0 } satisfies SnapshotFileDiff
|
||||
|
||||
expect(uniqueSummaryDiffs(undefined)).toEqual([])
|
||||
expect(uniqueSummaryDiffs([])).toEqual([])
|
||||
expect(uniqueSummaryDiffs([invalid])).toEqual([])
|
||||
|
||||
const result = uniqueSummaryDiffs([alpha, beta])
|
||||
const result = uniqueSummaryDiffs([alpha, invalid, beta])
|
||||
expect(result).toEqual([alpha, beta])
|
||||
expect(result[0]).toBe(alpha)
|
||||
expect(result[1]).toBe(beta)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { SnapshotFileDiff } from "@/types"
|
||||
import type { SummaryDiff } from "./timeline-row"
|
||||
|
||||
export function uniqueSummaryDiffs(diffs: FileDiffInfo[] | undefined) {
|
||||
export function uniqueSummaryDiffs(diffs: SnapshotFileDiff[] | undefined) {
|
||||
const files = new Set<string>()
|
||||
return (diffs ?? [])
|
||||
.reduceRight<SummaryDiff[]>((result, diff) => {
|
||||
@@ -15,6 +15,6 @@ export function uniqueSummaryDiffs(diffs: FileDiffInfo[] | undefined) {
|
||||
.reverse()
|
||||
}
|
||||
|
||||
function isSummaryDiff(diff: FileDiffInfo): diff is SummaryDiff {
|
||||
function isSummaryDiff(diff: SnapshotFileDiff): diff is SummaryDiff {
|
||||
return typeof diff.file === "string"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { SnapshotFileDiff } from "@/types"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { Data, Equal } from "effect"
|
||||
|
||||
export type SummaryDiff = FileDiffInfo
|
||||
export type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
|
||||
export namespace TimelineRow {
|
||||
export class TurnGap extends Data.TaggedClass("TurnGap")<{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { SessionStatus } from "@/types"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSessionLayout } from "./session-layout"
|
||||
|
||||
@@ -187,7 +187,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const existing = undefined
|
||||
const existing = info()?.share?.url
|
||||
if (existing) {
|
||||
await copyShare(existing, true)
|
||||
return
|
||||
|
||||
@@ -4,8 +4,8 @@ import { filterReviewFiles, reviewDiffDirectory, reviewDiffKinds, reviewDiffNeed
|
||||
describe("reviewDiffKinds", () => {
|
||||
test("maps file and directory kinds", () => {
|
||||
const kinds = reviewDiffKinds([
|
||||
{ file: "src/a.ts", patch: "", additions: 1, deletions: 0, status: "added" },
|
||||
{ file: "src/b.ts", patch: "", additions: 0, deletions: 2, status: "deleted" },
|
||||
{ file: "src/a.ts", additions: 1, deletions: 0, status: "added" },
|
||||
{ file: "src/b.ts", additions: 0, deletions: 2, status: "deleted" },
|
||||
])
|
||||
|
||||
expect(kinds.get("src/a.ts")).toBe("add")
|
||||
@@ -14,9 +14,7 @@ describe("reviewDiffKinds", () => {
|
||||
})
|
||||
|
||||
test("normalizes file and directory paths", () => {
|
||||
const kinds = reviewDiffKinds([
|
||||
{ file: "\\src//lib/a.ts/", patch: "", additions: 1, deletions: 1, status: "modified" },
|
||||
])
|
||||
const kinds = reviewDiffKinds([{ file: "\\src//lib/a.ts/", additions: 1, deletions: 1, status: "modified" }])
|
||||
|
||||
expect(kinds.get("src/lib/a.ts")).toBe("mix")
|
||||
expect(kinds.get("src/lib")).toBe("mix")
|
||||
@@ -38,7 +36,6 @@ describe("reviewDiffNeedsLoad", () => {
|
||||
file: "src/a.ts",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
status: "modified",
|
||||
patch: "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts",
|
||||
}),
|
||||
).toBe(true)
|
||||
@@ -50,13 +47,10 @@ describe("reviewDiffNeedsLoad", () => {
|
||||
file: "src/a.ts",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
status: "modified",
|
||||
patch: "@@ -0,0 +1 @@\n+value",
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
reviewDiffNeedsLoad({ file: "empty.txt", patch: "", additions: 0, deletions: 0, status: "modified" }),
|
||||
).toBe(false)
|
||||
expect(reviewDiffNeedsLoad({ file: "empty.txt", additions: 0, deletions: 0 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { Kind } from "@/components/file-tree-v2"
|
||||
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||
|
||||
export type RenderDiff = FileDiffInfo
|
||||
export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
export function normalizePath(p: string) {
|
||||
return normalizeFileTreeV2Path(p)
|
||||
}
|
||||
|
||||
export function filterRenderableDiff(value: FileDiffInfo): value is RenderDiff {
|
||||
export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
export type ReviewPanelV2Props = {
|
||||
title?: JSX.Element
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
FileDiffInfo,
|
||||
FileDiffLegacyInfo,
|
||||
ProjectListOutput,
|
||||
QuestionAnswer,
|
||||
QuestionInfo,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
SessionInfo,
|
||||
SessionNotFoundError,
|
||||
SessionStatus,
|
||||
SessionV1Info,
|
||||
SessionsResponse,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
|
||||
export type {
|
||||
QuestionAnswer,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
SessionNotFoundError,
|
||||
SessionStatus,
|
||||
}
|
||||
|
||||
export type Project = Omit<ProjectListOutput[number], "canonical"> & { worktree: string }
|
||||
export type Session = Omit<SessionV1Info, "title"> & { title: string }
|
||||
export type SessionV2Info = SessionInfo
|
||||
export type V2SessionListResponse = SessionsResponse
|
||||
export type SnapshotFileDiff = FileDiffLegacyInfo
|
||||
export type VcsFileDiff = FileDiffInfo
|
||||
|
||||
type CurrentEvent = EventSubscribeOutput extends infer Item
|
||||
? Item extends { type: infer Type extends string; data: infer Data }
|
||||
@@ -13,9 +36,21 @@ type CurrentEvent = EventSubscribeOutput extends infer Item
|
||||
: never
|
||||
: never
|
||||
|
||||
export type Event = CurrentEvent
|
||||
export type Event =
|
||||
| Exclude<CurrentEvent, { type: "permission.asked" }>
|
||||
| { type: "permission.asked"; properties: PermissionRequest }
|
||||
|
||||
export type EventSessionError = Extract<Event, { type: "session.execution.failed" }>
|
||||
export type EventSessionError = Extract<Event, { type: "session.error" }>
|
||||
|
||||
export type PermissionRequest = {
|
||||
id: string
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: string[]
|
||||
metadata: Record<string, unknown>
|
||||
always: string[]
|
||||
tool?: { messageID: string; callID: string }
|
||||
}
|
||||
|
||||
type MessageError =
|
||||
| { name: "ProviderAuthError"; data: { providerID: string; message: string } }
|
||||
@@ -43,7 +78,7 @@ export type UserMessage = {
|
||||
role: "user"
|
||||
time: { created: number }
|
||||
format?: { type: "text" } | { type: "json_schema"; schema: Record<string, unknown>; retryCount?: number }
|
||||
summary?: { title?: string; body?: string; diffs: FileDiffInfo[] }
|
||||
summary?: { title?: string; body?: string; diffs: SnapshotFileDiff[] }
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
system?: string
|
||||
@@ -295,3 +330,9 @@ export type Config = {
|
||||
experimental?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type TextPartInput = Omit<TextPart, "id" | "sessionID" | "messageID"> & { id?: string }
|
||||
export type FilePartInput = Omit<FilePart, "id" | "sessionID" | "messageID"> & { id?: string }
|
||||
export type AgentPartInput = Omit<AgentPart, "id" | "sessionID" | "messageID"> & { id?: string }
|
||||
|
||||
export type Question = QuestionInfo
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SnapshotFileDiff } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { Message } from "@/types"
|
||||
import { diffs, message } from "./diffs"
|
||||
@@ -9,7 +10,7 @@ const item = {
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
} satisfies FileDiffInfo
|
||||
} satisfies FileDiffInfo & SnapshotFileDiff
|
||||
|
||||
describe("diffs", () => {
|
||||
test("keeps valid arrays", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { Message } from "@/types"
|
||||
|
||||
type Diff = FileDiffInfo
|
||||
type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
function diff(value: unknown): value is Diff {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotFoundError } from "@opencode-ai/client/promise"
|
||||
import type { SessionNotFoundError } from "@/types"
|
||||
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors"
|
||||
import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./server-errors"
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
export function sessionLabel(session: Pick<SessionInfo, "title" | "parentID">) {
|
||||
return displayLabel(session)
|
||||
}
|
||||
|
||||
export function sessionTitle(title?: string) {
|
||||
if (!title) return title
|
||||
const match = title.match(pattern)
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import { listAllSessions } from "./session"
|
||||
import { listAllSessions, normalizeSessionInfo } from "./session"
|
||||
|
||||
describe("normalizeSessionInfo", () => {
|
||||
test("adapts a current session to the app session shape", () => {
|
||||
const result = normalizeSessionInfo({
|
||||
id: "session-1",
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: "New session",
|
||||
location: { directory: "/repo/worktree", workspaceID: "workspace-1" },
|
||||
subpath: "worktree",
|
||||
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] },
|
||||
} as SessionInfo)
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "session-1",
|
||||
slug: "session-1",
|
||||
projectID: "project-1",
|
||||
workspaceID: "workspace-1",
|
||||
directory: "/repo/worktree",
|
||||
path: "worktree",
|
||||
parentID: undefined,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
title: "New session",
|
||||
agent: "build",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
version: "",
|
||||
time: { created: 1, updated: 1 },
|
||||
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("listAllSessions", () => {
|
||||
test("loads every page in server order and retains the query", async () => {
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@/types"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
if (!("location" in input)) return input
|
||||
return {
|
||||
id: input.id,
|
||||
slug: input.id,
|
||||
projectID: input.projectID,
|
||||
workspaceID: input.location.workspaceID,
|
||||
directory: input.location.directory,
|
||||
path: input.subpath,
|
||||
parentID: input.parentID,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
title: withTimestampedFallback(input),
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
version: "",
|
||||
time: input.time,
|
||||
revert: input.revert && {
|
||||
messageID: input.revert.messageID,
|
||||
partID: input.revert.partID,
|
||||
snapshot: input.revert.snapshot,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAllSessions(api: Pick<SessionApi, "list">, input: Omit<SessionListInput, "cursor">) {
|
||||
const load = async (cursor?: string): Promise<SessionInfo[]> => {
|
||||
const load = async (cursor?: string): Promise<Session[]> => {
|
||||
const result = await api.list({ ...input, limit: input.limit ?? 100, cursor })
|
||||
const sessions = result.data
|
||||
const sessions = result.data.map(normalizeSessionInfo)
|
||||
if (result.data.length === 0 || !result.cursor.next) return sessions
|
||||
return [...sessions, ...(await load(result.cursor.next))]
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ for (const item of targets) {
|
||||
name: "parcel-watcher-binding",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
|
||||
contents: `export default () => require(${JSON.stringify(parcelWatcherPackage)})`,
|
||||
contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
|
||||
+38
-193
@@ -1,4 +1,4 @@
|
||||
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
@@ -37,34 +37,6 @@ export type TurnStart =
|
||||
| { readonly type: "skill"; readonly id: string }
|
||||
| { readonly type: "compaction"; readonly id: string }
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
|
||||
type ChildSessionUpdateBase = {
|
||||
readonly rootSessionId: string
|
||||
readonly childSessionId: string
|
||||
readonly parentSessionId: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
type ChildSessionEvent =
|
||||
| { readonly type: "update"; readonly update: SessionUpdate }
|
||||
| {
|
||||
readonly type: "status"
|
||||
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
|
||||
readonly error?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
|
||||
export type ChildSessionUpdate = ChildSessionUpdateBase & ChildSessionEvent
|
||||
|
||||
type ChildSession = {
|
||||
readonly id: string
|
||||
readonly parentID: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
function emptyToolState(): ToolState {
|
||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
||||
}
|
||||
@@ -78,13 +50,8 @@ export async function streamTurn(input: {
|
||||
readonly writeTextFile: boolean
|
||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||
readonly control: TurnControl
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
readonly connectionSignal?: AbortSignal
|
||||
readonly sessionSignal?: AbortSignal
|
||||
}): Promise<PromptResponse> {
|
||||
const streamController = new AbortController()
|
||||
const connectionAbort = () => streamController.abort()
|
||||
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
|
||||
const connected = await stream.next()
|
||||
if (connected.done) throw new Error("event stream disconnected before prompt admission")
|
||||
@@ -95,101 +62,47 @@ export async function streamTurn(input: {
|
||||
let finish: SessionMessageAssistant["finish"]
|
||||
let executionError: { readonly type: string; readonly message: string } | undefined
|
||||
const tools = new Map<string, ToolState>()
|
||||
const children = new Map<string, ChildSession>()
|
||||
const openChildren = new Set<string>()
|
||||
let handedOff = false
|
||||
|
||||
const notifyChild = async (child: ChildSession, value: ChildSessionEvent) => {
|
||||
if (!input.childSessionUpdate) return
|
||||
await input
|
||||
.childSessionUpdate({
|
||||
rootSessionId: input.sessionID,
|
||||
childSessionId: child.id,
|
||||
parentSessionId: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...value,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) =>
|
||||
input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
|
||||
|
||||
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
|
||||
const projected = child ? projectChildUpdate(value, child) : value
|
||||
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
|
||||
await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected })
|
||||
}
|
||||
if (child) await notifyChild(child, { type: "update", update: projected })
|
||||
}
|
||||
|
||||
const consume = async (mode: "turn" | "background") => {
|
||||
const consume = async () => {
|
||||
while (!streamController.signal.aborted) {
|
||||
const next = await stream.next()
|
||||
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
||||
const event = next.value
|
||||
if (event.type === "session.created") {
|
||||
const parentID = event.data.parentID
|
||||
if (!parentID) continue
|
||||
const parent = parentID === input.sessionID ? undefined : children.get(parentID)
|
||||
if ((mode === "turn" && parentID === input.sessionID) || parent) {
|
||||
const child = {
|
||||
id: event.data.sessionID,
|
||||
parentID,
|
||||
depth: parent ? parent.depth + 1 : 1,
|
||||
title: event.data.title,
|
||||
}
|
||||
children.set(child.id, child)
|
||||
openChildren.add(child.id)
|
||||
await notifyChild(child, { type: "status", status: "created" })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const eventSessionID = sessionIDFromEvent(event)
|
||||
const child = eventSessionID ? children.get(eventSessionID) : undefined
|
||||
const send = (update: SessionUpdate) => updateSession(update, child, mode)
|
||||
if (mode === "background" && !child) continue
|
||||
|
||||
if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) {
|
||||
const tool = event.data.source?.id ? tools.get(toolKey(event.data.sessionID, event.data.source.id)) : undefined
|
||||
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
|
||||
const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined
|
||||
await replyPermission({
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
event,
|
||||
sessionID: event.data.sessionID,
|
||||
clientSessionID: input.sessionID,
|
||||
sessionID: input.sessionID,
|
||||
cwd: input.cwd,
|
||||
tool,
|
||||
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
|
||||
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
|
||||
await input.client.form
|
||||
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
|
||||
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
|
||||
.cancel({ sessionID: input.sessionID, formID: event.data.form.id })
|
||||
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
|
||||
continue
|
||||
}
|
||||
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
|
||||
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
|
||||
if (matchesStart(event, input.start)) {
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if (!started) continue
|
||||
|
||||
if (event.type === "session.execution.started") {
|
||||
if (child) {
|
||||
await notifyChild(child, { type: "status", status: "running" })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.step.started") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
await send({
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
await update({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
@@ -197,8 +110,8 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
await send({
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
await update({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
@@ -206,14 +119,9 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
tools.set(toolKey(event.data.sessionID, event.data.id), {
|
||||
name: event.data.name,
|
||||
input: {},
|
||||
metadata: {},
|
||||
content: [],
|
||||
})
|
||||
await send({
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] })
|
||||
await update({
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: event.data.id,
|
||||
@@ -225,12 +133,11 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
||||
current.input = event.data.input
|
||||
tools.set(key, current)
|
||||
await send({
|
||||
tools.set(event.data.id, current)
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
@@ -242,10 +149,10 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(toolKey(event.data.sessionID, event.data.id))
|
||||
const current = tools.get(event.data.id)
|
||||
if (!current) continue
|
||||
current.metadata = event.data.metadata
|
||||
await send({
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
@@ -257,9 +164,8 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.success") {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
tools.delete(key)
|
||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
||||
tools.delete(event.data.id)
|
||||
await syncEditedFiles({
|
||||
connection: input.connection,
|
||||
writeTextFile: input.writeTextFile,
|
||||
@@ -269,7 +175,7 @@ export async function streamTurn(input: {
|
||||
toolInput: current.input,
|
||||
metadata: event.data.metadata ?? {},
|
||||
}).catch(() => {})
|
||||
await send({
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
@@ -282,10 +188,9 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.failed") {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
tools.delete(key)
|
||||
await send({
|
||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
||||
tools.delete(event.data.id)
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
@@ -300,33 +205,13 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.step.ended") {
|
||||
if (!child) {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
finish = event.data.finish
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.succeeded") {
|
||||
if (!child) return "succeeded" as const
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "completed" })
|
||||
if (mode === "background" && openChildren.size === 0) return "succeeded" as const
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.interrupted") {
|
||||
if (!child) return "interrupted" as const
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "interrupted" })
|
||||
if (mode === "background" && openChildren.size === 0) return "interrupted" as const
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
finish = event.data.finish
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.succeeded") return "succeeded" as const
|
||||
if (event.type === "session.execution.interrupted") return "interrupted" as const
|
||||
if (event.type === "session.execution.failed") {
|
||||
if (child) {
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "failed", error: event.data.error })
|
||||
if (mode === "background" && openChildren.size === 0) return "failed" as const
|
||||
continue
|
||||
}
|
||||
executionError = event.data.error
|
||||
return "failed" as const
|
||||
}
|
||||
@@ -334,13 +219,7 @@ export async function streamTurn(input: {
|
||||
return "interrupted" as const
|
||||
}
|
||||
|
||||
const completed = consume("turn")
|
||||
const closeStream = async () => {
|
||||
streamController.abort()
|
||||
input.connectionSignal?.removeEventListener("abort", connectionAbort)
|
||||
input.sessionSignal?.removeEventListener("abort", connectionAbort)
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
}
|
||||
const completed = consume()
|
||||
try {
|
||||
await input.submit(control.admission.signal).catch((error) => {
|
||||
if (!control.cancelled) throw error
|
||||
@@ -354,13 +233,6 @@ export async function streamTurn(input: {
|
||||
}
|
||||
}
|
||||
const terminal = await completed
|
||||
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
|
||||
handedOff = true
|
||||
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||
void consume("background")
|
||||
.catch(() => {})
|
||||
.finally(closeStream)
|
||||
}
|
||||
const assistant = assistantMessageID
|
||||
? await input.client.session
|
||||
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
|
||||
@@ -378,38 +250,11 @@ export async function streamTurn(input: {
|
||||
await completed.catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
if (!handedOff) await closeStream()
|
||||
streamController.abort()
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function sessionIDFromEvent(event: EventSubscribeOutput) {
|
||||
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
|
||||
if (event.type === "form.created") return event.data.form.sessionID
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toolKey(sessionID: string, id: string) {
|
||||
return `${sessionID}:${id}`
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = {
|
||||
...projected._meta,
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
export async function replayMessages(
|
||||
connection: Pick<AgentSideConnection, "sessionUpdate">,
|
||||
sessionID: string,
|
||||
|
||||
@@ -20,28 +20,20 @@ export async function replyPermission(input: {
|
||||
readonly connection: Connection
|
||||
readonly event: PermissionEvent
|
||||
readonly sessionID: string
|
||||
readonly clientSessionID?: string
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly toolCallPrefix?: string
|
||||
readonly titlePrefix?: string
|
||||
}) {
|
||||
const toolName = input.tool?.name ?? input.event.data.action
|
||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
|
||||
const toolCallID = input.event.data.source?.id ?? input.event.data.id
|
||||
const title = permissionTitle(toolName, toolInput, previews)
|
||||
const result = await input.connection
|
||||
.requestPermission({
|
||||
sessionId: input.clientSessionID ?? input.sessionID,
|
||||
sessionId: input.sessionID,
|
||||
toolCall: {
|
||||
...pendingToolCall({
|
||||
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
|
||||
toolCallId: input.event.data.source?.id ?? input.event.data.id,
|
||||
toolName,
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: prefixedTitle(input.titlePrefix, title),
|
||||
},
|
||||
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
||||
@@ -59,12 +51,6 @@ export async function replyPermission(input: {
|
||||
})
|
||||
}
|
||||
|
||||
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
if (!prefix) return title
|
||||
if (!title) return prefix
|
||||
return `${prefix}: ${title}`
|
||||
}
|
||||
|
||||
export async function syncEditedFiles(input: {
|
||||
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||
readonly writeTextFile: boolean
|
||||
|
||||
@@ -43,21 +43,13 @@ import { OPENCODE_VERSION } from "../version"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
|
||||
import { promptContentToParts } from "./content"
|
||||
import {
|
||||
ChildSessionUpdateMethod,
|
||||
ChildSessionUpdatesCapability,
|
||||
replayMessages,
|
||||
streamTurn,
|
||||
type ChildSessionUpdate,
|
||||
type TurnControl,
|
||||
type TurnStart,
|
||||
} from "./event"
|
||||
import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
||||
Partial<Pick<AgentSideConnection, "writeTextFile" | "extNotification" | "signal">>
|
||||
Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||
|
||||
type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
@@ -72,7 +64,6 @@ type Catalog = {
|
||||
type Attached = {
|
||||
readonly id: string
|
||||
readonly cwd: string
|
||||
readonly abort: AbortController
|
||||
catalog: Catalog
|
||||
model: ModelRef
|
||||
modeID: string
|
||||
@@ -109,7 +100,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
const catalogs = new Map<string, Promise<Catalog>>()
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const active = new Map<string, TurnControl>()
|
||||
const capabilities = { writeTextFile: false, childSessionUpdates: false }
|
||||
const capabilities = { writeTextFile: false }
|
||||
|
||||
const catalog = (cwd: string) => {
|
||||
const cached = catalogs.get(cwd)
|
||||
@@ -128,19 +119,11 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
|
||||
}
|
||||
|
||||
const detach = (sessionID: string) => {
|
||||
sessions.get(sessionID)?.abort.abort()
|
||||
sessions.delete(sessionID)
|
||||
registeredMcp.delete(sessionID)
|
||||
}
|
||||
|
||||
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
|
||||
const currentCatalog = await catalog(cwd)
|
||||
sessions.get(session.id)?.abort.abort()
|
||||
const state: Attached = {
|
||||
id: session.id,
|
||||
cwd,
|
||||
abort: new AbortController(),
|
||||
catalog: currentCatalog,
|
||||
model: session.model ?? currentCatalog.defaultModel,
|
||||
modeID: session.agent ?? currentCatalog.defaultModeID,
|
||||
@@ -178,7 +161,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
return {
|
||||
initialize: async (params) => {
|
||||
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
||||
capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
@@ -196,7 +178,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
mcpCapabilities: { http: true, sse: false },
|
||||
promptCapabilities: { embeddedContext: true, image: true },
|
||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
_meta: { [ChildSessionUpdatesCapability]: true },
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||
@@ -243,7 +224,8 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
})
|
||||
detach(params.sessionId)
|
||||
sessions.delete(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
return {}
|
||||
},
|
||||
resumeSession: async (params) => {
|
||||
@@ -252,7 +234,8 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
return { configOptions: configOptions(state) }
|
||||
},
|
||||
closeSession: async (params) => {
|
||||
detach(params.sessionId)
|
||||
sessions.delete(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
const turn = active.get(params.sessionId)
|
||||
if (turn) {
|
||||
turn.cancelled = true
|
||||
@@ -313,11 +296,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const extNotification = input.connection.extNotification
|
||||
const childSessionUpdate =
|
||||
capabilities.childSessionUpdates && extNotification
|
||||
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
|
||||
: undefined
|
||||
active.set(state.id, control)
|
||||
const response = await streamTurn({
|
||||
client: input.client,
|
||||
@@ -327,10 +305,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
start: prepared.start,
|
||||
writeTextFile: capabilities.writeTextFile,
|
||||
control,
|
||||
connectionSignal: input.connection.signal,
|
||||
sessionSignal: state.abort.signal,
|
||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||
...(childSessionUpdate ? { childSessionUpdate } : {}),
|
||||
}).finally(() => {
|
||||
if (active.get(state.id) === control) active.delete(state.id)
|
||||
})
|
||||
|
||||
@@ -84,10 +84,10 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
Spec.make("auth", {
|
||||
description: "Manage authentication",
|
||||
commands: [
|
||||
Spec.make("login", {
|
||||
description: "Log in to a well-known authentication provider",
|
||||
Spec.make("connect", {
|
||||
description: "Connect to a wellknown authentication provider",
|
||||
params: {
|
||||
url: Argument.string("url").pipe(Argument.withDescription("Well-known provider URL")),
|
||||
url: Argument.string("url").pipe(Argument.withDescription("Wellknown provider URL")),
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -133,10 +133,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
description: "Manage plugins",
|
||||
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||
}),
|
||||
Spec.make("models", {
|
||||
description: "List all available models",
|
||||
params: ServerParams,
|
||||
}),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
|
||||
+4
-4
@@ -9,9 +9,9 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.login,
|
||||
Effect.fn("cli.auth.login")(function* (input) {
|
||||
process.stdout.write("Logging in..." + EOL + EOL)
|
||||
Commands.commands.auth.commands.connect,
|
||||
Effect.fn("cli.auth.connect")(function* (input) {
|
||||
process.stdout.write("Connecting..." + EOL + EOL)
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
yield* request(() => client.integration.wellknown.add({ url: input.url, location }))
|
||||
@@ -28,7 +28,7 @@ export default Runtime.handler(
|
||||
const status = yield* wait(client, integrationID, started.data.attemptID)
|
||||
if (status.status === "failed") return yield* Effect.fail(new Error(status.message))
|
||||
if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired"))
|
||||
process.stdout.write("Logged in" + EOL)
|
||||
process.stdout.write("Connected" + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect, Option } from "effect"
|
||||
import { EOL } from "node:os"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.models,
|
||||
Effect.fn("cli.models")(function* (input) {
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
const client = OpenCode.make({
|
||||
baseUrl: server.endpoint.url,
|
||||
headers: Service.headers(server.endpoint),
|
||||
})
|
||||
const response = yield* Effect.promise(() => client.model.list({ location: { directory: process.cwd() } }))
|
||||
const models = response.data
|
||||
.map((model) => `${model.providerID}/${model.id}`)
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
if (models.length > 0) process.stdout.write(models.join(EOL) + EOL)
|
||||
}),
|
||||
)
|
||||
@@ -18,7 +18,7 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
acp: () => import("./commands/handlers/acp"),
|
||||
api: () => import("./commands/handlers/api"),
|
||||
auth: {
|
||||
login: () => import("./commands/handlers/auth/login"),
|
||||
connect: () => import("./commands/handlers/auth/connect"),
|
||||
},
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
@@ -35,7 +35,6 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
plugin: {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
},
|
||||
models: () => import("./commands/handlers/models"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
pair: () => import("./commands/handlers/pair"),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
@@ -81,7 +81,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
database: {
|
||||
path:
|
||||
process.env.OPENCODE_DB ??
|
||||
(["latest", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
|
||||
(["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||
? "opencode.db"
|
||||
@@ -108,7 +108,9 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
gitbash: process.env.OPENCODE_GIT_BASH_PATH,
|
||||
},
|
||||
fs: {
|
||||
filewatcher: !truthy(process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER),
|
||||
filewatcher: !truthy(
|
||||
process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER,
|
||||
),
|
||||
fff:
|
||||
process.env.OPENCODE_DISABLE_FFF === undefined
|
||||
? process.platform !== "win32"
|
||||
@@ -126,6 +128,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
|
||||
Effect.catch((error) => {
|
||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { resolve } from "node:path"
|
||||
import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
|
||||
import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/event"
|
||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
@@ -191,181 +191,6 @@ describe("acp event behavior", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("projects foreground child session updates onto the parent turn", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_child",
|
||||
...childSession("ses_child", "ses_parent", "Explore code"),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
input: { path: "/workspace/src/index.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: "source" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_parent",
|
||||
inputID: "input_parent",
|
||||
})
|
||||
|
||||
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
|
||||
["ses_parent", "tool_call"],
|
||||
["ses_parent", "tool_call_update"],
|
||||
["ses_parent", "tool_call_update"],
|
||||
])
|
||||
expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([
|
||||
"ses_child:call_read",
|
||||
"ses_child:call_read",
|
||||
"ses_child:call_read",
|
||||
])
|
||||
expect(updates[0]?.update).toMatchObject({
|
||||
title: "Explore code: read",
|
||||
_meta: {
|
||||
"opencode/child-session": {
|
||||
id: "ses_child",
|
||||
parentID: "ses_parent",
|
||||
depth: 1,
|
||||
title: "Explore code",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("continues child extension updates after the parent turn ends", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const childUpdates: ChildSessionUpdate[] = []
|
||||
const completed = Promise.withResolvers<void>()
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_background",
|
||||
...childSession("ses_background", "ses_parent", "Background research"),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_parent",
|
||||
inputID: "input_parent",
|
||||
childSessionUpdate: async (update) => {
|
||||
childUpdates.push(update)
|
||||
if (update.type === "status" && update.status === "completed") completed.resolve()
|
||||
},
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
|
||||
fixture.send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_future",
|
||||
...childSession("ses_future", "ses_parent", "Later turn child"),
|
||||
}),
|
||||
)
|
||||
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
|
||||
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
|
||||
fixture.send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
name: "shell",
|
||||
}),
|
||||
)
|
||||
fixture.send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
input: { command: "pwd" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
fixture.send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "/workspace" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
|
||||
await withTimeout(completed.promise, "background child completion was not delivered")
|
||||
|
||||
expect(updates).toEqual([])
|
||||
expect(
|
||||
childUpdates.map((update) =>
|
||||
update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate],
|
||||
),
|
||||
).toEqual([
|
||||
["status", "created"],
|
||||
["status", "running"],
|
||||
["update", "tool_call"],
|
||||
["update", "tool_call_update"],
|
||||
["update", "tool_call_update"],
|
||||
["status", "completed"],
|
||||
])
|
||||
expect(childUpdates[2]).toMatchObject({
|
||||
rootSessionId: "ses_parent",
|
||||
childSessionId: "ses_background",
|
||||
parentSessionId: "ses_parent",
|
||||
depth: 1,
|
||||
title: "Background research",
|
||||
type: "update",
|
||||
update: { toolCallId: "ses_background:call_shell" },
|
||||
})
|
||||
expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("streams tool pending, progress, success, and failure updates", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
@@ -731,7 +556,6 @@ function turn(input: {
|
||||
readonly connection: Connection
|
||||
readonly sessionID: string
|
||||
readonly inputID: string
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
}) {
|
||||
return streamTurn({
|
||||
client: input.fixture.client,
|
||||
@@ -741,23 +565,11 @@ function turn(input: {
|
||||
start: { type: "input", id: input.inputID },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
childSessionUpdate: input.childSessionUpdate,
|
||||
submit: (signal) =>
|
||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
|
||||
})
|
||||
}
|
||||
|
||||
function childSession(id: string, parentID: string, title: string) {
|
||||
return {
|
||||
slug: id,
|
||||
projectID: "project",
|
||||
location: { directory: "/workspace" },
|
||||
parentID,
|
||||
title,
|
||||
version: "test",
|
||||
}
|
||||
}
|
||||
|
||||
function tokens() {
|
||||
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user