Merge remote-tracking branch 'origin/main' into port-2667
This commit is contained in:
@@ -18,7 +18,8 @@ There are **two distinct session data scopes** in the UI:
|
||||
- Holds:
|
||||
- global active sessions
|
||||
- global archived sessions
|
||||
- active sessions indexed by directory
|
||||
- active and archived entities indexed by ID
|
||||
- active root, parent/child, and directory indexes
|
||||
|
||||
These two scopes are intentionally different, but they are no longer equal peers for live UI truth.
|
||||
|
||||
@@ -43,11 +44,11 @@ So:
|
||||
|---|---|---|
|
||||
| `ChildStoreManager` and child directory stores | Priority-scheduled directory bootstrap plus `session`, `message`, `part`, `permission`, `question`, etc. | One runtime and one store per directory |
|
||||
| `SessionMessageLoader` | Initial message loading, pagination, prefetch, retries, load state, and optimistic reconciliation | One runtime, directory, and session ID |
|
||||
| `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots | All known directories in the active runtime |
|
||||
| `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots, plus a reference-stable active-ID membership collection maintained from the same mutations | All known directories in the active runtime |
|
||||
| `session-ordering.ts` | Ephemeral lifecycle rank used by every user-visible session list | All known sessions in the active runtime |
|
||||
| `session-activity-timing.ts` | Elapsed time of the running turn and of the turn that just finished, plus the persisted starts that survive a reload | All known sessions in the active runtime |
|
||||
| `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
|
||||
| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists |
|
||||
| `session-ui-store.ts` | Session selection, draft lifecycle, one-shot draft-materialization transition identity, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
|
||||
| `useGlobalSessionsStore.ts` | Global active/archived entities plus root, parent/child, and directory indexes | All opened project/worktree session lists |
|
||||
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
|
||||
| `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes |
|
||||
| `document-attachments.ts` | Bounded Office/OpenDocument extraction, document text serialization, embedded-image extraction, and positional citations | DOCX, PPTX, XLSX, ODT, ODP, and ODS chat attachments |
|
||||
@@ -57,12 +58,16 @@ So:
|
||||
|
||||
Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection.
|
||||
|
||||
Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 2,000,000 characters. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready.
|
||||
Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 500,000 characters so compact but dense Office files cannot consume an entire model context window. XLSX dense rows are serialized as quoted TSV under a single source range instead of repeating every cell address; highly sparse rows retain explicit cell coordinates so distant cells do not generate vast empty TSV spans. Confirmed Office/OpenDocument `@file` mentions are loaded through the runtime filesystem route before submit and use this same extraction pipeline instead of being forwarded as `text/plain` `file://` parts that OpenCode rejects as binary. A failed mention load or extraction leaves the composer intact, and a runtime switch discards preparation from the previous runtime. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready.
|
||||
|
||||
The composer compares normalized attachment MIME types with the selected model's declared input modalities. It warns when a newly attached file or an existing attachment after a model change requires an unsupported modality, but does not block sending. Missing modality metadata remains unknown and does not produce a warning.
|
||||
|
||||
## Session list rules
|
||||
|
||||
### Layout-mounted session-list lifecycle
|
||||
|
||||
`MainLayout` and `VSCodeLayout` each call `useSessionListSync({ isVSCode })` directly and unconditionally, outside Sidebar visibility, responsive, editor, settings, and compact-view branches. The hook selects the real topology inputs, publishes complete directory bootstrap demand through `ChildStoreManager`, refreshes topology additions (including all VS Code directories on its first mount), coalesces OpenChamber control events for 500ms, and supplies a memoized complete global active+archived input to authoritative cleanup. The root-level global poller owns the initial global refresh. MainLayout includes available worktrees; VS Code intentionally excludes them. Sidebar-local `session-created` worktree discovery is separate and full-app-only.
|
||||
|
||||
### Directory bootstrap scheduling
|
||||
|
||||
`ChildStoreManager` is the single owner of directory bootstrap scheduling. Consumers publish demand; they must not start bootstrap from row mount effects.
|
||||
@@ -72,9 +77,12 @@ The composer compares normalized attachment MIME types with the selected model's
|
||||
- Demand is deduplicated by normalized directory and can be promoted while queued.
|
||||
- The complete known project/worktree set is always published. Collapsed and off-screen directories remain background demand, so they refresh eventually rather than waiting for expansion.
|
||||
- A bootstrap holds its scheduler slot through critical state and the authoritative directory session-list fetch. Deferrable command/MCP/LSP/VCS/question/permission enrichment starts afterward without extending slot ownership or competing with the initial session-list request.
|
||||
- A system-resume signal, including Capacitor foreground resume, refreshes pending questions and permissions only for the active materialized directory. The refresh is deduplicated while in flight, preserves existing state on fetch failure, and leaves unopened directories untouched; normal stream reconnect recovery remains the broader catch-up path.
|
||||
- When a materialized current turn contains a pending/running question tool but that session's pending question record is missing, the mounted chat performs a question-only recovery scoped to that session. It tries at most three times with delays of 0, 500, and 1,500 ms, stops when the chat unmounts or changes sessions, and guards every attempt against runtime changes. This closes cold-start races without adding requests to ordinary session opens or scanning unrelated sessions and directories.
|
||||
- A mounted directory-store consumer pins that store for its lifetime. Eviction may dispose only unmounted directories, so optimistic actions and realtime events cannot move to a replacement store while visible React consumers remain subscribed to an older identity.
|
||||
- Reconfiguration and runtime switching invalidate stale generations. A stale completion must not publish state into the new runtime.
|
||||
- Failure is recorded as `failed`; it is not converted into a successful empty snapshot. Forced demand can retry failed or completed work.
|
||||
- A failed bootstrap is classified as `os-permission` only when the owning runtime filesystem API independently confirms `EPERM`/`EACCES` for that exact directory. OpenCode/proxy error text is never used as permission evidence. The scheduler retains the directory-scoped reason so local Desktop can offer native folder selection before a forced retry.
|
||||
|
||||
Bootstrap remains stale-while-revalidate: a directory store may paint persisted sessions immediately, but only a successful authoritative fetch may replace that cached list.
|
||||
|
||||
@@ -110,6 +118,16 @@ Session materialization recency is keyed by runtime and directory. Foreground lo
|
||||
|
||||
Use `useGlobalSessionsStore` when the UI needs a **shared global session cache**.
|
||||
|
||||
Each full app root owns one global polling lifecycle through
|
||||
`useGlobalSessionsPolling`. The web/desktop root and VS Code chat root load once
|
||||
when mounted and refresh every 45 seconds so sessions created by another
|
||||
OpenCode process are discovered without relying on the sidebar or native tray
|
||||
being visible. Embedded chats and the VS Code agent-manager panel do not poll.
|
||||
The sidebar and tray consume the same store and must not start their own
|
||||
full-list timers. Surface-specific refreshes, such as opening the mobile session
|
||||
sheet or returning from suspension, may still request freshness at their
|
||||
explicit lifecycle edge; the store coalesces an overlapping in-flight load.
|
||||
|
||||
Current consumers:
|
||||
|
||||
- `useSessionAutoCleanup.ts`
|
||||
@@ -179,8 +197,10 @@ Rules:
|
||||
6. Message and part materialization preserves references for unchanged records and maintains direct message-to-parts lookup. Consumers subscribe to the selected session's records rather than broad message/part containers.
|
||||
7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree.
|
||||
8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work.
|
||||
9. Transcript arrays are chronological by `message.time.created`, with message ID used only as a deterministic equal-time tie-breaker. Message IDs are identity and reconciliation keys, not chronology: OpenCode's fixed-width sortable timestamp prefix rolls over, so a newer `msg_000...` can follow an older `msg_fff...`. Fetch, pagination, materialization, optimistic insertion, events, reconnect inspection, rendering, and revert/undo/redo must preserve this contract.
|
||||
10. Part arrays preserve authoritative response/event order. Part IDs are identity keys and have the same rollover limitation; identity lookup/removal must not require a part array to be lexically ID-sorted.
|
||||
|
||||
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication.
|
||||
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering.
|
||||
|
||||
## Loading diagnostics
|
||||
|
||||
@@ -196,7 +216,7 @@ The profiler also emits a user-timing mark when pending global-session recency i
|
||||
|
||||
Streaming assistant and reasoning text is throttled once before reaching the markdown renderer. The renderer incrementally reconciles changed markdown blocks but does not add a second character-pacing timer, which would multiply parse/morph work while catching up on large streamed chunks.
|
||||
|
||||
The event pipeline delivers each ordered per-directory flush as one reducer batch. Events retain their individual global indexes, notifications, cleanup, routing, materialization, and debug side effects, while their directory mutations accumulate in order and publish one store transaction per touched directory. Each top-level state slice is cloned lazily at most once in that batch; no-op events do not change references.
|
||||
The event pipeline delivers each ordered per-directory flush as one reducer batch. Events retain their individual notifications, cleanup, routing, materialization, and debug side effects, while directory mutations accumulate in order and publish one store transaction per touched directory. Global session mutations and live status, ordering, and timing transitions also accumulate in event order and each owner publishes at most once for the flush. Each top-level state slice is cloned lazily at most once in that batch; no-op events do not change references.
|
||||
|
||||
Streaming lifecycle derivation has two paths. Directory attach, switch, bootstrap, and reconnect may perform a full reconciliation. Normal store publications reconcile only sessions whose `session_status` or `message` bucket changed; part-only events update the affected streaming message heartbeat directly and must not rescan all busy sessions.
|
||||
|
||||
@@ -208,7 +228,9 @@ Incomplete-session materialization is deduplicated by runtime, directory, and se
|
||||
|
||||
When `session.idle` or `session.error` settles a session but the trailing assistant message still contains a `pending` or `running` tool, sync refreshes that session tail. This narrowly reconciles a missed terminal tool-part event without refetching normally completed turns or stale tools from older turns. A stale refresh or delayed part event cannot regress a locally observed terminal tool to an active status.
|
||||
|
||||
Directory stores also own session-keyed sidecar notification channels for permissions and message materialization. High-frequency realtime part events annotate the exact session/message before committing, so visible records, user history, renderability, and sidebar permission rows are not notified by unrelated sessions. Structural message replacements notify only changed subscribed session buckets; unannotated bulk part replacement conservatively resets active message subscribers so bootstrap, pagination, rollback, and legacy writers cannot leave stale projections.
|
||||
When a session is authoritatively settled — `session.idle`/`session.error` event, or an authoritative status snapshot that lowers a previously busy session — and the trailing assistant message is still *unfinished* (`time.completed` missing) with no pending question/permission, the turn is treated as interrupted (managed OpenCode process died mid-turn; the server never finalizes the message or parts, see openchamber#2577 / anomalyco/opencode#19023). The unfinished assistant message is completed locally with `MessageAbortedError`, including text-only turns and turns whose tools had already finished, so the chat shows a visible interrupted state. Any active parts are also finalized as `error`/`Interrupted` with an end time, so tool timers stop and cards render the error state. The mark is gated on an explicit idle status (absent status is "unknown", never judged), never applies while the session is busy (including question/permission waits), and a later terminal event can supersede it while a stale unfinished refresh cannot regress the locally finalized message or parts.
|
||||
|
||||
Directory stores also own session-keyed sidecar notification channels for permissions, questions, and message materialization. High-frequency realtime part events annotate the exact session/message before committing, so visible records, user history, renderability, and sidebar permission and question rows are not notified by unrelated sessions. Structural message replacements notify only changed subscribed session buckets; unannotated bulk part replacement conservatively resets active message subscribers so bootstrap, pagination, rollback, and legacy writers cannot leave stale projections.
|
||||
|
||||
Message sidecar consumers also filter targeted updates by purpose before notifying React. Suspended live-tail text/reasoning changes do not rebuild visible message records, but structural Task session identity changes bypass suspension so a parent can link a newly created subagent immediately. Assistant-only part changes do not rebuild user input history, and targeted updates that preserve authoritative part buckets do not recheck a session that is already renderable. Message replacements, removed final part buckets, and conservative resets always notify.
|
||||
|
||||
@@ -246,8 +268,12 @@ Rules:
|
||||
2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct.
|
||||
3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls.
|
||||
4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected.
|
||||
5. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
|
||||
6. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
|
||||
5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime.
|
||||
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
|
||||
7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation.
|
||||
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
|
||||
9. `SessionLiveActivity` has three answers and `unknown` is never `idle`. `getSessionLiveActivity` reports `active` when any child store or the global session-status index holds a non-idle status, `idle` only when a child store actually covers the session's directory, and `unknown` otherwise — child stores are evicted for background directories, and the global index keeps only non-idle entries, so absence of a status is not proof of idleness. Callers that gate a destructive action (worktree moves) must refuse on `unknown`.
|
||||
10. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo.
|
||||
|
||||
Examples of global-store updates performed in `session-actions.ts`:
|
||||
|
||||
@@ -259,6 +285,10 @@ Examples of global-store updates performed in `session-actions.ts`:
|
||||
- `deleteSession()` / `deleteSessions()` -> wait for server confirmation or `404`, then remove the session and its persisted state
|
||||
- `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index
|
||||
|
||||
### Blocking-request (question/permission) reply routing
|
||||
|
||||
`respondToQuestion`, `rejectQuestion`, `respondToPermission`, and `dismissPermission` route the reply through `resolveDirectoryForBlockingRequest`. The directory chosen decides which OpenCode instance resolves the pending request, so it must be the **session record's own server-confirmed directory** (ownership), never the containing child-store key (containment): a project store legitimately holds its worktree sessions, and a reply addressed to the parent instance makes the server answer `QuestionNotFoundError` while the question stays pending in the worktree instance — the session is then stuck on the running question tool with no recovery. When a reply/reject comes back not-found, the stale request is removed locally and a `settled-running-tool` tail materialization is enqueued so the trailing tool part converges to the server's actual state instead of leaving the UI on "asking question" forever.
|
||||
|
||||
### Restore (unarchive) contract
|
||||
|
||||
The OpenCode server cannot clear `time.archived` over HTTP: `session.update`
|
||||
@@ -315,6 +345,16 @@ metadata and the next authoritative load reconciles it.
|
||||
|
||||
## The golden rule
|
||||
|
||||
### Managed chat directories
|
||||
|
||||
Ordinary user-created drafts default to the OpenChamber-managed Chat target. The first submit creates one isolated directory under `~/.config/openchamber/chats/YYYY-MM-DD/session-<id>` before creating the OpenCode session. The shared `~/.config/openchamber/chats` root acts as a system project owner for sidebar membership and Notes, Todo, Plans, pinned knowledge, and project memory, but it is never persisted or rendered as a user project and exposes no Git/worktree controls. Project and worktree actions remain explicit targets. Archiving retains a chat directory so restore remains lossless. Confirmed deletion removes that managed directory and never removes project directories.
|
||||
|
||||
Typing the first character in a managed Chat draft starts one deduplicated directory preparation for that draft. Materialization consumes the prepared directory before `createSession`, removing filesystem creation from the usual submit path. Closing the draft, changing it to a project target, or completing preparation after the runtime/draft changed deletes the unclaimed directory. A create failure also deletes the consumed directory.
|
||||
|
||||
The global sessions store persists and hydrates one bounded, runtime-scoped startup snapshot containing only active managed chat sessions. Every global session surface, including the main sidebar and Electron Mini Chat switcher, sees that stale snapshot while the global list is unresolved or failed; the first authoritative global snapshot replaces it. Runtime reset to idle must hydrate rather than erase the destination runtime's snapshot; authoritative empty, archive, and delete updates do persist the resulting empty or reduced list.
|
||||
|
||||
VS Code intentionally has no managed Chats mode. It neither reads nor writes the managed Chats startup cache, regular drafts continue to target the open workspace, and the global session store rejects managed chat sessions from both snapshots and live upserts before any VS Code surface can consume them. Sidebar and switcher filters repeat that exclusion defensively.
|
||||
|
||||
When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly.
|
||||
|
||||
```typescript
|
||||
@@ -349,7 +389,7 @@ Keep this in sync with `handleDirectoryEvent` in `sync-context.tsx`:
|
||||
|
||||
| Event type | Fields to clone |
|
||||
|---|---|
|
||||
| `session.created/updated/deleted` | `session`, `permission`, `todo`, `part` |
|
||||
| `session.created/updated/deleted` | `session`, `permission`, `todo`, `part`; archived/deleted sessions also clone `question` |
|
||||
| `session.diff` | `session_diff` |
|
||||
| `session.status` | `session_status` |
|
||||
| `todo.updated` | `todo` |
|
||||
@@ -361,6 +401,10 @@ Keep this in sync with `handleDirectoryEvent` in `sync-context.tsx`:
|
||||
| `question.asked/replied/rejected` | `question` |
|
||||
| `lsp.updated` | `lsp` |
|
||||
|
||||
### Directory-less session events
|
||||
|
||||
The global stream can omit a directory for a session-addressed event. Resolve it through the session routing index first. If the index is briefly stale during a session transition, route only when the event session matches the active session and that directory store exists; otherwise leave it un-routed rather than updating another directory.
|
||||
|
||||
## Adding a new event type
|
||||
|
||||
1. Add the case to the event reducer (`event-reducer.ts`)
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { createEventPipeline } from '../event-pipeline';
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
// A WebSocket attempt mints an `oc_url_token` before connecting, because a WS
|
||||
// upgrade cannot carry an Authorization header. Stub only that mint so the
|
||||
// socket assertions below exercise the transport rather than the auth round-trip.
|
||||
const actualRuntimeAuth = await import('@/lib/runtime-auth');
|
||||
mock.module('@/lib/runtime-auth', () => ({
|
||||
...actualRuntimeAuth,
|
||||
refreshRuntimeUrlAuthToken: async () => 'test-url-token',
|
||||
}));
|
||||
|
||||
const { createEventPipeline } = await import('../event-pipeline');
|
||||
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -48,9 +58,9 @@ class FakeWebSocket {
|
||||
this.onmessage?.({ data: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
emitClose() {
|
||||
emitClose(code = 1006, reason = '') {
|
||||
this.readyState = 3;
|
||||
this.onclose?.();
|
||||
this.onclose?.({ code, reason });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import type { Event, Part, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Event, Message, Part, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import { applyDirectoryEvent } from "../event-reducer"
|
||||
import { INITIAL_STATE, type State } from "../types"
|
||||
|
||||
@@ -65,6 +65,71 @@ function buildSession(title: string, time: Session["time"]): Session {
|
||||
}
|
||||
|
||||
describe("applyDirectoryEvent", () => {
|
||||
test("inserts post-rollover message events by creation time rather than ID", () => {
|
||||
const legacy = {
|
||||
id: "msg_ffffffffffffLegacy",
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 100 },
|
||||
} as Message
|
||||
const current = {
|
||||
id: "msg_000000000000Current",
|
||||
sessionID: "ses_1",
|
||||
role: "assistant",
|
||||
time: { created: 200 },
|
||||
} as Message
|
||||
const draft = state({ message: { ses_1: [legacy] } })
|
||||
|
||||
expect(applyDirectoryEvent(draft, {
|
||||
type: "message.updated",
|
||||
properties: { info: current },
|
||||
} as Event)).toBe(true)
|
||||
expect(draft.message.ses_1).toEqual([legacy, current])
|
||||
})
|
||||
|
||||
test("preserves part event order across the part ID rollover", () => {
|
||||
const legacyPart = {
|
||||
id: "prt_ffffffffffffLegacy",
|
||||
messageID: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
type: "text",
|
||||
text: "legacy",
|
||||
} as Part
|
||||
const currentPart = {
|
||||
id: "prt_000000000000Current",
|
||||
messageID: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
type: "text",
|
||||
text: "current",
|
||||
} as Part
|
||||
const draft = state({
|
||||
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "assistant", time: { created: 1 } } as Message] },
|
||||
part: { msg_1: [legacyPart] },
|
||||
})
|
||||
|
||||
expect(applyDirectoryEvent(draft, {
|
||||
type: "message.part.updated",
|
||||
properties: { part: currentPart },
|
||||
} as Event)).toBe(true)
|
||||
expect(draft.part.msg_1).toEqual([legacyPart, currentPart])
|
||||
})
|
||||
|
||||
test("replaces an optimistic user part in place instead of appending it", () => {
|
||||
const optimisticText = { id: "prt_optimistic_text", messageID: "msg_1", type: "text", text: "hi" } as Part
|
||||
const optimisticFile = { id: "prt_optimistic_file", messageID: "msg_1", type: "file", filename: "a.png" } as Part
|
||||
const serverText = { id: "prt_server_text", messageID: "msg_1", sessionID: "ses_1", type: "text", text: "hi" } as Part
|
||||
const draft = state({
|
||||
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "user", time: { created: 1 } } as Message] },
|
||||
part: { msg_1: [optimisticText, optimisticFile] },
|
||||
})
|
||||
|
||||
expect(applyDirectoryEvent(draft, {
|
||||
type: "message.part.updated",
|
||||
properties: { part: serverText },
|
||||
} as Event)).toBe(true)
|
||||
expect(draft.part.msg_1).toEqual([serverText, optimisticFile])
|
||||
})
|
||||
|
||||
test("returns typed materialization when delta arrives before parts", () => {
|
||||
const result = applyDirectoryEvent(state(), deltaEvent())
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Tests for interrupted-turn reconciliation (#2577): when a managed OpenCode
|
||||
* process dies mid-turn, the persisted turn never settles — the trailing
|
||||
* assistant message has no time.completed and its tool parts stay running.
|
||||
* Once the session is authoritatively settled, `interruptedTurnToolParts`
|
||||
* completes the assistant message as aborted and finalizes orphaned parts.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { interruptedTurnToolParts } from "../sync-context"
|
||||
import type { DirectoryStore } from "../child-store"
|
||||
import { INITIAL_STATE } from "../types"
|
||||
|
||||
function state(overrides: Partial<DirectoryStore> = {}): DirectoryStore {
|
||||
return {
|
||||
...INITIAL_STATE,
|
||||
session_status: {},
|
||||
message: {},
|
||||
part: {},
|
||||
question: {},
|
||||
permission: {},
|
||||
...overrides,
|
||||
} as unknown as DirectoryStore
|
||||
}
|
||||
|
||||
function runningTool(id: string, messageID: string, start = 1000): Part {
|
||||
return {
|
||||
id,
|
||||
messageID,
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: { status: "running", time: { start }, input: {} },
|
||||
} as unknown as Part
|
||||
}
|
||||
|
||||
function completedTool(id: string, messageID: string): Part {
|
||||
return {
|
||||
id,
|
||||
messageID,
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: { status: "completed", time: { start: 1000, end: 2000 }, input: {} },
|
||||
} as unknown as Part
|
||||
}
|
||||
|
||||
function pendingTool(id: string, messageID: string): Part {
|
||||
return {
|
||||
id,
|
||||
messageID,
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: { status: "pending", time: { start: 1000 }, input: {} },
|
||||
} as unknown as Part
|
||||
}
|
||||
|
||||
function unfinishedAssistantMessage(id: string): Message {
|
||||
return { id, sessionID: "ses_1", role: "assistant", parentID: "", modelID: "", providerID: "", mode: "primary", system: "", agent: "", model: "", time: { created: 10 } } as unknown as Message
|
||||
}
|
||||
|
||||
function finishedAssistantMessage(id: string): Message {
|
||||
return { id, sessionID: "ses_1", role: "assistant", parentID: "", modelID: "", providerID: "", mode: "primary", system: "", agent: "", model: "", time: { created: 10, completed: 2000 } } as unknown as Message
|
||||
}
|
||||
|
||||
describe("interruptedTurnToolParts (#2577)", () => {
|
||||
test("settled session with unfinished message and running tool finalizes the part", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
|
||||
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
||||
expect(result).not.toBeNull()
|
||||
const part = result!.parts![0] as { state: { status: string; error: string; time: { end: number } } }
|
||||
expect(part.state.status).toBe("error")
|
||||
expect(part.state.error).toBe("Interrupted")
|
||||
expect(part.state.time.end).toBe(5000)
|
||||
expect(result!.messages[0]).toEqual({
|
||||
...unfinishedAssistantMessage("msg_1"),
|
||||
time: { created: 10, completed: 5000 },
|
||||
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
|
||||
})
|
||||
})
|
||||
|
||||
test("busy session is never marked (live work)", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "busy" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("absent status is unknown, not settled — never marked", () => {
|
||||
const store = state({
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("finished message is not an interruption (tail refresh reconciles it)", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [finishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("pending question means the turn is waiting for input, not interrupted", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
question: { ses_1: [{ id: "q_1", sessionID: "ses_1", questions: [{ question: "?", header: "h", options: [{ label: "a", description: "" }] }] }] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("pending permission means the turn is waiting for input, not interrupted", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
permission: { ses_1: [{ id: "p_1", sessionID: "ses_1", permission: "bash", patterns: [], metadata: {}, always: [] }] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("only active parts are finalized; completed parts are untouched", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: {
|
||||
msg_1: [runningTool("tool_1", "msg_1"), completedTool("tool_2", "msg_1"), pendingTool("tool_3", "msg_1")],
|
||||
},
|
||||
})
|
||||
|
||||
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
||||
expect(result).not.toBeNull()
|
||||
const statuses = result!.parts!.map((part) => (part as { state: { status: string } }).state.status)
|
||||
expect(statuses).toEqual(["error", "completed", "error"])
|
||||
})
|
||||
|
||||
test("unfinished assistant with no tools is completed as aborted", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: {},
|
||||
})
|
||||
|
||||
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.parts).toBe(undefined)
|
||||
expect(result!.messages[0]).toEqual({
|
||||
...unfinishedAssistantMessage("msg_1"),
|
||||
time: { created: 10, completed: 5000 },
|
||||
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
|
||||
})
|
||||
})
|
||||
|
||||
test("completed tools are untouched while the unfinished assistant is aborted", () => {
|
||||
const completed = completedTool("tool_2", "msg_1")
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [completed] },
|
||||
})
|
||||
|
||||
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.parts).toBe(undefined)
|
||||
expect(store.part.msg_1[0]).toBe(completed)
|
||||
expect(result!.messages[0]).toEqual({
|
||||
...unfinishedAssistantMessage("msg_1"),
|
||||
time: { created: 10, completed: 5000 },
|
||||
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,11 @@ import { togglePermissionAutoAccept } from "../../components/chat/permissionAuto
|
||||
const storage = new Map<string, string>()
|
||||
const createSessionCalls: Array<{ title?: string; directory: string | null; parentID: string | null; metadata?: unknown }> = []
|
||||
const permissionAutoAcceptCalls: Array<[string, boolean]> = []
|
||||
const savedVariantCalls: Array<string | undefined> = []
|
||||
let configVariantOverride: string | null | undefined
|
||||
// Sync's session→directory index. `createSession` writes it, and directory
|
||||
// resolution reads it as the authoritative source, so the mock has to keep one.
|
||||
const sessionDirectoryRegistry = new Map<string, string>()
|
||||
let createdSessionDirectory: string | undefined
|
||||
|
||||
const getMockCalls = (fn: unknown): unknown[][] => ((fn as { mock?: { calls: unknown[][] } }).mock?.calls ?? [])
|
||||
@@ -73,6 +78,8 @@ mock.module("@/stores/utils/safeStorage", () => ({
|
||||
mock.module("@/lib/opencode/client", () => ({
|
||||
opencodeClient: {
|
||||
getDirectory: () => null,
|
||||
getFilesystemHome: mock(async () => "/home/test"),
|
||||
createDirectory: mock(async (path: string) => ({ success: true, path })),
|
||||
setDirectory: mock(() => undefined),
|
||||
},
|
||||
}))
|
||||
@@ -91,6 +98,9 @@ mock.module("@/stores/useConfigStore", () => ({
|
||||
useConfigStore: {
|
||||
getState: () => ({
|
||||
currentAgentName: "agent-default",
|
||||
currentProviderId: "provider",
|
||||
currentModelId: "model",
|
||||
currentVariantSelection: { override: configVariantOverride, inherited: "high" },
|
||||
agents: [],
|
||||
activateDirectory: mock(async () => undefined),
|
||||
applyDefaultModelAgentSelection: mock(() => undefined),
|
||||
@@ -165,7 +175,9 @@ mock.module("../selection-store", () => ({
|
||||
saveSessionModelSelection: () => undefined,
|
||||
saveSessionAgentSelection: () => undefined,
|
||||
saveAgentModelForSession: () => undefined,
|
||||
saveAgentModelVariantForSession: () => undefined,
|
||||
saveAgentModelVariantForSession: (_sessionId: string, _agent: string, _provider: string, _model: string, variant: string | undefined) => {
|
||||
savedVariantCalls.push(variant)
|
||||
},
|
||||
getSessionAgentSelection: () => null,
|
||||
getSessionModelSelection: () => null,
|
||||
getAgentModelForSession: () => null,
|
||||
@@ -239,13 +251,34 @@ mock.module("../sync-refs", () => ({
|
||||
getSyncMessages: () => [],
|
||||
getSyncParts: () => [],
|
||||
getAllSyncSessions: () => [],
|
||||
getSyncSessionDirectory: () => null,
|
||||
getSyncSessionDirectory: (sessionId: string) => sessionDirectoryRegistry.get(sessionId) ?? null,
|
||||
registerSessionDirectory: (sessionId: string, directory: string) => {
|
||||
sessionDirectoryRegistry.set(sessionId, directory)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("../session-actions", () => ({
|
||||
createSession: mock(async (title: string | undefined, directory: string | null, parentID: string | null, metadata?: unknown) => {
|
||||
// Mirrors the real action's authoritative steps: the created session becomes
|
||||
// current under the directory the server confirmed, and that directory enters
|
||||
// the routing index. Everything these tests assert about routing depends on
|
||||
// those two, so a mock without them tests nothing.
|
||||
createSession: mock(async (
|
||||
title: string | undefined,
|
||||
directory: string | null,
|
||||
parentID: string | null,
|
||||
metadata?: unknown,
|
||||
selectionTransition?: "submitted-draft",
|
||||
) => {
|
||||
createSessionCalls.push({ title, directory, parentID, metadata })
|
||||
return { id: "ses_issue_2039", directory: createdSessionDirectory ?? directory }
|
||||
const session = { id: "ses_issue_2039", directory: createdSessionDirectory ?? directory }
|
||||
const sessionDirectory = session.directory ?? null
|
||||
if (sessionDirectory) {
|
||||
sessionDirectoryRegistry.set(session.id, sessionDirectory)
|
||||
}
|
||||
const { useSessionUIStore: store } = await import("../session-ui-store")
|
||||
store.getState().setCurrentSession(session.id, sessionDirectory, selectionTransition)
|
||||
store.getState().markSessionAsOpenChamberCreated(session.id)
|
||||
return session
|
||||
}),
|
||||
deleteSession: mock(async () => true),
|
||||
deleteSessions: mock(async () => ({ deletedIds: [], failedIds: [] })),
|
||||
@@ -320,16 +353,21 @@ describe("issue 2039 draft auto-accept", () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
createSessionCalls.length = 0
|
||||
sessionDirectoryRegistry.clear()
|
||||
permissionAutoAcceptCalls.length = 0
|
||||
savedVariantCalls.length = 0
|
||||
configVariantOverride = undefined
|
||||
createdSessionDirectory = undefined
|
||||
|
||||
useSessionUIStore.setState({
|
||||
currentSessionId: null,
|
||||
currentSessionDirectory: null,
|
||||
newSessionDraft: {
|
||||
draftId: 0,
|
||||
open: false,
|
||||
directoryOverride: null,
|
||||
parentID: null,
|
||||
target: "chat",
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -355,6 +393,29 @@ describe("issue 2039 draft auto-accept", () => {
|
||||
expect(useSessionUIStore.getState().currentSessionId).toBe("ses_issue_2039")
|
||||
})
|
||||
|
||||
test("stores only an explicit draft variant as the session override", async () => {
|
||||
useSessionUIStore.getState().openNewSessionDraft()
|
||||
await materializeOpenDraftSession({
|
||||
providerID: "provider",
|
||||
modelID: "model",
|
||||
agent: "agent-default",
|
||||
variant: "high",
|
||||
})
|
||||
|
||||
expect(savedVariantCalls).toEqual([undefined])
|
||||
|
||||
configVariantOverride = "high"
|
||||
useSessionUIStore.getState().openNewSessionDraft()
|
||||
await materializeOpenDraftSession({
|
||||
providerID: "provider",
|
||||
modelID: "model",
|
||||
agent: "agent-default",
|
||||
variant: "high",
|
||||
})
|
||||
|
||||
expect(savedVariantCalls).toEqual([undefined, "high"])
|
||||
})
|
||||
|
||||
test("does not apply draft auto-accept after the draft is closed", async () => {
|
||||
useSessionUIStore.getState().openNewSessionDraft()
|
||||
useSessionUIStore.getState().setDraftPermissionAutoAcceptEnabled(true)
|
||||
@@ -373,6 +434,27 @@ describe("issue 2039 draft auto-accept", () => {
|
||||
expect(permissionAutoAcceptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("transfers draft project context pins only to the session it creates", async () => {
|
||||
useSessionUIStore.getState().openNewSessionDraft({
|
||||
projectContextPins: { notes: ["note-a"], plans: [] },
|
||||
})
|
||||
useSessionUIStore.getState().setDraftProjectContextPin("plan", "plan-a", true)
|
||||
|
||||
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
|
||||
|
||||
expect(createSessionCalls[0]?.metadata).toEqual({
|
||||
openchamber: {
|
||||
project_context_pins: { notes: ["note-a"], plans: ["plan-a"] },
|
||||
},
|
||||
})
|
||||
expect(useSessionUIStore.getState().newSessionDraft.projectContextPins).toBe(undefined)
|
||||
|
||||
useSessionUIStore.getState().openNewSessionDraft()
|
||||
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
|
||||
|
||||
expect(createSessionCalls[1]?.metadata).toBe(undefined)
|
||||
})
|
||||
|
||||
test("uses the server-authoritative directory after worktree session creation", async () => {
|
||||
createdSessionDirectory = "/canonical/worktree"
|
||||
useSessionUIStore.getState().openNewSessionDraft({
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
findLiveSession,
|
||||
findLiveSessionStatus,
|
||||
} from '../live-aggregate.ts'
|
||||
import { deriveRecentSessions, RECENT_SESSION_MAX_AGE_MS } from '../../components/session/sidebar/activitySections.ts'
|
||||
|
||||
const session = (id, directory, updated, extra = {}) => ({
|
||||
id,
|
||||
@@ -94,19 +93,4 @@ describe('live aggregate', () => {
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('derives recent sessions from the 48h window, excluding archived/subtasks', () => {
|
||||
const now = 1_000_000_000
|
||||
const sessions = [
|
||||
session('ses-1', '/a', now - 1_000),
|
||||
session('ses-2', '/b', now - 500),
|
||||
session('ses-3', '/c', now - 10, { time: { created: now - 11, updated: now - 10, archived: now - 5 } }),
|
||||
session('ses-4', '/d', now - 200, { parentID: 'ses-parent' }),
|
||||
session('ses-5', '/e', now - RECENT_SESSION_MAX_AGE_MS - 1),
|
||||
]
|
||||
|
||||
const recent = deriveRecentSessions(sessions, now)
|
||||
|
||||
// ses-3 archived, ses-4 subtask, ses-5 older than 48h -> excluded; rest newest-first
|
||||
expect(recent.map((item) => item.id)).toEqual(['ses-2', 'ses-1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,6 +119,62 @@ describe("materializeSessionSnapshots", () => {
|
||||
expect(result.part.msg_1[0]).toBe(livePart)
|
||||
})
|
||||
|
||||
test("preserves a locally aborted assistant message when a stale unfinished snapshot arrives", () => {
|
||||
const unfinishedMessage = message("msg_1")
|
||||
if (unfinishedMessage.role !== "assistant") throw new Error("Expected assistant fixture")
|
||||
const abortedMessage: Message = {
|
||||
...unfinishedMessage,
|
||||
time: { created: 1, completed: 5000 },
|
||||
error: { name: "MessageAbortedError", data: { message: "aborted" } },
|
||||
}
|
||||
const staleMessage = message("msg_1")
|
||||
const state = {
|
||||
message: { ses_1: [abortedMessage] },
|
||||
part: { msg_1: [] },
|
||||
}
|
||||
|
||||
const result = materializeSessionSnapshots(
|
||||
state,
|
||||
"ses_1",
|
||||
[{ info: staleMessage, parts: [] }],
|
||||
)
|
||||
|
||||
expect(result.message).toBe(state.message)
|
||||
expect(result.message.ses_1[0]).toBe(abortedMessage)
|
||||
expect(result.message.ses_1[0]).not.toBe(staleMessage)
|
||||
})
|
||||
|
||||
test("replaces a locally aborted assistant message with the authoritative completed snapshot", () => {
|
||||
const unfinishedMessage = message("msg_1")
|
||||
if (unfinishedMessage.role !== "assistant") throw new Error("Expected assistant fixture")
|
||||
const abortedMessage: Message = {
|
||||
...unfinishedMessage,
|
||||
time: { created: 1, completed: 5000 },
|
||||
error: { name: "MessageAbortedError", data: { message: "aborted" } },
|
||||
}
|
||||
const completedMessage: Message = {
|
||||
...unfinishedMessage,
|
||||
time: { created: 1, completed: 4000 },
|
||||
}
|
||||
const state = {
|
||||
message: { ses_1: [abortedMessage] },
|
||||
part: { msg_1: [] },
|
||||
}
|
||||
|
||||
const result = materializeSessionSnapshots(
|
||||
state,
|
||||
"ses_1",
|
||||
[{ info: completedMessage, parts: [] }],
|
||||
)
|
||||
|
||||
const reconciled = result.message.ses_1[0]
|
||||
expect(reconciled).toBe(completedMessage)
|
||||
expect(reconciled?.role).toBe("assistant")
|
||||
if (reconciled?.role !== "assistant") throw new Error("Expected assistant result")
|
||||
expect("error" in reconciled).toBe(false)
|
||||
expect(reconciled.time.completed).toBe(4000)
|
||||
})
|
||||
|
||||
test("does not preserve omitted optimistic user text parts beside server snapshot parts", () => {
|
||||
const optimisticPart = { id: "prt_optimistic", messageID: "msg_1", type: "text", text: "Hello" } as Part
|
||||
const serverPart = part("prt_server", "msg_1", "text", "Hello")
|
||||
@@ -167,6 +223,39 @@ describe("materializeSessionSnapshots", () => {
|
||||
expect(mergedPart.state?.time?.end).toBe(2000)
|
||||
})
|
||||
|
||||
test("does not regress a locally interrupted tool (error + end) when a stale running snapshot arrives", () => {
|
||||
// The #2577 mark writes status "error" + end time; a later stale refresh
|
||||
// that still reports the part as running must not undo it.
|
||||
const interruptedTool = {
|
||||
id: "prt_1",
|
||||
messageID: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
state: { status: "error", error: "Interrupted", time: { start: 1000, end: 5000 } },
|
||||
} as unknown as Part
|
||||
const staleRunningTool = {
|
||||
id: "prt_1",
|
||||
messageID: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
state: { status: "running", time: { start: 1000 } },
|
||||
} as unknown as Part
|
||||
const state = {
|
||||
message: { ses_1: [message("msg_1")] },
|
||||
part: { msg_1: [interruptedTool] },
|
||||
}
|
||||
|
||||
const result = materializeSessionSnapshots(
|
||||
state,
|
||||
"ses_1",
|
||||
[{ info: message("msg_1"), parts: [staleRunningTool] }],
|
||||
)
|
||||
|
||||
expect(result.part.msg_1[0]).toBe(interruptedTool)
|
||||
expect(result.part.msg_1[0]).not.toBe(staleRunningTool)
|
||||
expect((result.part.msg_1[0] as { state: { status: string } }).state.status).toBe("error")
|
||||
})
|
||||
|
||||
test("does not regress a completed tool when a stale running snapshot arrives", () => {
|
||||
const completedTool = {
|
||||
id: "prt_1",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, test, beforeEach, mock } from "bun:test"
|
||||
import { create, type StoreApi } from "zustand"
|
||||
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
const listPendingQuestionsCalls: Array<{ directories?: Array<string | null | undefined> }> = []
|
||||
const listPendingPermissionsCalls: Array<{ directories?: Array<string | null | undefined> }> = []
|
||||
const todoPersistWrites: Array<{ directory: string; sessionID: string; todos: unknown }> = []
|
||||
let pendingQuestionsResponse: QuestionRequest[] = []
|
||||
let pendingPermissionsResponse: PermissionRequest[] = []
|
||||
let pendingQuestionsShouldThrow = false
|
||||
@@ -41,7 +42,22 @@ mock.module("@/stores/useConfigStore", () => ({
|
||||
}))
|
||||
|
||||
mock.module("@/stores/useTodosPersistStore", () => ({
|
||||
useTodosPersistStore: { getState: () => ({}) },
|
||||
useTodosPersistStore: {
|
||||
getState: () => ({
|
||||
setSessionTodos: (directory: string, sessionID: string, todos: unknown) => {
|
||||
todoPersistWrites.push({ directory, sessionID, todos })
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("sonner", () => ({
|
||||
toast: {
|
||||
dismiss: () => undefined,
|
||||
error: () => undefined,
|
||||
info: () => undefined,
|
||||
success: () => undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/components/ui", () => ({
|
||||
@@ -49,8 +65,15 @@ mock.module("@/components/ui", () => ({
|
||||
}))
|
||||
|
||||
import { INITIAL_STATE, type State } from "../types"
|
||||
import type { DirectoryStore } from "../child-store"
|
||||
import { resyncBlockingRequestsForDirectory } from "../sync-context"
|
||||
import { ChildStoreManager, type DirectoryStore } from "../child-store"
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
const {
|
||||
createEventRoutingIndex,
|
||||
handleEvent,
|
||||
resyncBlockingRequestsForActiveDirectory,
|
||||
resyncBlockingRequestsForDirectory,
|
||||
setActiveSession,
|
||||
} = await import("../sync-context")
|
||||
|
||||
function buildQuestion(overrides: Partial<QuestionRequest> = {}): QuestionRequest {
|
||||
return {
|
||||
@@ -91,6 +114,8 @@ describe("resyncBlockingRequestsForDirectory", () => {
|
||||
pendingPermissionsResponse = []
|
||||
pendingQuestionsShouldThrow = false
|
||||
pendingPermissionsShouldThrow = false
|
||||
todoPersistWrites.length = 0
|
||||
setActiveSession("", "")
|
||||
})
|
||||
|
||||
test("calls listPendingQuestions and listPendingPermissions exactly once for the directory", async () => {
|
||||
@@ -106,6 +131,34 @@ describe("resyncBlockingRequestsForDirectory", () => {
|
||||
expect(listPendingPermissionsCalls[0]).toEqual({ directories: ["/repo"] })
|
||||
})
|
||||
|
||||
test("resume recovery refreshes blocking requests only for the active materialized directory", async () => {
|
||||
const childStores = new ChildStoreManager()
|
||||
childStores.ensureChild("/resume-active", { bootstrap: false }).setState({
|
||||
session: [{ id: "ses_a", title: "ses_a", time: { created: 1, updated: 1 }, version: "1" } as State["session"][number]],
|
||||
})
|
||||
childStores.ensureChild("/resume-inactive", { bootstrap: false }).setState({
|
||||
session: [{ id: "ses_b", title: "ses_b", time: { created: 1, updated: 1 }, version: "1" } as State["session"][number]],
|
||||
})
|
||||
pendingQuestionsResponse = [buildQuestion()]
|
||||
|
||||
await resyncBlockingRequestsForActiveDirectory("/resume-active", childStores)
|
||||
|
||||
expect(listPendingQuestionsCalls).toEqual([{ directories: ["/resume-active"] }])
|
||||
expect(listPendingPermissionsCalls).toEqual([{ directories: ["/resume-active"] }])
|
||||
expect(childStores.getChild("/resume-active")?.getState().question.ses_a?.[0]?.id).toBe("que_1")
|
||||
expect(childStores.getChild("/resume-inactive")?.getState().question.ses_b).toBe(undefined)
|
||||
})
|
||||
|
||||
test("resume recovery does not materialize or fetch an unopened directory", async () => {
|
||||
const childStores = new ChildStoreManager()
|
||||
|
||||
await resyncBlockingRequestsForActiveDirectory("/unopened", childStores)
|
||||
|
||||
expect(childStores.getChild("/unopened")).toBe(undefined)
|
||||
expect(listPendingQuestionsCalls).toHaveLength(0)
|
||||
expect(listPendingPermissionsCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("merges newly fetched questions/permissions into the directory store", async () => {
|
||||
const store = createDirectoryStore({})
|
||||
pendingQuestionsResponse = [buildQuestion()]
|
||||
@@ -163,6 +216,36 @@ describe("resyncBlockingRequestsForDirectory", () => {
|
||||
expect(listPendingPermissionsCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("recovers an explicit session candidate before directory bootstrap materializes it", async () => {
|
||||
const store = createDirectoryStore({ session: [] })
|
||||
pendingQuestionsResponse = [buildQuestion()]
|
||||
|
||||
await resyncBlockingRequestsForDirectory("/repo", store, ["ses_a"], { includePermissions: false })
|
||||
|
||||
expect(listPendingQuestionsCalls).toEqual([{ directories: ["/repo"] }])
|
||||
expect(listPendingPermissionsCalls).toHaveLength(0)
|
||||
expect(store.getState().question.ses_a?.[0]?.id).toBe("que_1")
|
||||
})
|
||||
|
||||
test("limits explicit question-only recovery to the requested session", async () => {
|
||||
const store = createDirectoryStore({
|
||||
session: [
|
||||
{ id: "ses_a", title: "ses_a", time: { created: 1, updated: 1 }, version: "1" },
|
||||
{ id: "ses_b", title: "ses_b", time: { created: 1, updated: 1 }, version: "1" },
|
||||
] as State["session"],
|
||||
})
|
||||
pendingQuestionsResponse = [
|
||||
buildQuestion(),
|
||||
buildQuestion({ id: "que_b", sessionID: "ses_b" }),
|
||||
]
|
||||
|
||||
await resyncBlockingRequestsForDirectory("/repo", store, ["ses_a"], { includePermissions: false })
|
||||
|
||||
expect(store.getState().question.ses_a?.[0]?.id).toBe("que_1")
|
||||
expect(store.getState().question.ses_b).toBe(undefined)
|
||||
expect(listPendingPermissionsCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
// Regression: prior to the fix, listPendingQuestions silently returned [] on
|
||||
// fetch failure, indistinguishable from a successful empty server response.
|
||||
// The resync then walked the candidate set and deleted any question that
|
||||
@@ -205,4 +288,56 @@ describe("resyncBlockingRequestsForDirectory", () => {
|
||||
expect(store.getState().question["ses_a"]?.[0]?.id).toBe("que_1")
|
||||
expect(listPendingPermissionsCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("routes a directory-less todo snapshot to its active session during a multi-store routing-index gap", () => {
|
||||
const childStores = new ChildStoreManager()
|
||||
const store = childStores.ensureChild("/target", { bootstrap: false })
|
||||
childStores.ensureChild("/other", { bootstrap: false })
|
||||
const todos = [
|
||||
{ content: "Finish plan", status: "completed", priority: "high" },
|
||||
{ content: "Implement changes", status: "in_progress", priority: "high" },
|
||||
]
|
||||
const event = {
|
||||
type: "todo.updated",
|
||||
properties: { sessionID: "ses_a", todos },
|
||||
} as Event
|
||||
const routingIndex = createEventRoutingIndex()
|
||||
|
||||
expect(childStores.children.size).toBe(2)
|
||||
expect(routingIndex.sessionDirectoryById.size).toBe(0)
|
||||
for (const candidate of childStores.children.values()) {
|
||||
const state = candidate.getState()
|
||||
expect(state.session).toEqual([])
|
||||
expect(state.message.ses_a).toBe(undefined)
|
||||
expect(state.session_status.ses_a).toBe(undefined)
|
||||
}
|
||||
|
||||
let storeWrites = 0
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
storeWrites += 1
|
||||
})
|
||||
setActiveSession("/target", "ses_a")
|
||||
handleEvent("global", event, childStores, routingIndex, getRuntimeKey())
|
||||
|
||||
expect(store.getState().todo.ses_a).toEqual(todos)
|
||||
expect(todoPersistWrites).toEqual([{ directory: "/target", sessionID: "ses_a", todos }])
|
||||
expect(storeWrites).toBe(1)
|
||||
|
||||
const stateAfterFirstSnapshot = store.getState()
|
||||
const duplicateTodos = todos.map((todo) => ({ ...todo }))
|
||||
const duplicateEvent = {
|
||||
type: "todo.updated",
|
||||
properties: { sessionID: "ses_a", todos: duplicateTodos },
|
||||
} as Event
|
||||
expect(duplicateTodos).not.toBe(todos)
|
||||
expect(duplicateTodos).toEqual(todos)
|
||||
|
||||
handleEvent("global", duplicateEvent, childStores, routingIndex, getRuntimeKey())
|
||||
|
||||
expect(store.getState()).toBe(stateAfterFirstSnapshot)
|
||||
expect(todoPersistWrites).toEqual([{ directory: "/target", sessionID: "ses_a", todos }])
|
||||
expect(storeWrites).toBe(1)
|
||||
unsubscribe()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Event, Session } from "@opencode-ai/sdk/v2/client"
|
||||
let currentSessions: Session[] = []
|
||||
const upsertedSessions: Session[] = []
|
||||
const removedSessionIds: string[] = []
|
||||
let mutationCalls = 0
|
||||
let runtimeKey = "runtime-a"
|
||||
let runtimeWillChange: (() => void) | null = null
|
||||
|
||||
@@ -15,6 +16,7 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
|
||||
getState: () => ({
|
||||
activeSessions: currentSessions,
|
||||
archivedSessions: [] as Session[],
|
||||
entityById: new Map(currentSessions.map((session) => [session.id, session])),
|
||||
upsertSession: (session: Session) => {
|
||||
upsertedSessions.push(session)
|
||||
},
|
||||
@@ -24,6 +26,15 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
|
||||
removeSessions: (ids: string[]) => {
|
||||
removedSessionIds.push(...ids)
|
||||
},
|
||||
applySessionMutations: (mutations: Array<
|
||||
{ type: "upsert"; session: Session } | { type: "remove"; sessionId: string }
|
||||
>) => {
|
||||
mutationCalls += 1
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === "upsert") upsertedSessions.push(mutation.session)
|
||||
else removedSessionIds.push(mutation.sessionId)
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
@@ -34,7 +45,7 @@ mock.module("@/lib/runtime-switch", () => ({
|
||||
return () => undefined
|
||||
},
|
||||
}))
|
||||
import { applySessionEventToGlobalSessions } from "../session-event-router"
|
||||
import { applySessionEventsToGlobalSessions, applySessionEventToGlobalSessions } from "../session-event-router"
|
||||
|
||||
const buildSession = (title: string, time: Session["time"]): Session => ({
|
||||
id: "ses_1",
|
||||
@@ -66,6 +77,7 @@ describe("applySessionEventToGlobalSessions", () => {
|
||||
currentSessions = []
|
||||
upsertedSessions.length = 0
|
||||
removedSessionIds.length = 0
|
||||
mutationCalls = 0
|
||||
})
|
||||
|
||||
test("skips stale global session.updated echoes after a newer rename", () => {
|
||||
@@ -116,4 +128,22 @@ describe("applySessionEventToGlobalSessions", () => {
|
||||
|
||||
expect(upsertedSessions).toEqual([])
|
||||
})
|
||||
|
||||
test("commits an ordered event batch once", () => {
|
||||
const events = Array.from({ length: 1_000 }, (_, index) => ({
|
||||
type: "session.created",
|
||||
properties: {
|
||||
info: {
|
||||
id: `ses_${index}`,
|
||||
title: `Session ${index}`,
|
||||
time: { created: index, updated: index },
|
||||
},
|
||||
},
|
||||
} as Event))
|
||||
|
||||
applySessionEventsToGlobalSessions(events)
|
||||
|
||||
expect(mutationCalls).toBe(1)
|
||||
expect(upsertedSessions).toHaveLength(1_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ATTACHMENT_ACCEPT,
|
||||
getAttachmentInputModality,
|
||||
getUnsupportedAttachmentInputs,
|
||||
isDocumentAttachmentFilename,
|
||||
prepareAttachmentFile,
|
||||
} from "./attachment-files"
|
||||
|
||||
@@ -47,6 +48,11 @@ describe("attachment file preparation", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("identifies Office and OpenDocument filenames for shared mention preparation", () => {
|
||||
expect(isDocumentAttachmentFilename("reports/BUDGET.XLSX")).toBe(true)
|
||||
expect(isDocumentAttachmentFilename("notes.txt")).toBe(false)
|
||||
})
|
||||
|
||||
test("renders notebooks as readable markdown without binary outputs", async () => {
|
||||
const notebook = {
|
||||
metadata: { kernelspec: { language: "python" } },
|
||||
|
||||
@@ -217,6 +217,8 @@ const extensionOf = (name: string): string => {
|
||||
return index === -1 ? "" : name.slice(index + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export const isDocumentAttachmentFilename = (name: string): boolean => DOCUMENT_EXTENSIONS.has(extensionOf(name))
|
||||
|
||||
const declaredMimeOf = (file: File): string => file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||
|
||||
const inspectTextContent = async (file: File): Promise<"text/plain" | undefined> => {
|
||||
@@ -392,7 +394,7 @@ export const prepareAttachmentFiles = (
|
||||
file: File,
|
||||
reservedFilenames: Iterable<string> = [],
|
||||
): PreparedAttachmentFile[] | Promise<PreparedAttachmentFile[] | undefined> | undefined => {
|
||||
if (!DOCUMENT_EXTENSIONS.has(extensionOf(file.name))) {
|
||||
if (!isDocumentAttachmentFilename(file.name)) {
|
||||
const prepared = prepareAttachmentFile(file)
|
||||
if (prepared instanceof Promise) return prepared.then((output) => output ? [output] : undefined)
|
||||
return prepared ? [prepared] : undefined
|
||||
|
||||
@@ -2,8 +2,11 @@ import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
ChildStoreManager,
|
||||
type DirectoryBootstrapContext,
|
||||
markDirectorySessionPartChanged,
|
||||
subscribeDirectoryPermission,
|
||||
subscribeDirectoryQuestion,
|
||||
subscribeDirectoryQuestions,
|
||||
subscribeDirectorySessionMessages,
|
||||
} from './child-store';
|
||||
import {
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
setSyncPerformanceDiagnosticsEnabled,
|
||||
} from './performance-diagnostics';
|
||||
import { DIR_IDLE_TTL_MS } from './types';
|
||||
import { FilesystemError } from '@/lib/api/files-errors';
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
@@ -120,6 +124,132 @@ describe('ChildStoreManager permission subscriptions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChildStoreManager question subscriptions', () => {
|
||||
test('notifies only the owning session and ignores unrelated high-frequency updates', () => {
|
||||
const manager = new ChildStoreManager();
|
||||
const child = manager.ensureChild('/workspace', { bootstrap: false });
|
||||
const notifications = new Map<string, number>();
|
||||
const unsubscribers = Array.from({ length: 50 }, (_, index) => {
|
||||
const sessionID = `session-${index}`;
|
||||
return subscribeDirectoryQuestion(child, sessionID, () => {
|
||||
notifications.set(sessionID, (notifications.get(sessionID) ?? 0) + 1);
|
||||
});
|
||||
});
|
||||
setSyncPerformanceDiagnosticsEnabled(true);
|
||||
|
||||
for (let index = 0; index < 10_000; index += 1) {
|
||||
child.setState({ part: { [`message-${index}`]: [] } });
|
||||
}
|
||||
|
||||
expect(notifications.size).toBe(0);
|
||||
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(0);
|
||||
|
||||
child.setState({ question: { 'session-17': [{ id: 'question-1' }] as never[] } });
|
||||
|
||||
expect(notifications.get('session-17')).toBe(1);
|
||||
expect(notifications.size).toBe(1);
|
||||
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(1);
|
||||
|
||||
// A new map that preserves session-17's bucket must not notify it again.
|
||||
child.setState({ question: { ...child.getState().question, 'session-18': [{ id: 'question-2' }] as never[] } });
|
||||
|
||||
expect(notifications.get('session-17')).toBe(1);
|
||||
expect(notifications.get('session-18')).toBe(1);
|
||||
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(2);
|
||||
|
||||
child.setState({ question: {} });
|
||||
|
||||
expect(notifications.get('session-17')).toBe(2);
|
||||
expect(notifications.get('session-18')).toBe(2);
|
||||
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(4);
|
||||
|
||||
for (const unsubscribe of unsubscribers) unsubscribe();
|
||||
setSyncPerformanceDiagnosticsEnabled(false);
|
||||
manager.disposeAll();
|
||||
});
|
||||
|
||||
test('notifies subtree and exact-session rows once for each relevant replacement', () => {
|
||||
const manager = new ChildStoreManager();
|
||||
const child = manager.ensureChild('/workspace', { bootstrap: false });
|
||||
let parentNotifications = 0;
|
||||
let childNotifications = 0;
|
||||
const unsubscribeParent = subscribeDirectoryQuestions(child, ['parent', 'child'], () => {
|
||||
parentNotifications += 1;
|
||||
});
|
||||
const unsubscribeChild = subscribeDirectoryQuestion(child, 'child', () => {
|
||||
childNotifications += 1;
|
||||
});
|
||||
const parentQuestions = [{ id: 'question-parent' }] as never[];
|
||||
const childQuestions = [{ id: 'question-child' }] as never[];
|
||||
|
||||
child.setState({ question: { parent: parentQuestions, child: childQuestions } });
|
||||
|
||||
expect(parentNotifications).toBe(1);
|
||||
expect(childNotifications).toBe(1);
|
||||
|
||||
child.setState({ part: { message: [] } });
|
||||
expect(parentNotifications).toBe(1);
|
||||
expect(childNotifications).toBe(1);
|
||||
|
||||
child.setState({
|
||||
question: {
|
||||
parent: parentQuestions,
|
||||
child: [{ id: 'question-child-replacement' }] as never[],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parentNotifications).toBe(2);
|
||||
expect(childNotifications).toBe(2);
|
||||
|
||||
child.setState({ question: {} });
|
||||
|
||||
expect(parentNotifications).toBe(3);
|
||||
expect(childNotifications).toBe(3);
|
||||
|
||||
unsubscribeParent();
|
||||
unsubscribeChild();
|
||||
manager.disposeAll();
|
||||
});
|
||||
|
||||
test('aggregates exact question buckets across directory stores', () => {
|
||||
const manager = new ChildStoreManager();
|
||||
const parentStore = manager.ensureChild('/repo', { bootstrap: false });
|
||||
const childStore = manager.ensureChild('/worktrees/feature', { bootstrap: false });
|
||||
let notifications = 0;
|
||||
const notify = () => {
|
||||
notifications += 1;
|
||||
};
|
||||
const unsubscribers = [
|
||||
subscribeDirectoryQuestions(parentStore, ['parent'], notify),
|
||||
subscribeDirectoryQuestions(childStore, ['child'], notify),
|
||||
];
|
||||
const questionCount = () => (
|
||||
(parentStore.getState().question.parent?.length ?? 0)
|
||||
+ (childStore.getState().question.child?.length ?? 0)
|
||||
);
|
||||
|
||||
childStore.setState({ question: { child: [{ id: 'child-question' }] as never[] } });
|
||||
expect(questionCount()).toBe(1);
|
||||
expect(notifications).toBe(1);
|
||||
|
||||
childStore.setState({
|
||||
question: {
|
||||
...childStore.getState().question,
|
||||
unrelated: [{ id: 'unrelated-question' }] as never[],
|
||||
},
|
||||
});
|
||||
expect(questionCount()).toBe(1);
|
||||
expect(notifications).toBe(1);
|
||||
|
||||
parentStore.setState({ question: { parent: [{ id: 'parent-question' }] as never[] } });
|
||||
expect(questionCount()).toBe(2);
|
||||
expect(notifications).toBe(2);
|
||||
|
||||
for (const unsubscribe of unsubscribers) unsubscribe();
|
||||
manager.disposeAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChildStoreManager session message subscriptions', () => {
|
||||
test('routes annotated part changes only to the owning session', () => {
|
||||
const manager = new ChildStoreManager();
|
||||
@@ -286,6 +416,40 @@ describe('ChildStoreManager directory bootstrap scheduler', () => {
|
||||
manager.disposeAll();
|
||||
});
|
||||
|
||||
test('records os-permission failures and clears them on forced retry', async () => {
|
||||
const manager = new ChildStoreManager();
|
||||
let denied = true;
|
||||
const cleanup = manager.configure({
|
||||
onBootstrap: () => {
|
||||
if (denied) {
|
||||
throw new FilesystemError('Access denied', { reason: 'os-permission', status: 403 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
manager.requestBootstrap({ directory: '/protected', priority: 'selected', reason: 'current-directory' });
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
expect(manager.getBootstrapState('/protected')).toBe('failed');
|
||||
expect(manager.getBootstrapFailure('/protected')).toBe('os-permission');
|
||||
|
||||
denied = false;
|
||||
manager.requestBootstrap({
|
||||
directory: '/protected',
|
||||
priority: 'selected',
|
||||
reason: 'action-demand',
|
||||
force: true,
|
||||
});
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
expect(manager.getBootstrapState('/protected')).toBe('complete');
|
||||
expect(manager.getBootstrapFailure('/protected')).toBe(undefined);
|
||||
cleanup();
|
||||
manager.disposeAll();
|
||||
});
|
||||
|
||||
test('continues after a synchronous bootstrap failure', async () => {
|
||||
const manager = new ChildStoreManager();
|
||||
const started: string[] = [];
|
||||
@@ -395,3 +559,54 @@ describe('ChildStoreManager directory bootstrap scheduler', () => {
|
||||
manager.disposeAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChildStoreManager bootstrap context liveness', () => {
|
||||
test('isCurrent stays true after the run settles so deferred recovery work can commit', async () => {
|
||||
const manager = new ChildStoreManager();
|
||||
let captured: DirectoryBootstrapContext | undefined;
|
||||
const cleanup = manager.configure({
|
||||
onBootstrap: (context) => {
|
||||
captured = context;
|
||||
},
|
||||
});
|
||||
manager.requestBootstrap({ directory: '/workspace', priority: 'selected', reason: 'current-directory' });
|
||||
await settle();
|
||||
expect(manager.getBootstrapState('/workspace')).toBe('complete');
|
||||
|
||||
// bootstrapDirectory schedules deferred recovery pulls (permission.list
|
||||
// and friends) from a setTimeout(0), which always runs after the pump's
|
||||
// .finally() has cleaned up the run entry. isCurrent must remain true
|
||||
// there, or those pulls and every commit they make get skipped.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(captured?.isCurrent()).toBe(true);
|
||||
|
||||
cleanup();
|
||||
expect(captured?.isCurrent()).toBe(false);
|
||||
manager.disposeAll();
|
||||
});
|
||||
|
||||
test('a newer same-directory run invalidates the previous context', async () => {
|
||||
const manager = new ChildStoreManager();
|
||||
const contexts: DirectoryBootstrapContext[] = [];
|
||||
const cleanup = manager.configure({
|
||||
onBootstrap: (context) => {
|
||||
contexts.push(context);
|
||||
},
|
||||
});
|
||||
manager.requestBootstrap({ directory: '/workspace', priority: 'selected', reason: 'current-directory' });
|
||||
await settle();
|
||||
expect(contexts[0]?.isCurrent()).toBe(true);
|
||||
|
||||
// A forced rerun for the same directory must retire the previous
|
||||
// context: its in-flight deferred responses may no longer commit over
|
||||
// whatever the newer run synchronizes.
|
||||
manager.requestBootstrap({ directory: '/workspace', priority: 'selected', reason: 'server-connected', force: true });
|
||||
await settle();
|
||||
expect(contexts).toHaveLength(2);
|
||||
expect(contexts[0]?.isCurrent()).toBe(false);
|
||||
expect(contexts[1]?.isCurrent()).toBe(true);
|
||||
|
||||
cleanup();
|
||||
manager.disposeAll();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readDirCache, persistVcs, persistProjectMeta, persistIcon, persistSessi
|
||||
import { normalizePath } from "@/lib/pathNormalization"
|
||||
import { startSessionLoadPerformanceEvent } from "./session-load-performance"
|
||||
import { countSyncPerformance } from "./performance-diagnostics"
|
||||
import { isFilesystemError } from "@/lib/api/files-errors"
|
||||
|
||||
export type DirectoryStore = State & {
|
||||
/** Apply a partial state update */
|
||||
@@ -14,8 +15,10 @@ export type DirectoryStore = State & {
|
||||
replace: (next: State) => void
|
||||
}
|
||||
|
||||
type PermissionSubscriber = () => void
|
||||
const permissionSubscribersByStore = new WeakMap<StoreApi<DirectoryStore>, Map<string, Set<PermissionSubscriber>>>()
|
||||
type BlockingRequestSubscriber = () => void
|
||||
type BlockingRequestSubscribers = WeakMap<StoreApi<DirectoryStore>, Map<string, Set<BlockingRequestSubscriber>>>
|
||||
const permissionSubscribersByStore: BlockingRequestSubscribers = new WeakMap()
|
||||
const questionSubscribersByStore: BlockingRequestSubscribers = new WeakMap()
|
||||
|
||||
type SessionMessageChange = {
|
||||
messagesChanged: boolean
|
||||
@@ -72,12 +75,42 @@ export function markDirectorySessionPartChanged(
|
||||
export function subscribeDirectoryPermission(
|
||||
store: StoreApi<DirectoryStore>,
|
||||
sessionID: string,
|
||||
listener: PermissionSubscriber,
|
||||
listener: BlockingRequestSubscriber,
|
||||
): () => void {
|
||||
let bySession = permissionSubscribersByStore.get(store)
|
||||
return subscribeBlockingRequest(permissionSubscribersByStore, store, sessionID, listener)
|
||||
}
|
||||
|
||||
export function subscribeDirectoryQuestion(
|
||||
store: StoreApi<DirectoryStore>,
|
||||
sessionID: string,
|
||||
listener: BlockingRequestSubscriber,
|
||||
): () => void {
|
||||
return subscribeBlockingRequest(questionSubscribersByStore, store, sessionID, listener)
|
||||
}
|
||||
|
||||
export function subscribeDirectoryQuestions(
|
||||
store: StoreApi<DirectoryStore>,
|
||||
sessionIDs: readonly string[],
|
||||
listener: BlockingRequestSubscriber,
|
||||
): () => void {
|
||||
const unsubscribers = [...new Set(sessionIDs.filter(Boolean))].map((sessionID) => (
|
||||
subscribeBlockingRequest(questionSubscribersByStore, store, sessionID, listener)
|
||||
))
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribers) unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeBlockingRequest(
|
||||
subscribersByStore: BlockingRequestSubscribers,
|
||||
store: StoreApi<DirectoryStore>,
|
||||
sessionID: string,
|
||||
listener: BlockingRequestSubscriber,
|
||||
): () => void {
|
||||
let bySession = subscribersByStore.get(store)
|
||||
if (!bySession) {
|
||||
bySession = new Map()
|
||||
permissionSubscribersByStore.set(store, bySession)
|
||||
subscribersByStore.set(store, bySession)
|
||||
}
|
||||
let listeners = bySession.get(sessionID)
|
||||
if (!listeners) {
|
||||
@@ -88,24 +121,28 @@ export function subscribeDirectoryPermission(
|
||||
return () => {
|
||||
listeners?.delete(listener)
|
||||
if (listeners?.size === 0) bySession?.delete(sessionID)
|
||||
if (bySession?.size === 0) permissionSubscribersByStore.delete(store)
|
||||
if (bySession?.size === 0) subscribersByStore.delete(store)
|
||||
}
|
||||
}
|
||||
|
||||
const notifyChangedPermissions = (
|
||||
const notifyChangedBlockingRequests = <T,>(
|
||||
subscribersByStore: BlockingRequestSubscribers,
|
||||
counter: "permissionChangeCallbacks" | "questionChangeCallbacks",
|
||||
store: StoreApi<DirectoryStore>,
|
||||
current: State["permission"],
|
||||
previous: State["permission"],
|
||||
current: Record<string, T>,
|
||||
previous: Record<string, T>,
|
||||
): void => {
|
||||
if (current === previous) return
|
||||
const subscribers = permissionSubscribersByStore.get(store)
|
||||
const subscribers = subscribersByStore.get(store)
|
||||
if (!subscribers || subscribers.size === 0) return
|
||||
const changedListeners = new Set<BlockingRequestSubscriber>()
|
||||
for (const [sessionID, listeners] of subscribers) {
|
||||
if (current[sessionID] === previous[sessionID]) continue
|
||||
for (const listener of listeners) {
|
||||
countSyncPerformance("permissionChangeCallbacks")
|
||||
listener()
|
||||
}
|
||||
for (const listener of listeners) changedListeners.add(listener)
|
||||
}
|
||||
for (const listener of changedListeners) {
|
||||
countSyncPerformance(counter)
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +217,7 @@ export type DirectoryBootstrapDemand = {
|
||||
}
|
||||
|
||||
export type DirectoryBootstrapState = "queued" | "running" | "complete" | "failed"
|
||||
export type DirectoryBootstrapFailureReason = "os-permission" | "generic"
|
||||
|
||||
export type DirectoryBootstrapContext = DirectoryBootstrapDemand & {
|
||||
generation: number
|
||||
@@ -239,7 +277,8 @@ function createDirectoryStore(directory: string): StoreApi<DirectoryStore> {
|
||||
if (state.projectMeta !== prev.projectMeta) persistProjectMeta(directory, state.projectMeta)
|
||||
if (state.icon !== prev.icon) persistIcon(directory, state.icon)
|
||||
if (state.session !== prev.session) persistSessions(directory, state.session)
|
||||
notifyChangedPermissions(store, state.permission, prev.permission)
|
||||
notifyChangedBlockingRequests(permissionSubscribersByStore, "permissionChangeCallbacks", store, state.permission, prev.permission)
|
||||
notifyChangedBlockingRequests(questionSubscribersByStore, "questionChangeCallbacks", store, state.question, prev.question)
|
||||
notifyChangedSessionMessages(store, state, prev)
|
||||
})
|
||||
|
||||
@@ -259,6 +298,7 @@ export class ChildStoreManager {
|
||||
private readonly bootstrapQueue = new Map<string, QueuedBootstrap>()
|
||||
private readonly runningBootstraps = new Map<string, RunningBootstrap>()
|
||||
private readonly bootstrapStates = new Map<string, DirectoryBootstrapState>()
|
||||
private readonly bootstrapFailures = new Map<string, DirectoryBootstrapFailureReason>()
|
||||
|
||||
private onBootstrap?: (context: DirectoryBootstrapContext) => Promise<void> | void
|
||||
private onDispose?: (directory: string) => void
|
||||
@@ -267,6 +307,8 @@ export class ChildStoreManager {
|
||||
private bootstrapConcurrency = 2
|
||||
private bootstrapGeneration = 0
|
||||
private bootstrapSequence = 0
|
||||
private bootstrapRunSequence = 0
|
||||
private readonly directoryBootstrapRuns = new Map<string, number>()
|
||||
private manualBootstrapDemandRevision = 0
|
||||
private disposed = false
|
||||
|
||||
@@ -442,6 +484,11 @@ export class ChildStoreManager {
|
||||
return normalizedDirectory ? this.bootstrapStates.get(normalizedDirectory) : undefined
|
||||
}
|
||||
|
||||
getBootstrapFailure(directory: string): DirectoryBootstrapFailureReason | undefined {
|
||||
const normalizedDirectory = normalizePath(directory)
|
||||
return normalizedDirectory ? this.bootstrapFailures.get(normalizedDirectory) : undefined
|
||||
}
|
||||
|
||||
subscribeBootstrap(listener: () => void): () => void {
|
||||
this.bootstrapSubscribers.add(listener)
|
||||
return () => this.bootstrapSubscribers.delete(listener)
|
||||
@@ -493,6 +540,7 @@ export class ChildStoreManager {
|
||||
if (demand.force) running.rerunRequested = true
|
||||
return false
|
||||
}
|
||||
this.bootstrapFailures.delete(directory)
|
||||
const existing = this.bootstrapQueue.get(directory)
|
||||
const next: QueuedBootstrap = existing
|
||||
? {
|
||||
@@ -550,11 +598,23 @@ export class ChildStoreManager {
|
||||
queuedMs: Math.max(0, Date.now() - next.enqueuedAt),
|
||||
})
|
||||
|
||||
// Store liveness, not run-token ownership. The pump deletes the run
|
||||
// token in `.finally()` as soon as onBootstrap settles, while
|
||||
// bootstrapDirectory schedules deferred recovery pulls (permission.list
|
||||
// and friends) from a `setTimeout(0)` that always runs after that
|
||||
// cleanup — gating those on the token made them dead code. A per-
|
||||
// directory run sequence keeps the context current across settle (so
|
||||
// deferred pulls commit) while invalidating it as soon as a newer run
|
||||
// starts, so a late deferred response cannot overwrite a newer run's
|
||||
// state. Mirrors the isCurrent contract in session-message-loader.
|
||||
const runSequence = ++this.bootstrapRunSequence
|
||||
this.directoryBootstrapRuns.set(next.directory, runSequence)
|
||||
const store = this.children.get(next.directory)
|
||||
const isCurrent = () => (
|
||||
!this.disposed
|
||||
&& this.bootstrapGeneration === running.generation
|
||||
&& this.runningBootstraps.get(next.directory)?.token === token
|
||||
&& this.children.has(next.directory)
|
||||
&& this.directoryBootstrapRuns.get(next.directory) === runSequence
|
||||
&& this.children.get(next.directory) === store
|
||||
)
|
||||
let bootstrapPromise: Promise<void>
|
||||
try {
|
||||
@@ -566,14 +626,19 @@ export class ChildStoreManager {
|
||||
.then(() => {
|
||||
if (isCurrent()) {
|
||||
this.bootstrapStates.set(next.directory, "complete")
|
||||
this.bootstrapFailures.delete(next.directory)
|
||||
finishPerformanceEvent("complete")
|
||||
} else {
|
||||
finishPerformanceEvent("stale")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
if (isCurrent()) {
|
||||
this.bootstrapStates.set(next.directory, "failed")
|
||||
this.bootstrapFailures.set(
|
||||
next.directory,
|
||||
isFilesystemError(error) && error.reason === "os-permission" ? "os-permission" : "generic",
|
||||
)
|
||||
finishPerformanceEvent("error")
|
||||
} else {
|
||||
finishPerformanceEvent("stale")
|
||||
@@ -621,6 +686,8 @@ export class ChildStoreManager {
|
||||
this.bootstrapQueue.delete(directory)
|
||||
this.manualBootstrapDemands.delete(directory)
|
||||
this.bootstrapStates.delete(directory)
|
||||
this.bootstrapFailures.delete(directory)
|
||||
this.directoryBootstrapRuns.delete(directory)
|
||||
for (const demands of this.bootstrapDemandsByOwner.values()) demands.delete(directory)
|
||||
this.children.delete(directory)
|
||||
this.notifyRegistrySubscribers()
|
||||
@@ -682,6 +749,7 @@ export class ChildStoreManager {
|
||||
this.bootstrapQueue.clear()
|
||||
this.runningBootstraps.clear()
|
||||
this.bootstrapStates.clear()
|
||||
this.bootstrapFailures.clear()
|
||||
this.bootstrapDemandsByOwner.clear()
|
||||
this.manualBootstrapDemands.clear()
|
||||
this.notifyBootstrapSubscribers()
|
||||
|
||||
@@ -82,7 +82,10 @@ describe("document attachment extraction", () => {
|
||||
"xl/_rels/workbook.xml.rels": relationships([{ id: "rIdSheet", target: "worksheets/sheet1.xml" }]),
|
||||
"xl/sharedStrings.xml": `<sst><si><t>Revenue</t></si></sst>`,
|
||||
"xl/worksheets/sheet1.xml": `
|
||||
<worksheet xmlns:r="r"><sheetData><row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>42</v></c></row></sheetData><drawing r:id="rIdDrawing"/></worksheet>`,
|
||||
<worksheet xmlns:r="r"><sheetData>
|
||||
<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>42</v></c></row>
|
||||
<row r="2"><c r="A2" t="inlineStr"><is><t>North</t></is></c><c r="B2"><v>17</v></c></row>
|
||||
</sheetData><drawing r:id="rIdDrawing"/></worksheet>`,
|
||||
"xl/worksheets/_rels/sheet1.xml.rels": relationships([{ id: "rIdDrawing", target: "../drawings/drawing1.xml" }]),
|
||||
"xl/drawings/drawing1.xml": `
|
||||
<xdr:wsDr xmlns:xdr="xdr" xmlns:a="a" xmlns:r="r"><xdr:oneCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:row>2</xdr:row></xdr:from><a:blip r:embed="rIdImage"/></xdr:oneCellAnchor></xdr:wsDr>`,
|
||||
@@ -94,11 +97,27 @@ describe("document attachment extraction", () => {
|
||||
const text = await result?.textFile.text() ?? ""
|
||||
|
||||
expect(text.includes("## Sheet: Summary")).toBe(true)
|
||||
expect(text.includes("A1: Revenue | B1: 42")).toBe(true)
|
||||
expect(text.includes("Range: A1:B2\nRevenue\t42\nNorth\t17")).toBe(true)
|
||||
expect(text.includes("Image at B3: [budget-image-1.webp]")).toBe(true)
|
||||
expect(result?.images[0]?.name).toBe("budget-image-1.webp")
|
||||
})
|
||||
|
||||
test("quotes TSV values and keeps sparse XLSX rows coordinate-based", async () => {
|
||||
const file = zippedFile("sparse.xlsx", {
|
||||
"xl/workbook.xml": `<workbook xmlns:r="r"><sheets><sheet name="Data" r:id="sheet"/></sheets></workbook>`,
|
||||
"xl/_rels/workbook.xml.rels": relationships([{ id: "sheet", target: "worksheets/sheet1.xml" }]),
|
||||
"xl/worksheets/sheet1.xml": `<worksheet><sheetData>
|
||||
<row r="1"><c r="A1" t="inlineStr"><is><t>line 1 line 2</t></is></c><c r="B1" t="inlineStr"><is><t>say "hi"</t></is></c></row>
|
||||
<row r="2"><c r="A2" t="inlineStr"><is><t>first</t></is></c><c r="XFD2" t="inlineStr"><is><t>last</t></is></c></row>
|
||||
</sheetData></worksheet>`,
|
||||
})
|
||||
|
||||
const text = await (await extractDocumentAttachments(file))?.textFile.text() ?? ""
|
||||
|
||||
expect(text.includes('Range: A1:B1\n"line 1\nline 2"\t"say ""hi"""')).toBe(true)
|
||||
expect(text.includes("Cells: A2\tfirst | XFD2\tlast")).toBe(true)
|
||||
})
|
||||
|
||||
test("extracts OpenDocument text, presentations, spreadsheets, and image positions", async () => {
|
||||
const image = pngBytes()
|
||||
const odt = zippedFile("notes.odt", {
|
||||
@@ -196,7 +215,7 @@ describe("document attachment extraction", () => {
|
||||
|
||||
test("does not retain images whose citations fall beyond the text limit", async () => {
|
||||
const file = zippedFile("long.docx", {
|
||||
"word/document.xml": `<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>${"x".repeat(2_000_100)}</w:t></w:p><w:p><a:blip r:embed="image"/></w:p></w:body></w:document>`,
|
||||
"word/document.xml": `<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>${"x".repeat(500_100)}</w:t></w:p><w:p><a:blip r:embed="image"/></w:p></w:body></w:document>`,
|
||||
"word/_rels/document.xml.rels": relationships([{ id: "image", target: "media/image.png" }]),
|
||||
"word/media/image.png": pngBytes(),
|
||||
})
|
||||
@@ -204,7 +223,7 @@ describe("document attachment extraction", () => {
|
||||
const result = await extractDocumentAttachments(file)
|
||||
const text = await result?.textFile.text() ?? ""
|
||||
|
||||
expect(text.length <= 2_000_000).toBe(true)
|
||||
expect(text.length <= 500_000).toBe(true)
|
||||
expect(text.endsWith("[Document text truncated by OpenChamber]\n")).toBe(true)
|
||||
expect(text.includes("[long-image-1.png]")).toBe(false)
|
||||
expect(result?.images).toEqual([])
|
||||
|
||||
@@ -8,7 +8,7 @@ const MAX_ARCHIVE_ENTRIES = 5_000
|
||||
const MAX_EMBEDDED_IMAGES = 50
|
||||
const MAX_EMBEDDED_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
const MAX_EMBEDDED_IMAGES_BYTES = 40 * 1024 * 1024
|
||||
const MAX_EXTRACTED_TEXT_CHARS = 2_000_000
|
||||
const MAX_EXTRACTED_TEXT_CHARS = 500_000
|
||||
const MAX_ODF_SPACES_PER_ELEMENT = 100
|
||||
const TEXT_TRUNCATION_NOTICE = "\n\n[Document text truncated by OpenChamber]\n"
|
||||
|
||||
@@ -310,6 +310,17 @@ const columnName = (index: number): string => {
|
||||
return result
|
||||
}
|
||||
|
||||
const columnIndex = (name: string): number => {
|
||||
let result = 0
|
||||
for (const character of name.toUpperCase()) result = result * 26 + character.charCodeAt(0) - 64
|
||||
return result - 1
|
||||
}
|
||||
|
||||
const tsvValue = (value: string): string => {
|
||||
if (!/[\t\r\n"]/.test(value)) return value
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
const cellValue = (cell: string, sharedStrings: string[]): string => {
|
||||
const type = attribute(cell.match(/^<c\b[^>]*>/i)?.[0] ?? "", "t")
|
||||
if (type === "inlineStr") {
|
||||
@@ -321,6 +332,95 @@ const cellValue = (cell: string, sharedStrings: string[]): string => {
|
||||
return decodeXml(value)
|
||||
}
|
||||
|
||||
type SpreadsheetCell = {
|
||||
reference: string
|
||||
column: number
|
||||
row: number
|
||||
value: string
|
||||
}
|
||||
|
||||
type SpreadsheetRow = {
|
||||
cells: SpreadsheetCell[]
|
||||
firstColumn: number
|
||||
lastColumn: number
|
||||
row: number
|
||||
}
|
||||
|
||||
const isDenseSpreadsheetRow = (row: SpreadsheetRow): boolean => {
|
||||
const width = row.lastColumn - row.firstColumn + 1
|
||||
return width <= Math.max(32, row.cells.length * 4)
|
||||
}
|
||||
|
||||
const serializeDenseSpreadsheetRow = (row: SpreadsheetRow): string => {
|
||||
const valuesByColumn = new Map(row.cells.map((cell) => [cell.column, cell.value]))
|
||||
return Array.from(
|
||||
{ length: row.lastColumn - row.firstColumn + 1 },
|
||||
(_, offset) => tsvValue(valuesByColumn.get(row.firstColumn + offset) ?? ""),
|
||||
).join("\t")
|
||||
}
|
||||
|
||||
const spreadsheetRows = (worksheet: string, sharedStrings: string[]): SpreadsheetRow[] => {
|
||||
const rows: SpreadsheetRow[] = []
|
||||
for (const rowXml of tagBlocks(worksheet, "row")) {
|
||||
const cells: SpreadsheetCell[] = []
|
||||
for (const match of rowXml.matchAll(/<c\b[^>]*>[\s\S]*?<\/c>/gi)) {
|
||||
const tag = match[0].match(/^<c\b[^>]*>/i)?.[0] ?? ""
|
||||
const reference = attribute(tag, "r")
|
||||
const coordinates = reference?.match(/^([a-z]+)([1-9]\d*)$/i)
|
||||
const value = cellValue(match[0], sharedStrings)
|
||||
if (!reference || !coordinates || !value) continue
|
||||
cells.push({
|
||||
reference,
|
||||
column: columnIndex(coordinates[1]),
|
||||
row: Number(coordinates[2]),
|
||||
value,
|
||||
})
|
||||
}
|
||||
cells.sort((left, right) => left.column - right.column)
|
||||
const first = cells[0]
|
||||
const last = cells.at(-1)
|
||||
if (!first || !last) continue
|
||||
rows.push({ cells, firstColumn: first.column, lastColumn: last.column, row: first.row })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const serializeSpreadsheetRows = (rows: SpreadsheetRow[]): string[] => {
|
||||
const sections: string[] = []
|
||||
let denseBlock: SpreadsheetRow[] = []
|
||||
|
||||
const flushDenseBlock = () => {
|
||||
const first = denseBlock[0]
|
||||
const last = denseBlock.at(-1)
|
||||
if (!first || !last) return
|
||||
sections.push([
|
||||
`Range: ${columnName(first.firstColumn)}${first.row}:${columnName(first.lastColumn)}${last.row}`,
|
||||
...denseBlock.map(serializeDenseSpreadsheetRow),
|
||||
].join("\n"))
|
||||
denseBlock = []
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const previous = denseBlock.at(-1)
|
||||
if (!isDenseSpreadsheetRow(row)) {
|
||||
flushDenseBlock()
|
||||
sections.push(`Cells: ${row.cells.map((cell) => `${cell.reference}\t${tsvValue(cell.value)}`).join(" | ")}`)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
previous
|
||||
&& (row.row !== previous.row + 1
|
||||
|| row.firstColumn !== previous.firstColumn
|
||||
|| row.lastColumn !== previous.lastColumn)
|
||||
) {
|
||||
flushDenseBlock()
|
||||
}
|
||||
denseBlock.push(row)
|
||||
}
|
||||
flushDenseBlock()
|
||||
return sections
|
||||
}
|
||||
|
||||
const drawingCitations = (
|
||||
archive: Unzipped,
|
||||
worksheetPath: string,
|
||||
@@ -362,15 +462,7 @@ const extractXlsx = (archive: Unzipped, images: EmbeddedImages): string | undefi
|
||||
if (!worksheetPath) continue
|
||||
sections.push(`## Sheet: ${name}`)
|
||||
|
||||
const rows: string[] = []
|
||||
for (const row of tagBlocks(xml(archive, worksheetPath), "row")) {
|
||||
const cells = Array.from(row.matchAll(/<c\b[^>]*>[\s\S]*?<\/c>/gi), (match) => {
|
||||
const tag = match[0].match(/^<c\b[^>]*>/i)?.[0] ?? ""
|
||||
const reference = attribute(tag, "r") ?? "?"
|
||||
return `${reference}: ${cellValue(match[0], sharedStrings)}`
|
||||
}).filter((value) => !value.endsWith(": "))
|
||||
if (cells.length > 0) rows.push(cells.join(" | "))
|
||||
}
|
||||
const rows = serializeSpreadsheetRows(spreadsheetRows(xml(archive, worksheetPath), sharedStrings))
|
||||
sections.push(...(rows.length > 0 ? rows : ["[Empty sheet]"]), ...drawingCitations(archive, worksheetPath, images))
|
||||
}
|
||||
return `${sections.join("\n\n")}\n`
|
||||
|
||||
@@ -15,6 +15,11 @@ import { dropSessionCaches } from "./session-cache"
|
||||
import { stripSessionDiffSnapshots } from "./sanitize"
|
||||
import { syncDebug } from "./debug"
|
||||
import { shouldSkipStaleSessionEvent } from "./session-event-freshness"
|
||||
import {
|
||||
compareMessagesChronologically,
|
||||
findMessageIndex,
|
||||
insertMessageChronologically,
|
||||
} from "./message-ordering"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const DELTA_OVERLAP_FIELDS = ["text", "output"] as const
|
||||
@@ -180,7 +185,7 @@ function hasMessage(draft: State, sessionID: string | undefined, messageID: stri
|
||||
if (!sessionID) return false
|
||||
const messages = draft.message[sessionID]
|
||||
if (!messages) return false
|
||||
return Binary.search(messages, messageID, (message) => message.id).found
|
||||
return messages.some((message) => message.id === messageID)
|
||||
}
|
||||
|
||||
export function reduceGlobalEvent(event: Event): GlobalEventResult {
|
||||
@@ -309,6 +314,9 @@ export function applyDirectoryEvent(
|
||||
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
if (areJsonEquivalent(draft.todo[props.sessionID], props.todos)) {
|
||||
return false
|
||||
}
|
||||
draft.todo[props.sessionID] = props.todos
|
||||
callbacks?.onSetSessionTodo?.(props.sessionID, props.todos)
|
||||
return true
|
||||
@@ -350,21 +358,26 @@ export function applyDirectoryEvent(
|
||||
draft.message[info.sessionID] = [info]
|
||||
return true
|
||||
}
|
||||
const result = Binary.search(messages, info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
const messageIndex = findMessageIndex(messages, info.id)
|
||||
if (messageIndex >= 0) {
|
||||
// Skip message replacement if unchanged — preserves reference, avoids re-render
|
||||
const existing = messages[result.index]
|
||||
const existing = messages[messageIndex]
|
||||
const unchanged = areMessageUpdateFieldsEqual(existing, info)
|
||||
if (unchanged) {
|
||||
syncDebug.reducer.messageUpdatedUnchanged(info.sessionID, info.id, info.role, (info as { finish?: unknown }).finish, (info.time as { completed?: number })?.completed)
|
||||
return false
|
||||
}
|
||||
const next = [...messages]
|
||||
next[result.index] = info
|
||||
if (compareMessagesChronologically(existing, info) === 0) {
|
||||
next[messageIndex] = info
|
||||
} else {
|
||||
next.splice(messageIndex, 1)
|
||||
insertMessageChronologically(next, info)
|
||||
}
|
||||
draft.message[info.sessionID] = next
|
||||
} else {
|
||||
const next = [...messages]
|
||||
next.splice(result.index, 0, info)
|
||||
insertMessageChronologically(next, info)
|
||||
draft.message[info.sessionID] = next
|
||||
}
|
||||
return true
|
||||
@@ -375,9 +388,9 @@ export function applyDirectoryEvent(
|
||||
const messages = draft.message[props.sessionID]
|
||||
if (messages) {
|
||||
const next = [...messages]
|
||||
const result = Binary.search(next, props.messageID, (m) => m.id)
|
||||
if (result.found) {
|
||||
next.splice(result.index, 1)
|
||||
const messageIndex = findMessageIndex(next, props.messageID)
|
||||
if (messageIndex >= 0) {
|
||||
next.splice(messageIndex, 1)
|
||||
draft.message[props.sessionID] = next
|
||||
}
|
||||
}
|
||||
@@ -408,14 +421,14 @@ export function applyDirectoryEvent(
|
||||
: true
|
||||
}
|
||||
const next = [...parts]
|
||||
const result = Binary.search(next, part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
const previous = next[result.index]
|
||||
const partIndex = next.findIndex((candidate) => candidate.id === part.id)
|
||||
if (partIndex >= 0) {
|
||||
const previous = next[partIndex]
|
||||
if (shouldPreserveExistingPart(previous, part)) {
|
||||
return false
|
||||
}
|
||||
const dedupeFields = getUpdatedDeltaFields(previous, part)
|
||||
next[result.index] = dedupeFields.length > 0
|
||||
next[partIndex] = dedupeFields.length > 0
|
||||
? { ...part, __dedupeNextDeltaFields: dedupeFields } as unknown as Part
|
||||
: part
|
||||
} else {
|
||||
@@ -424,14 +437,16 @@ export function applyDirectoryEvent(
|
||||
// always inserted first). Assistant messages never have optimistic parts,
|
||||
// so this check is effectively free during streaming.
|
||||
const hasOptimistic = next.length > 0 && !(next[0] as { sessionID?: string }).sessionID
|
||||
const optimisticIdx = hasOptimistic && (part.type === "text" || part.type === "file")
|
||||
const optimisticIndex = hasOptimistic && (part.type === "text" || part.type === "file")
|
||||
? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID)
|
||||
: -1
|
||||
if (optimisticIdx >= 0) {
|
||||
next.splice(optimisticIdx, 1)
|
||||
if (optimisticIndex >= 0) {
|
||||
// Replace in place: pushing to the end reorders text/file parts of a
|
||||
// just-sent message and remounts its rendered subtree.
|
||||
next[optimisticIndex] = part
|
||||
} else {
|
||||
next.push(part)
|
||||
}
|
||||
const insertResult = Binary.search(next, part.id, (p) => p.id)
|
||||
next.splice(insertResult.index, 0, part)
|
||||
}
|
||||
draft.part[messageID] = next
|
||||
return missingOwningMessage
|
||||
@@ -446,10 +461,10 @@ export function applyDirectoryEvent(
|
||||
const props = event.properties as { messageID: string; partID: string }
|
||||
const parts = draft.part[props.messageID]
|
||||
if (!parts) return false
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
if (result.found) {
|
||||
const partIndex = parts.findIndex((part) => part.id === props.partID)
|
||||
if (partIndex >= 0) {
|
||||
const next = [...parts]
|
||||
next.splice(result.index, 1)
|
||||
next.splice(partIndex, 1)
|
||||
if (next.length === 0) {
|
||||
delete draft.part[props.messageID]
|
||||
} else {
|
||||
@@ -476,21 +491,21 @@ export function applyDirectoryEvent(
|
||||
materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
|
||||
}
|
||||
}
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
if (!result.found) {
|
||||
const partIndex = parts.findIndex((part) => part.id === props.partID)
|
||||
if (partIndex < 0) {
|
||||
syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID)
|
||||
return {
|
||||
changed: false,
|
||||
materialization: { type: "incomplete-session-snapshot", reason: "missing-delta-part", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
|
||||
}
|
||||
}
|
||||
const existing = parts[result.index] as Record<string, unknown>
|
||||
const existing = parts[partIndex] as Record<string, unknown>
|
||||
const existingValue = existing[props.field] as string | undefined
|
||||
const dedupeFields = (existing as DedupeMetadata).__dedupeNextDeltaFields ?? []
|
||||
const shouldDedupe = dedupeFields.includes(props.field)
|
||||
// Create new Part object + new array so React detects the change
|
||||
const next = [...parts]
|
||||
next[result.index] = {
|
||||
next[partIndex] = {
|
||||
...existing,
|
||||
[props.field]: shouldDedupe ? appendNonOverlappingDelta(existingValue, props.delta) : (existingValue ?? "") + props.delta,
|
||||
__dedupeNextDeltaFields: dedupeFields.filter((field) => field !== props.field),
|
||||
|
||||
@@ -2,17 +2,23 @@ import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
applyGlobalSessionStatusEvent,
|
||||
applyGlobalSessionStatusEvents,
|
||||
applyGlobalSessionStatusSnapshot,
|
||||
useGlobalSessionStatusStore,
|
||||
replaceGlobalSessionStatusById,
|
||||
} from "./global-session-status"
|
||||
import { resetSessionOrdering, useSessionOrderingStore } from "./session-ordering"
|
||||
import { resetSessionActivityTiming, useSessionActivityTimingStore } from "./session-activity-timing"
|
||||
|
||||
beforeEach(() => {
|
||||
useGlobalSessionStatusStore.setState({ statusById: new Map() })
|
||||
replaceGlobalSessionStatusById(new Map())
|
||||
resetSessionOrdering()
|
||||
resetSessionActivityTiming()
|
||||
})
|
||||
|
||||
describe("global session status index", () => {
|
||||
const activeSessionIds = (): ReadonlySet<string> => useGlobalSessionStatusStore.getState().activeSessionIds
|
||||
|
||||
test("preserves full retry status details from live events", () => {
|
||||
applyGlobalSessionStatusEvent("/repo", {
|
||||
type: "session.status",
|
||||
@@ -29,6 +35,62 @@ describe("global session status index", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps active membership stable across active status detail and directory updates", () => {
|
||||
applyGlobalSessionStatusEvent("/repo", {
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session-a", status: { type: "busy" } },
|
||||
} as Event)
|
||||
const before = activeSessionIds()
|
||||
|
||||
applyGlobalSessionStatusEvent("/other-repo", {
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session-a", status: { type: "retry", attempt: 2, message: "waiting" } },
|
||||
} as Event)
|
||||
|
||||
expect(activeSessionIds()).toBe(before)
|
||||
})
|
||||
|
||||
test("replaces active membership only when a session becomes idle or active", () => {
|
||||
applyGlobalSessionStatusEvent("/repo", {
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session-a", status: { type: "busy" } },
|
||||
} as Event)
|
||||
const active = activeSessionIds()
|
||||
|
||||
applyGlobalSessionStatusEvent("/repo", {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-a" },
|
||||
} as Event)
|
||||
const idle = activeSessionIds()
|
||||
expect(idle).not.toBe(active)
|
||||
expect(idle?.has("session-a")).toBe(false)
|
||||
|
||||
applyGlobalSessionStatusEvent("/repo", {
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session-a", status: { type: "busy" } },
|
||||
} as Event)
|
||||
expect(activeSessionIds()).not.toBe(idle)
|
||||
expect(activeSessionIds()?.has("session-a")).toBe(true)
|
||||
})
|
||||
|
||||
test("removes deleted sessions from active membership", () => {
|
||||
// SAFETY: This fixture matches the SDK event shape consumed by the status event reducer.
|
||||
applyGlobalSessionStatusEvent("/repo", {
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session-a", status: { type: "busy" } },
|
||||
} as Event)
|
||||
const active = activeSessionIds()
|
||||
|
||||
applyGlobalSessionStatusEvent("/repo", {
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: "session-a" },
|
||||
} as Event)
|
||||
|
||||
expect(activeSessionIds()).not.toBe(active)
|
||||
expect(activeSessionIds().has("session-a")).toBe(false)
|
||||
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
|
||||
})
|
||||
|
||||
test("promotes on active and settled lifecycle edges only", () => {
|
||||
applyGlobalSessionStatusEvent("/repo", {
|
||||
type: "session.status",
|
||||
@@ -64,6 +126,48 @@ describe("global session status index", () => {
|
||||
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps active membership stable for snapshots with the same active IDs", () => {
|
||||
applyGlobalSessionStatusSnapshot("/repo", { "session-a": { type: "busy" } }, ["session-a"])
|
||||
const before = activeSessionIds()
|
||||
|
||||
applyGlobalSessionStatusSnapshot("/repo", {
|
||||
"session-a": { type: "retry" },
|
||||
}, ["session-a"])
|
||||
|
||||
expect(activeSessionIds()).toBe(before)
|
||||
})
|
||||
|
||||
test("updates active membership when a snapshot adds and removes IDs", () => {
|
||||
applyGlobalSessionStatusSnapshot("/repo", { "session-a": { type: "busy" } }, ["session-a"])
|
||||
const before = activeSessionIds()
|
||||
|
||||
applyGlobalSessionStatusSnapshot("/repo", {
|
||||
"session-a": { type: "busy" },
|
||||
"session-b": { type: "busy" },
|
||||
}, ["session-a", "session-b"])
|
||||
const added = activeSessionIds()
|
||||
expect(added).not.toBe(before)
|
||||
expect(added?.has("session-a")).toBe(true)
|
||||
expect(added?.has("session-b")).toBe(true)
|
||||
|
||||
applyGlobalSessionStatusSnapshot("/repo", { "session-b": { type: "busy" } }, ["session-a", "session-b"])
|
||||
const removed = activeSessionIds()
|
||||
expect(removed).not.toBe(added)
|
||||
expect(removed?.has("session-a")).toBe(false)
|
||||
expect(removed?.has("session-b")).toBe(true)
|
||||
})
|
||||
|
||||
test("clears active membership when a runtime reset replaces status state", () => {
|
||||
applyGlobalSessionStatusEvent("/repo", {
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session-a", status: { type: "busy" } },
|
||||
} as Event)
|
||||
|
||||
replaceGlobalSessionStatusById(new Map())
|
||||
|
||||
expect(activeSessionIds()?.size).toBe(0)
|
||||
})
|
||||
|
||||
test("clears an explicitly idle known session when directory aliases differ", () => {
|
||||
applyGlobalSessionStatusSnapshot("/canonical/repo", { "session-a": { type: "busy" } }, ["session-a"])
|
||||
|
||||
@@ -71,4 +175,44 @@ describe("global session status index", () => {
|
||||
|
||||
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
|
||||
})
|
||||
|
||||
test("publishes status, ordering, and timing once for a large event batch", () => {
|
||||
let statusPublications = 0
|
||||
let orderingPublications = 0
|
||||
let timingPublications = 0
|
||||
const unsubscribeStatus = useGlobalSessionStatusStore.subscribe(() => { statusPublications += 1 })
|
||||
const unsubscribeOrdering = useSessionOrderingStore.subscribe(() => { orderingPublications += 1 })
|
||||
const unsubscribeTiming = useSessionActivityTimingStore.subscribe(() => { timingPublications += 1 })
|
||||
const events = Array.from({ length: 1_000 }, (_, index) => ({
|
||||
type: "session.status",
|
||||
properties: { sessionID: `session-${index}`, status: { type: "busy" } },
|
||||
} as Event))
|
||||
|
||||
applyGlobalSessionStatusEvents("/repo", events)
|
||||
|
||||
unsubscribeStatus()
|
||||
unsubscribeOrdering()
|
||||
unsubscribeTiming()
|
||||
expect(useGlobalSessionStatusStore.getState().activeSessionIds.size).toBe(1_000)
|
||||
expect(statusPublications).toBe(1)
|
||||
expect(orderingPublications).toBe(1)
|
||||
expect(timingPublications).toBe(1)
|
||||
})
|
||||
|
||||
test("keeps lifecycle event order inside a batch", () => {
|
||||
applyGlobalSessionStatusEvents("/repo", [
|
||||
{
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session-a", status: { type: "busy" } },
|
||||
} as Event,
|
||||
{
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: "session-a" },
|
||||
} as Event,
|
||||
])
|
||||
|
||||
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
|
||||
expect(useSessionOrderingStore.getState().rankById.has("session-a")).toBe(false)
|
||||
expect(useSessionActivityTimingStore.getState().startedAt.has("session-a")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,15 +2,16 @@ import { create } from 'zustand';
|
||||
import type { Event, SessionStatus } from '@opencode-ai/sdk/v2/client';
|
||||
import { normalizeProjectPath } from '@/lib/projectResolution';
|
||||
import {
|
||||
observeSessionActivityEvent,
|
||||
applySessionOrderingMutations,
|
||||
reconcileSessionActivitySnapshot,
|
||||
removeSessionOrdering,
|
||||
type SessionOrderingMutation,
|
||||
} from './session-ordering';
|
||||
import {
|
||||
observeSessionActivityTiming,
|
||||
applySessionActivityTimingMutations,
|
||||
reconcileSessionActivityTiming,
|
||||
removeSessionActivityTiming,
|
||||
type SessionActivityTimingMutation,
|
||||
} from './session-activity-timing';
|
||||
import { countSyncPerformance } from './performance-diagnostics';
|
||||
|
||||
// Shared live busy/retry index for every directory. Global events update it
|
||||
// incrementally and authoritative directory snapshots reconcile it, so each
|
||||
@@ -26,13 +27,43 @@ type GlobalSessionStatusEntry = { status: SessionStatus; directory: string };
|
||||
|
||||
type GlobalSessionStatusState = {
|
||||
statusById: Map<string, GlobalSessionStatusEntry>;
|
||||
activeSessionIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export const useGlobalSessionStatusStore = create<GlobalSessionStatusState>(() => ({
|
||||
statusById: new Map(),
|
||||
}));
|
||||
const EMPTY_ACTIVE_SESSION_IDS: ReadonlySet<string> = new Set();
|
||||
|
||||
const normalizeStatusType = (type: unknown): ActiveStatusType | 'idle' => {
|
||||
const initialState: GlobalSessionStatusState = {
|
||||
statusById: new Map(),
|
||||
activeSessionIds: EMPTY_ACTIVE_SESSION_IDS,
|
||||
};
|
||||
|
||||
export const useGlobalSessionStatusStore = create<GlobalSessionStatusState>(() => initialState);
|
||||
useGlobalSessionStatusStore.subscribe(() => countSyncPerformance('globalStatusPublications'));
|
||||
|
||||
/**
|
||||
* Replaces the status map wholesale and derives active membership from it.
|
||||
* This is the ONE sanctioned way to swap statusById from outside the event
|
||||
* reducers (runtime switch, tests) — previously a setState monkeypatch
|
||||
* derived membership for arbitrary callers, which silently trusted any
|
||||
* caller passing both fields to keep them consistent.
|
||||
*/
|
||||
export const replaceGlobalSessionStatusById = (statusById: Map<string, GlobalSessionStatusEntry>): void => {
|
||||
const current = useGlobalSessionStatusStore.getState();
|
||||
const nextActiveSessionIds = new Set<string>();
|
||||
for (const [sessionId, entry] of statusById) {
|
||||
if (entry.status.type === 'busy' || entry.status.type === 'retry') {
|
||||
nextActiveSessionIds.add(sessionId);
|
||||
}
|
||||
}
|
||||
const sameMembership = nextActiveSessionIds.size === current.activeSessionIds.size
|
||||
&& [...nextActiveSessionIds].every((sessionId) => current.activeSessionIds.has(sessionId));
|
||||
useGlobalSessionStatusStore.setState({
|
||||
statusById,
|
||||
activeSessionIds: sameMembership ? current.activeSessionIds : nextActiveSessionIds,
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeStatusType = (type: string | undefined): ActiveStatusType | 'idle' => {
|
||||
if (type === 'busy') return 'busy';
|
||||
if (type === 'retry') return 'retry';
|
||||
return 'idle';
|
||||
@@ -48,63 +79,84 @@ const statusesEqual = (left: SessionStatus, right: SessionStatus): boolean => (
|
||||
const normalizeDirectory = (directory: string): string =>
|
||||
normalizeProjectPath(directory) ?? directory;
|
||||
|
||||
const setStatus = (sessionId: string, directory: string, status: SessionStatus | { type: 'idle' }): void => {
|
||||
useGlobalSessionStatusStore.setState((state) => {
|
||||
const current = state.statusById.get(sessionId);
|
||||
if (status.type === 'idle') {
|
||||
if (!current) return state;
|
||||
const next = new Map(state.statusById);
|
||||
next.delete(sessionId);
|
||||
return { statusById: next };
|
||||
}
|
||||
if (current && current.directory === directory && statusesEqual(current.status, status)) return state;
|
||||
const next = new Map(state.statusById);
|
||||
next.set(sessionId, { status, directory });
|
||||
return { statusById: next };
|
||||
});
|
||||
};
|
||||
|
||||
// Event-driven path: called by the sync dispatcher for status-bearing events
|
||||
// whose directory has no child store. Mirrors the child reducer's semantics
|
||||
// (`session.idle` / `session.error` both resolve to idle).
|
||||
export const applyGlobalSessionStatusEvent = (directory: string, payload: Event): void => {
|
||||
switch (payload.type) {
|
||||
case 'session.status': {
|
||||
export const applyGlobalSessionStatusEvents = (directory: string, payloads: readonly Event[]): void => {
|
||||
if (payloads.length === 0) return;
|
||||
const normalizedDirectory = normalizeDirectory(directory);
|
||||
const state = useGlobalSessionStatusStore.getState();
|
||||
let statusById: Map<string, GlobalSessionStatusEntry> | null = null;
|
||||
let activeSessionIds: Set<string> | null = null;
|
||||
const orderingMutations: SessionOrderingMutation[] = [];
|
||||
const timingMutations: SessionActivityTimingMutation[] = [];
|
||||
const currentStatuses = (): ReadonlyMap<string, GlobalSessionStatusEntry> => statusById ?? state.statusById;
|
||||
const draftStatuses = (): Map<string, GlobalSessionStatusEntry> => (statusById ??= new Map(state.statusById));
|
||||
const draftActiveIds = (): Set<string> => (activeSessionIds ??= new Set(state.activeSessionIds));
|
||||
const settle = (sessionId: string): void => {
|
||||
if (currentStatuses().has(sessionId)) {
|
||||
draftStatuses().delete(sessionId);
|
||||
draftActiveIds().delete(sessionId);
|
||||
}
|
||||
orderingMutations.push({ type: 'observe', sessionId, phase: 'settled' });
|
||||
timingMutations.push({ type: 'observe', sessionId, phase: 'settled' });
|
||||
};
|
||||
|
||||
for (const payload of payloads) {
|
||||
if (payload.type === 'session.status') {
|
||||
// SAFETY: OpenCode event properties for this event contain the optional session ID and status payload.
|
||||
const props = payload.properties as { sessionID?: string; status?: { type?: string } } | undefined;
|
||||
if (typeof props?.sessionID !== 'string' || !props.sessionID) return;
|
||||
if (typeof props?.sessionID !== 'string' || !props.sessionID) continue;
|
||||
const type = normalizeStatusType(props.status?.type);
|
||||
setStatus(
|
||||
props.sessionID,
|
||||
normalizeDirectory(directory),
|
||||
type === 'idle' ? { type: 'idle' } : { ...(props.status ?? {}), type } as SessionStatus,
|
||||
);
|
||||
observeSessionActivityEvent(props.sessionID, type === 'idle' ? 'settled' : 'active');
|
||||
// `retry` is still a running turn, so the elapsed counter keeps going.
|
||||
observeSessionActivityTiming(props.sessionID, type === 'idle' ? 'settled' : 'active');
|
||||
return;
|
||||
}
|
||||
case 'session.idle':
|
||||
case 'session.error': {
|
||||
const props = payload.properties as { sessionID?: string } | undefined;
|
||||
if (typeof props?.sessionID === 'string' && props.sessionID) {
|
||||
setStatus(props.sessionID, normalizeDirectory(directory), { type: 'idle' });
|
||||
observeSessionActivityEvent(props.sessionID, 'settled');
|
||||
observeSessionActivityTiming(props.sessionID, 'settled');
|
||||
if (type === 'idle') {
|
||||
settle(props.sessionID);
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
// SAFETY: the normalized discriminator is one of the SDK's active status types.
|
||||
const status = { ...(props.status ?? {}), type } as SessionStatus;
|
||||
const current = currentStatuses().get(props.sessionID);
|
||||
if (!current || current.directory !== normalizedDirectory || !statusesEqual(current.status, status)) {
|
||||
draftStatuses().set(props.sessionID, { status, directory: normalizedDirectory });
|
||||
if (!current) draftActiveIds().add(props.sessionID);
|
||||
}
|
||||
orderingMutations.push({ type: 'observe', sessionId: props.sessionID, phase: 'active' });
|
||||
timingMutations.push({ type: 'observe', sessionId: props.sessionID, phase: 'active' });
|
||||
continue;
|
||||
}
|
||||
case 'session.deleted': {
|
||||
|
||||
if (payload.type === 'session.idle' || payload.type === 'session.error') {
|
||||
// SAFETY: OpenCode terminal event properties contain the optional addressed session ID.
|
||||
const props = payload.properties as { sessionID?: string } | undefined;
|
||||
if (typeof props?.sessionID === 'string' && props.sessionID) settle(props.sessionID);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (payload.type === 'session.deleted') {
|
||||
// SAFETY: OpenCode deletion event properties identify the deleted session directly or through info.id.
|
||||
const props = payload.properties as { sessionID?: string; info?: { id?: string } } | undefined;
|
||||
const sessionId = props?.sessionID ?? props?.info?.id;
|
||||
if (sessionId) {
|
||||
removeSessionOrdering(sessionId);
|
||||
removeSessionActivityTiming(sessionId);
|
||||
if (!sessionId) continue;
|
||||
if (currentStatuses().has(sessionId)) {
|
||||
draftStatuses().delete(sessionId);
|
||||
draftActiveIds().delete(sessionId);
|
||||
}
|
||||
return;
|
||||
orderingMutations.push({ type: 'remove', sessionId });
|
||||
timingMutations.push({ type: 'remove', sessionId });
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusById) {
|
||||
useGlobalSessionStatusStore.setState({
|
||||
statusById,
|
||||
activeSessionIds: activeSessionIds ?? state.activeSessionIds,
|
||||
});
|
||||
}
|
||||
applySessionOrderingMutations(orderingMutations);
|
||||
applySessionActivityTimingMutations(timingMutations);
|
||||
};
|
||||
|
||||
export const applyGlobalSessionStatusEvent = (directory: string, payload: Event): void => {
|
||||
applyGlobalSessionStatusEvents(directory, [payload]);
|
||||
};
|
||||
|
||||
// Polled path: an authoritative `/session/status?directory=X` snapshot. Entries
|
||||
@@ -137,10 +189,25 @@ export const applyGlobalSessionStatusSnapshot = (
|
||||
useGlobalSessionStatusStore.setState((state) => {
|
||||
let changed = false;
|
||||
const next = new Map(state.statusById);
|
||||
let nextActiveSessionIds: Set<string> | null = null;
|
||||
const hasActiveSession = (sessionId: string): boolean => (
|
||||
(nextActiveSessionIds ?? state.activeSessionIds).has(sessionId)
|
||||
);
|
||||
const removeActiveSession = (sessionId: string): void => {
|
||||
if (!hasActiveSession(sessionId)) return;
|
||||
nextActiveSessionIds ??= new Set(state.activeSessionIds);
|
||||
nextActiveSessionIds.delete(sessionId);
|
||||
};
|
||||
const addActiveSession = (sessionId: string): void => {
|
||||
if (hasActiveSession(sessionId)) return;
|
||||
nextActiveSessionIds ??= new Set(state.activeSessionIds);
|
||||
nextActiveSessionIds.add(sessionId);
|
||||
};
|
||||
|
||||
for (const [sessionId, entry] of state.statusById) {
|
||||
if ((entry.directory === directory || known.has(sessionId)) && !(sessionId in raw)) {
|
||||
next.delete(sessionId);
|
||||
removeActiveSession(sessionId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -151,17 +218,23 @@ export const applyGlobalSessionStatusSnapshot = (
|
||||
if (type === 'idle') {
|
||||
if (current && (current.directory === directory || known.has(sessionId))) {
|
||||
next.delete(sessionId);
|
||||
removeActiveSession(sessionId);
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// SAFETY: normalizeStatusType has narrowed this snapshot entry to the SDK's busy/retry status discriminator.
|
||||
const normalizedStatus = { ...status, type } as SessionStatus;
|
||||
if (!current || current.directory !== directory || !statusesEqual(current.status, normalizedStatus)) {
|
||||
next.set(sessionId, { status: normalizedStatus, directory });
|
||||
if (!current) addActiveSession(sessionId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? { statusById: next } : state;
|
||||
return changed ? {
|
||||
statusById: next,
|
||||
activeSessionIds: nextActiveSessionIds ?? state.activeSessionIds,
|
||||
} : state;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -276,4 +276,98 @@ describe("input-store attachments", () => {
|
||||
const textAttachment = useInputStore.getState().attachedFiles[1]
|
||||
expect((await textAttachment?.file.text())?.includes("[design-image-2.png]")).toBe(true)
|
||||
})
|
||||
|
||||
testWithMockFileReader("extracted document entries share a sourceDocumentId for cascade removal", async () => {
|
||||
const archive = zipSync({
|
||||
"word/document.xml": strToU8(`<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>Diagram</w:t><a:blip r:embed="rId1"/></w:p></w:body></w:document>`),
|
||||
"word/_rels/document.xml.rels": strToU8(`<Relationships><Relationship Id="rId1" Target="media/image.png" Type="image"/></Relationships>`),
|
||||
"word/media/image.png": pngBytes,
|
||||
})
|
||||
const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "design.docx"))
|
||||
|
||||
await waitForReaderCount(1)
|
||||
resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=")
|
||||
await waitForReaderCount(2)
|
||||
resolveReader(pendingReaders[1], "data:image/png;base64,AQID")
|
||||
|
||||
expect(await addPromise).toBe(true)
|
||||
const files = useInputStore.getState().attachedFiles
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files[0].filename).toBe("design.docx")
|
||||
expect(files[1].filename).toBe("design-image-1.png")
|
||||
|
||||
// All entries from the same document extraction share the same sourceDocumentId
|
||||
expect(files[0].sourceDocumentId).toBeDefined()
|
||||
expect(files[0].sourceDocumentId).toBe(files[1].sourceDocumentId)
|
||||
|
||||
// Removing any entry in the group cascade-removes all entries
|
||||
useInputStore.getState().removeAttachedFile(files[0].id)
|
||||
expect(useInputStore.getState().attachedFiles).toEqual([])
|
||||
})
|
||||
|
||||
testWithMockFileReader("removing an extracted image child also cascade-removes the document group", async () => {
|
||||
const archive = zipSync({
|
||||
"word/document.xml": strToU8(`<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>Diagram</w:t><a:blip r:embed="rId1"/></w:p></w:body></w:document>`),
|
||||
"word/_rels/document.xml.rels": strToU8(`<Relationships><Relationship Id="rId1" Target="media/image.png" Type="image"/></Relationships>`),
|
||||
"word/media/image.png": pngBytes,
|
||||
})
|
||||
const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "design.docx"))
|
||||
|
||||
await waitForReaderCount(1)
|
||||
resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=")
|
||||
await waitForReaderCount(2)
|
||||
resolveReader(pendingReaders[1], "data:image/png;base64,AQID")
|
||||
|
||||
expect(await addPromise).toBe(true)
|
||||
const files = useInputStore.getState().attachedFiles
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files[0].filename).toBe("design.docx")
|
||||
expect(files[1].filename).toBe("design-image-1.png")
|
||||
|
||||
// Removing the image child also cascade-removes the entire group
|
||||
useInputStore.getState().removeAttachedFile(files[1].id)
|
||||
expect(useInputStore.getState().attachedFiles).toEqual([])
|
||||
})
|
||||
|
||||
testWithMockFileReader("non-document attachments do not have sourceDocumentId and remove individually", async () => {
|
||||
const addPromise = useInputStore.getState().addAttachedFile(
|
||||
new File(["hello"], "hello.txt", { type: "text/plain" })
|
||||
)
|
||||
expect(pendingReaders).toHaveLength(1)
|
||||
resolveReader(pendingReaders[0], "data:text/plain;base64,aGVsbG8=")
|
||||
|
||||
expect(await addPromise).toBe(true)
|
||||
const files = useInputStore.getState().attachedFiles
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0].sourceDocumentId).toBe(undefined)
|
||||
|
||||
useInputStore.getState().removeAttachedFile(files[0].id)
|
||||
expect(useInputStore.getState().attachedFiles).toEqual([])
|
||||
})
|
||||
|
||||
testWithMockFileReader("PPTX slide extraction cascades removal of all slide images", async () => {
|
||||
const archive = zipSync({
|
||||
"ppt/slides/slide1.xml": strToU8(`<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree><p:pic><p:nvPicPr/><p:blipFill><a:blip r:embed="rId1"/></p:blipFill></p:pic></p:spTree></p:cSld></p:sld>`),
|
||||
"ppt/slides/_rels/slide1.xml.rels": strToU8(`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Target="../media/image1.png" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/></Relationships>`),
|
||||
"ppt/media/image1.png": pngBytes,
|
||||
})
|
||||
const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "deck.pptx"))
|
||||
|
||||
await waitForReaderCount(1)
|
||||
resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=")
|
||||
await waitForReaderCount(2)
|
||||
resolveReader(pendingReaders[1], "data:image/png;base64,AQID")
|
||||
|
||||
expect(await addPromise).toBe(true)
|
||||
const files = useInputStore.getState().attachedFiles
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files[0].filename).toBe("deck.pptx")
|
||||
expect(files[1].filename).toBe("deck-image-1.png")
|
||||
expect(files[0].sourceDocumentId).toBeDefined()
|
||||
expect(files[0].sourceDocumentId).toBe(files[1].sourceDocumentId)
|
||||
|
||||
// Removing the text entry cascades to the slide image
|
||||
useInputStore.getState().removeAttachedFile(files[0].id)
|
||||
expect(useInputStore.getState().attachedFiles).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { create } from "zustand"
|
||||
import type { ContextPartMetadata } from '@/lib/messages/contextParts'
|
||||
import type { AttachedFile } from "@/stores/types/sessionTypes"
|
||||
import { prepareAttachmentFiles } from "./attachment-files"
|
||||
|
||||
@@ -54,6 +55,35 @@ const readFileAsDataUrl = (file: File, mime: string): Promise<string> => new Pro
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
export const prepareLocalAttachments = async (
|
||||
file: File,
|
||||
reservedFilenames: Iterable<string> = [],
|
||||
): Promise<AttachedFile[] | undefined> => {
|
||||
const preparedOrPending = prepareAttachmentFiles(file, reservedFilenames)
|
||||
const preparedFiles = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
|
||||
if (!preparedFiles || preparedFiles.length === 0) return
|
||||
|
||||
const sourceDocumentId = preparedFiles.length > 1
|
||||
? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
: undefined
|
||||
const attachedFiles: AttachedFile[] = []
|
||||
for (const prepared of preparedFiles) {
|
||||
const dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
|
||||
if (!dataUrl) return
|
||||
attachedFiles.push({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
file: prepared.file,
|
||||
dataUrl,
|
||||
mimeType: prepared.mimeType,
|
||||
filename: prepared.file.name,
|
||||
size: prepared.file.size,
|
||||
source: "local",
|
||||
sourceDocumentId,
|
||||
})
|
||||
}
|
||||
return attachedFiles
|
||||
}
|
||||
|
||||
const getDataUrlByteSize = (url: string): number => {
|
||||
if (!url.startsWith("data:")) return 0
|
||||
const commaIndex = url.indexOf(",")
|
||||
@@ -86,6 +116,7 @@ export type SyntheticContextPart = {
|
||||
text: string
|
||||
attachments?: AttachedFile[]
|
||||
synthetic?: boolean
|
||||
metadata?: ContextPartMetadata
|
||||
}
|
||||
|
||||
export type VSCodeActiveEditorFile = {
|
||||
@@ -167,34 +198,17 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
||||
const generation = attachmentReadGeneration
|
||||
for (let attempt = 0; attempt < MAX_ATTACHMENT_PREPARATION_ATTEMPTS; attempt += 1) {
|
||||
const reservedFilenames = get().attachedFiles.map((attachment) => attachment.filename)
|
||||
const preparedOrPending = prepareAttachmentFiles(file, reservedFilenames)
|
||||
const preparedFiles = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
|
||||
if (!preparedFiles || preparedFiles.length === 0 || generation !== attachmentReadGeneration) return false
|
||||
|
||||
const generatedFilenames = preparedFiles.slice(1).map((prepared) => prepared.file.name)
|
||||
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
|
||||
|
||||
const attachedFiles: AttachedFile[] = []
|
||||
for (const prepared of preparedFiles) {
|
||||
let dataUrl: string
|
||||
try {
|
||||
dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!dataUrl || generation !== attachmentReadGeneration) return false
|
||||
attachedFiles.push({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
file: prepared.file,
|
||||
dataUrl,
|
||||
mimeType: prepared.mimeType,
|
||||
filename: prepared.file.name,
|
||||
size: prepared.file.size,
|
||||
source: "local",
|
||||
})
|
||||
let attachedFiles: AttachedFile[] | undefined
|
||||
try {
|
||||
attachedFiles = await prepareLocalAttachments(file, reservedFilenames)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!attachedFiles || generation !== attachmentReadGeneration) return false
|
||||
|
||||
const generatedFilenames = attachedFiles.slice(1).map((attachment) => attachment.filename)
|
||||
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
|
||||
|
||||
set((state) => ({ attachedFiles: [...state.attachedFiles, ...attachedFiles] }))
|
||||
return true
|
||||
}
|
||||
@@ -202,7 +216,13 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
||||
},
|
||||
|
||||
removeAttachedFile: (id) =>
|
||||
set((s) => ({ attachedFiles: s.attachedFiles.filter((f) => f.id !== id) })),
|
||||
set((s) => {
|
||||
const target = s.attachedFiles.find((f) => f.id === id)
|
||||
if (target?.sourceDocumentId) {
|
||||
return { attachedFiles: s.attachedFiles.filter((f) => f.sourceDocumentId !== target.sourceDocumentId) }
|
||||
}
|
||||
return { attachedFiles: s.attachedFiles.filter((f) => f.id !== id) }
|
||||
}),
|
||||
|
||||
setAttachedFiles: (files) => {
|
||||
attachmentReadGeneration += 1
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { mergeMessages } from "./optimistic"
|
||||
import type { SessionMaterializationReason } from "./event-reducer"
|
||||
import { sortMessagesChronologically } from "./message-ordering"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const STREAMING_PART_FIELDS = ["text", "output"] as const
|
||||
const ACTIVE_TOOL_STATUSES = new Set(["pending", "running"])
|
||||
const FINAL_TOOL_STATUSES = new Set(["completed", "error", "aborted", "failed", "timeout", "cancelled"])
|
||||
@@ -93,10 +93,9 @@ export function getStaleRunningToolMessageID(
|
||||
return undefined
|
||||
}
|
||||
|
||||
function sortParts(parts: Part[], skipPartTypes: ReadonlySet<string>) {
|
||||
function filterMaterializedParts(parts: Part[], skipPartTypes: ReadonlySet<string>): Part[] {
|
||||
return parts
|
||||
.filter((part) => !!part?.id && !skipPartTypes.has(part.type))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean {
|
||||
@@ -252,7 +251,7 @@ function mergeMaterializedParts(
|
||||
)
|
||||
if (missingLiveParts.length === 0) return mergedParts
|
||||
|
||||
return [...mergedParts, ...missingLiveParts].sort((a, b) => cmp(a.id, b.id))
|
||||
return [...mergedParts, ...missingLiveParts]
|
||||
}
|
||||
|
||||
export function materializeSessionSnapshots(
|
||||
@@ -262,13 +261,30 @@ export function materializeSessionSnapshots(
|
||||
options: MaterializeSessionSnapshotsOptions = {},
|
||||
): MaterializeSessionSnapshotsResult {
|
||||
const skipPartTypes = options.skipPartTypes ?? new Set<string>()
|
||||
const snapshots = records
|
||||
.filter((record) => !!record?.info?.id)
|
||||
.sort((left, right) => cmp(left.info.id, right.info.id))
|
||||
const nextMessages = snapshots.map((record) => record.info)
|
||||
const recordsByMessageID = new Map(
|
||||
records
|
||||
.filter((record) => !!record?.info?.id)
|
||||
.map((record) => [record.info.id, record] as const),
|
||||
)
|
||||
const nextMessages = sortMessagesChronologically([...recordsByMessageID.values()].map((record) => record.info))
|
||||
const snapshots = nextMessages.map((message) => recordsByMessageID.get(message.id)!)
|
||||
const existingMessages = state.message[sessionID]
|
||||
const currentMessages = existingMessages ?? []
|
||||
const messages = mergeMessages(currentMessages, nextMessages)
|
||||
const incomingByID = new Map(nextMessages.map((message) => [message.id, message] as const))
|
||||
let reconciledCurrentMessages = currentMessages
|
||||
for (let index = 0; index < currentMessages.length; index += 1) {
|
||||
const existing = currentMessages[index]
|
||||
const incoming = incomingByID.get(existing.id)
|
||||
if (
|
||||
existing.role !== "assistant"
|
||||
|| existing.error?.name !== "MessageAbortedError"
|
||||
|| incoming?.role !== "assistant"
|
||||
|| incoming.time.completed === undefined
|
||||
) continue
|
||||
if (reconciledCurrentMessages === currentMessages) reconciledCurrentMessages = [...currentMessages]
|
||||
reconciledCurrentMessages[index] = incoming
|
||||
}
|
||||
const messages = mergeMessages(reconciledCurrentMessages, nextMessages)
|
||||
const messagesChanged = messages !== currentMessages || (existingMessages === undefined && snapshots.length === 0)
|
||||
|
||||
let partsChanged = false
|
||||
@@ -283,7 +299,7 @@ export function materializeSessionSnapshots(
|
||||
const existing = nextPartState[messageID]
|
||||
const nextParts = mergeMaterializedParts(
|
||||
existing,
|
||||
sortParts(record.parts ?? [], skipPartTypes),
|
||||
filterMaterializedParts(record.parts ?? [], skipPartTypes),
|
||||
skipPartTypes,
|
||||
isAssistant,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
insertMessageChronologically,
|
||||
messagesBefore,
|
||||
messagesFrom,
|
||||
sortMessagesChronologically,
|
||||
} from "./message-ordering"
|
||||
|
||||
const message = (id: string, created: number): Message => ({
|
||||
id,
|
||||
sessionID: "session-a",
|
||||
role: "user",
|
||||
time: { created },
|
||||
} as Message)
|
||||
|
||||
describe("message chronology", () => {
|
||||
test("orders post-rollover IDs after legacy IDs by creation time", () => {
|
||||
const legacy = message("msg_ffffffffffffLegacy", 100)
|
||||
const current = message("msg_000000000000Current", 200)
|
||||
|
||||
expect(sortMessagesChronologically([current, legacy])).toEqual([legacy, current])
|
||||
|
||||
const messages = [legacy]
|
||||
insertMessageChronologically(messages, current)
|
||||
expect(messages).toEqual([legacy, current])
|
||||
})
|
||||
|
||||
test("uses ID only as a deterministic equal-time tie breaker", () => {
|
||||
const second = message("msg_b", 100)
|
||||
const first = message("msg_a", 100)
|
||||
expect(sortMessagesChronologically([second, first])).toEqual([first, second])
|
||||
|
||||
const messages = [second]
|
||||
insertMessageChronologically(messages, first)
|
||||
expect(messages).toEqual([first, second])
|
||||
})
|
||||
|
||||
test("splits a revert branch by marker position instead of ID value", () => {
|
||||
const before = message("msg_ffffBefore", 100)
|
||||
const marker = message("msg_0000Marker", 200)
|
||||
const after = message("msg_0001After", 300)
|
||||
const messages = [before, marker, after]
|
||||
|
||||
expect(messagesBefore(messages, marker.id)).toEqual([before])
|
||||
expect(messagesFrom(messages, marker.id)).toEqual([marker, after])
|
||||
})
|
||||
|
||||
test("does not destructively split when the marker is not materialized", () => {
|
||||
const messages = [message("msg_a", 100)]
|
||||
expect(messagesBefore(messages, "missing")).toBe(messages)
|
||||
expect(messagesFrom(messages, "missing")).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
const getCreatedAt = (message: Message): number => {
|
||||
const value = (message as { time?: { created?: unknown } }).time?.created
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Message IDs identify records; they are not chronology. OpenCode's sortable
|
||||
* ID timestamp rolls over, so a newly created `msg_000...` can follow a legacy
|
||||
* `msg_fff...`. Creation time is the authoritative transcript order, with ID
|
||||
* used only to make equal timestamps deterministic.
|
||||
*/
|
||||
export const compareMessagesChronologically = (left: Message, right: Message): number => {
|
||||
const createdAtDifference = getCreatedAt(left) - getCreatedAt(right)
|
||||
if (createdAtDifference !== 0) return createdAtDifference
|
||||
if (left.id < right.id) return -1
|
||||
if (left.id > right.id) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
export const sortMessagesChronologically = <T extends Message>(messages: readonly T[]): T[] => (
|
||||
[...messages].sort(compareMessagesChronologically)
|
||||
)
|
||||
|
||||
export const findMessageIndex = (messages: readonly Message[], messageID: string): number => (
|
||||
messages.findIndex((message) => message.id === messageID)
|
||||
)
|
||||
|
||||
export const insertMessageChronologically = <T extends Message>(messages: T[], message: T): number => {
|
||||
let low = 0
|
||||
let high = messages.length
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1
|
||||
if (compareMessagesChronologically(messages[middle], message) < 0) {
|
||||
low = middle + 1
|
||||
} else {
|
||||
high = middle
|
||||
}
|
||||
}
|
||||
messages.splice(low, 0, message)
|
||||
return low
|
||||
}
|
||||
|
||||
/** Return the messages before the marker's current array position. */
|
||||
export const messagesBefore = <T extends Message>(messages: readonly T[], messageID?: string): T[] => {
|
||||
if (!messageID) return messages as T[]
|
||||
const index = findMessageIndex(messages, messageID)
|
||||
return index < 0 ? messages as T[] : messages.slice(0, index)
|
||||
}
|
||||
|
||||
/** Return the marker and all messages after its current array position. */
|
||||
export const messagesFrom = <T extends Message>(messages: readonly T[], messageID?: string): T[] => {
|
||||
if (!messageID) return []
|
||||
const index = findMessageIndex(messages, messageID)
|
||||
return index < 0 ? [] : messages.slice(index)
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { Binary } from "./binary"
|
||||
import { sortMessagesChronologically } from "./message-ordering"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
function filterIdentifiedParts(parts: Part[]): Part[] {
|
||||
return parts.filter((part) => !!part?.id)
|
||||
}
|
||||
|
||||
export type OptimisticItem = {
|
||||
@@ -19,22 +17,24 @@ export type MessagePage = {
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return want.length === 0
|
||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||
const containsAllPartsByID = (currentParts: Part[] | undefined, requiredParts: Part[]) => {
|
||||
if (!currentParts) return requiredParts.length === 0
|
||||
const currentPartIDs = new Set(currentParts.map((part) => part.id))
|
||||
return requiredParts.every((part) => currentPartIDs.has(part.id))
|
||||
}
|
||||
|
||||
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return sortParts(want)
|
||||
const next = [...parts]
|
||||
const mergeParts = (currentParts: Part[] | undefined, optimisticParts: Part[]) => {
|
||||
if (!currentParts) return filterIdentifiedParts(optimisticParts)
|
||||
const next = [...currentParts]
|
||||
const partIDs = new Set(currentParts.map((part) => part.id))
|
||||
let changed = false
|
||||
for (const part of want) {
|
||||
const result = Binary.search(next, part.id, (item) => item.id)
|
||||
if (result.found) continue
|
||||
next.splice(result.index, 0, part)
|
||||
for (const part of optimisticParts) {
|
||||
if (partIDs.has(part.id)) continue
|
||||
partIDs.add(part.id)
|
||||
next.push(part)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return parts
|
||||
if (!changed) return currentParts
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -42,46 +42,47 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
|
||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
||||
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
|
||||
const messageIDs = new Set(session.map((message) => message.id))
|
||||
const partsByMessageID = new Map(page.part.map((item) => [item.id, filterIdentifiedParts(item.part)]))
|
||||
const confirmed: string[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
||||
const found = result.found
|
||||
if (!found) session.splice(result.index, 0, item.message)
|
||||
const messageExists = messageIDs.has(item.message.id)
|
||||
if (!messageExists) {
|
||||
messageIDs.add(item.message.id)
|
||||
session.push(item.message)
|
||||
}
|
||||
|
||||
const current = part.get(item.message.id)
|
||||
if (found && hasParts(current, item.parts)) {
|
||||
const currentParts = partsByMessageID.get(item.message.id)
|
||||
if (messageExists && containsAllPartsByID(currentParts, item.parts)) {
|
||||
confirmed.push(item.message.id)
|
||||
continue
|
||||
}
|
||||
|
||||
part.set(item.message.id, mergeParts(current, item.parts))
|
||||
partsByMessageID.set(item.message.id, mergeParts(currentParts, item.parts))
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: page.cursor,
|
||||
complete: page.complete,
|
||||
session,
|
||||
part: [...part.entries()]
|
||||
.sort((a, b) => cmp(a[0], b[0]))
|
||||
.map(([id, part]) => ({ id, part })),
|
||||
session: sortMessagesChronologically(session),
|
||||
part: [...partsByMessageID].map(([id, part]) => ({ id, part })),
|
||||
confirmed,
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge two sorted message arrays by id, deduplicating.
|
||||
* Preserves references from `a` for items that already exist — avoids
|
||||
/** Merge two chronologically sorted message arrays by identity, deduplicating.
|
||||
* Preserves existing references for items that already exist — avoids
|
||||
* unnecessary React re-renders when prepending older history. */
|
||||
export function mergeMessages<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||
const existing = new Map(a.map((item) => [item.id, item] as const))
|
||||
export function mergeMessages<T extends Message>(existingMessages: readonly T[], incomingMessages: readonly T[]) {
|
||||
const messagesByID = new Map(existingMessages.map((item) => [item.id, item] as const))
|
||||
let changed = false
|
||||
for (const item of b) {
|
||||
if (!existing.has(item.id)) {
|
||||
existing.set(item.id, item)
|
||||
for (const item of incomingMessages) {
|
||||
if (!messagesByID.has(item.id)) {
|
||||
messagesByID.set(item.id, item)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (!changed) return a as T[]
|
||||
return [...existing.values()].sort((x, y) => cmp(x.id, y.id))
|
||||
if (!changed) return existingMessages as T[]
|
||||
return sortMessagesChronologically([...messagesByID.values()])
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ export type SyncPerformanceCounters = {
|
||||
reducerEvents: number
|
||||
reducerChangedEvents: number
|
||||
directoryStorePublications: number
|
||||
globalSessionPublications: number
|
||||
globalStatusPublications: number
|
||||
orderingPublications: number
|
||||
timingPublications: number
|
||||
liveSessionAggregateRuns: number
|
||||
sidebarStructureBuilds: number
|
||||
sidebarOrderBuilds: number
|
||||
sidebarOrderMetadataEntries: number
|
||||
recentCandidatesVisited: number
|
||||
streamingFullReconciliations: number
|
||||
streamingIncrementalReconciliations: number
|
||||
streamingStatusEntriesVisited: number
|
||||
@@ -24,6 +33,7 @@ export type SyncPerformanceCounters = {
|
||||
streamingHeartbeatAttempts: number
|
||||
streamingHeartbeatCommits: number
|
||||
permissionChangeCallbacks: number
|
||||
questionChangeCallbacks: number
|
||||
sessionMessageChangeCallbacks: number
|
||||
sessionRenderableNotificationSkips: number
|
||||
userMessageHistoryNotificationSkips: number
|
||||
@@ -49,6 +59,15 @@ const createCounters = (): SyncPerformanceCounters => ({
|
||||
reducerEvents: 0,
|
||||
reducerChangedEvents: 0,
|
||||
directoryStorePublications: 0,
|
||||
globalSessionPublications: 0,
|
||||
globalStatusPublications: 0,
|
||||
orderingPublications: 0,
|
||||
timingPublications: 0,
|
||||
liveSessionAggregateRuns: 0,
|
||||
sidebarStructureBuilds: 0,
|
||||
sidebarOrderBuilds: 0,
|
||||
sidebarOrderMetadataEntries: 0,
|
||||
recentCandidatesVisited: 0,
|
||||
streamingFullReconciliations: 0,
|
||||
streamingIncrementalReconciliations: 0,
|
||||
streamingStatusEntriesVisited: 0,
|
||||
@@ -57,6 +76,7 @@ const createCounters = (): SyncPerformanceCounters => ({
|
||||
streamingHeartbeatAttempts: 0,
|
||||
streamingHeartbeatCommits: 0,
|
||||
permissionChangeCallbacks: 0,
|
||||
questionChangeCallbacks: 0,
|
||||
sessionMessageChangeCallbacks: 0,
|
||||
sessionRenderableNotificationSkips: 0,
|
||||
userMessageHistoryNotificationSkips: 0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { switchRuntimeEndpoint } from "@/lib/runtime-switch"
|
||||
import { persistSessions, readDirCache } from "./persist-cache"
|
||||
import { persistManagedChatSessions, persistSessions, readDirCache, readManagedChatSessions } from "./persist-cache"
|
||||
import { getSyncPerformanceDiagnostics, setSyncPerformanceDiagnosticsEnabled } from "./performance-diagnostics"
|
||||
|
||||
class TestStorage implements Storage {
|
||||
@@ -81,6 +81,17 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe("persisted directory sessions", () => {
|
||||
test("keeps one runtime-scoped startup snapshot for managed chats", async () => {
|
||||
const chat = session(1, 2, "Chat", "/home/user/.config/openchamber/chats/2026-08-21/session-a")
|
||||
persistManagedChatSessions([session(2, 3), chat])
|
||||
await waitForPersistence()
|
||||
|
||||
expect(readManagedChatSessions().map((item) => item.id)).toEqual([chat.id])
|
||||
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-other.test", runtimeKey: "runtime-other" })
|
||||
expect(readManagedChatSessions()).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps the 50 most recently updated sessions across restart reads", async () => {
|
||||
const sessions = Array.from({ length: 60 }, (_, updated) => session(59 - updated, updated))
|
||||
|
||||
|
||||
@@ -10,11 +10,14 @@ import type { Session, VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ProjectMeta } from "./types"
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch"
|
||||
import { countSyncPersistenceSerialization, countSyncPersistenceStorageWrite } from "./performance-diagnostics"
|
||||
import { isChatDirectoryPath } from "@/lib/chatDirectories"
|
||||
import { isVSCodeRuntime } from "@/lib/desktop"
|
||||
|
||||
/** Cap persisted session lists so localStorage stays bounded per directory. */
|
||||
const PERSISTED_SESSION_LIMIT = 50
|
||||
const SESSION_CACHE_FALLBACK_LIMITS = [PERSISTED_SESSION_LIMIT, 25, 10, 5, 1] as const
|
||||
const SESSION_PERSIST_DEBOUNCE_MS = 50
|
||||
const MANAGED_CHATS_CACHE_SCOPE = "openchamber:managed-chats"
|
||||
|
||||
type PendingSessionWrite = {
|
||||
runtimeKey: string
|
||||
@@ -241,6 +244,21 @@ export function persistSessions(directory: string, sessions: Session[] | undefin
|
||||
scheduleSessionCacheWrite(directory, sessions)
|
||||
}
|
||||
|
||||
export function readManagedChatSessions(expectedRuntimeKey = getRuntimeKey()): Session[] {
|
||||
if (isVSCodeRuntime()) return []
|
||||
if (expectedRuntimeKey !== getRuntimeKey()) return []
|
||||
return readDirCache(MANAGED_CHATS_CACHE_SCOPE).sessions?.filter((session) => (
|
||||
isChatDirectoryPath(session.directory)
|
||||
)) ?? []
|
||||
}
|
||||
|
||||
export function persistManagedChatSessions(sessions: Session[]): void {
|
||||
if (isVSCodeRuntime()) return
|
||||
persistSessions(MANAGED_CHATS_CACHE_SCOPE, sessions.filter((session) => (
|
||||
isChatDirectoryPath(session.directory)
|
||||
)))
|
||||
}
|
||||
|
||||
/** Write vcs info to cache */
|
||||
export function persistVcs(directory: string, vcs: VcsInfo | undefined): void {
|
||||
writeCache(directory, "vcs", vcs)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from "./question-recovery"
|
||||
|
||||
const message = (role: "user" | "assistant", parts: Part[] = []) => ({
|
||||
info: { id: `${role}-${parts.length}`, sessionID: "ses_1", role } as Message,
|
||||
parts,
|
||||
})
|
||||
|
||||
const questionTool = (status: "pending" | "running" | "completed"): Part => ({
|
||||
id: `tool-${status}`,
|
||||
sessionID: "ses_1",
|
||||
messageID: "assistant-1",
|
||||
type: "tool",
|
||||
tool: "question",
|
||||
state: { status, input: {}, output: "", title: "", metadata: {}, time: { start: 1, end: status === "completed" ? 2 : undefined } },
|
||||
} as Part)
|
||||
|
||||
describe("hasActiveQuestionToolInCurrentTurn", () => {
|
||||
test("detects a pending or running question in the current turn", () => {
|
||||
expect(hasActiveQuestionToolInCurrentTurn([message("user"), message("assistant", [questionTool("pending")])])).toBe(true)
|
||||
expect(hasActiveQuestionToolInCurrentTurn([message("user"), message("assistant", [questionTool("running")])])).toBe(true)
|
||||
})
|
||||
|
||||
test("ignores completed questions and active questions from an older turn", () => {
|
||||
expect(hasActiveQuestionToolInCurrentTurn([message("assistant", [questionTool("completed")])])).toBe(false)
|
||||
expect(hasActiveQuestionToolInCurrentTurn([
|
||||
message("assistant", [questionTool("running")]),
|
||||
message("user"),
|
||||
message("assistant"),
|
||||
])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("recoverPendingQuestionWithRetry", () => {
|
||||
test("retries the cold-start inconsistency with bounded delays and stops on recovery", async () => {
|
||||
const delays: number[] = []
|
||||
let attempts = 0
|
||||
|
||||
const recovered = await recoverPendingQuestionWithRetry(
|
||||
async () => {
|
||||
attempts += 1
|
||||
return attempts === 3
|
||||
},
|
||||
{ sleep: async (delayMs) => { delays.push(delayMs) } },
|
||||
)
|
||||
|
||||
expect(recovered).toBe(true)
|
||||
expect(attempts).toBe(3)
|
||||
expect(delays).toEqual([500, 1500])
|
||||
})
|
||||
|
||||
test("does no more work after cancellation", async () => {
|
||||
let attempts = 0
|
||||
const recovered = await recoverPendingQuestionWithRetry(
|
||||
async () => {
|
||||
attempts += 1
|
||||
return false
|
||||
},
|
||||
{ isCancelled: () => true, sleep: async () => undefined },
|
||||
)
|
||||
|
||||
expect(recovered).toBe(false)
|
||||
expect(attempts).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Message, Part, ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type MessageRecord = {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
const RECOVERY_DELAYS_MS = [0, 500, 1500] as const
|
||||
|
||||
const isActiveQuestionTool = (part: Part): boolean => {
|
||||
if (part.type !== "tool" || part.tool !== "question") return false
|
||||
const status = (part as ToolPart).state.status
|
||||
return status === "pending" || status === "running"
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted running question tool without a matching pending-request record
|
||||
* is the cold-start recovery signal. Only inspect the current turn so an old,
|
||||
* stale tool cannot trigger network work after the user has continued chatting.
|
||||
*/
|
||||
export function hasActiveQuestionToolInCurrentTurn(messages: readonly MessageRecord[]): boolean {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index]
|
||||
if (!message) continue
|
||||
if (message.info.role === "user") return false
|
||||
if (message.parts.some(isActiveQuestionTool)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function recoverPendingQuestionWithRetry(
|
||||
recover: () => Promise<boolean>,
|
||||
options?: {
|
||||
isCancelled?: () => boolean
|
||||
sleep?: (delayMs: number) => Promise<void>
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const isCancelled = options?.isCancelled ?? (() => false)
|
||||
const sleep = options?.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)))
|
||||
|
||||
for (const delayMs of RECOVERY_DELAYS_MS) {
|
||||
if (delayMs > 0) await sleep(delayMs)
|
||||
if (isCancelled()) return false
|
||||
if (await recover()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { retry } from './retry';
|
||||
|
||||
describe('retry transient classification (#2470)', () => {
|
||||
test("'terminated' (undici half-open socket teardown) is retried", async () => {
|
||||
let attempts = 0;
|
||||
await expect(
|
||||
retry(async () => {
|
||||
attempts += 1;
|
||||
throw new TypeError('terminated');
|
||||
}),
|
||||
).rejects.toThrow('terminated');
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
test("normalized 'request timed out' (SDK read timeout) is retried", async () => {
|
||||
let attempts = 0;
|
||||
await expect(
|
||||
retry(async () => {
|
||||
attempts += 1;
|
||||
throw new Error('OpenCode request timed out after 30000ms');
|
||||
}),
|
||||
).rejects.toThrow('request timed out');
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
test('caller-initiated abort (AbortError) is NOT retried', async () => {
|
||||
let attempts = 0;
|
||||
await expect(
|
||||
retry(async () => {
|
||||
attempts += 1;
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}),
|
||||
).rejects.toThrow('Aborted');
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ export interface RetryOptions {
|
||||
retryIf?: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
// undici tears down half-open upstream connection with `TypeError: terminated`
|
||||
// (exact failure from the #2470 logs); the SDK client also rejects reads with
|
||||
// normalized "request timed out" error after OPENCODE_REQUEST_TIMEOUT_MS.
|
||||
// Both are transient — managed process may be restarting.
|
||||
const TRANSIENT_MESSAGES = [
|
||||
"load failed",
|
||||
"network connection was lost",
|
||||
@@ -18,6 +22,8 @@ const TRANSIENT_MESSAGES = [
|
||||
"opencode api unavailable",
|
||||
"503",
|
||||
"502",
|
||||
"terminated",
|
||||
"request timed out",
|
||||
]
|
||||
|
||||
function isTransientError(error: unknown): boolean {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Send-failure classification.
|
||||
*
|
||||
* Pure predicates over an unknown error value: no store, SDK, or transport
|
||||
* imports. They live outside `session-actions` so callers (and their tests) can
|
||||
* use the real classifier instead of re-implementing a partial mirror of it.
|
||||
*/
|
||||
|
||||
import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error"
|
||||
|
||||
export function getErrorStatus(error: unknown): number | null {
|
||||
if (!error || typeof error !== "object") return null
|
||||
// SAFETY: `error` is a non-null object here; both probes read optional
|
||||
// properties an SDK/fetch rejection may carry and validate them below.
|
||||
const direct = (error as { status?: unknown }).status
|
||||
if (typeof direct === "number") return direct
|
||||
// SAFETY: same non-null object, optional property probe validated below.
|
||||
const response = (error as { response?: { status?: unknown } }).response
|
||||
return typeof response?.status === "number" ? response.status : null
|
||||
}
|
||||
|
||||
export function isAmbiguousSendFailure(error: unknown): boolean {
|
||||
// Authoritative first: the transport that lost the request says whether it
|
||||
// had already been dispatched. The text matching below only covers direct
|
||||
// fetch/HTTP failures, whose wording we do not control either — relay tunnel
|
||||
// aborts ("stream aborted by host", "relay keepalive timeout", …) match none
|
||||
// of those patterns and used to be misread as definite failures.
|
||||
if (isAmbiguousTransportFailure(error)) return true
|
||||
|
||||
const status = getErrorStatus(error)
|
||||
if (status === 503 || status === 504 || status === 408) return true
|
||||
if (error instanceof TypeError) return true
|
||||
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true
|
||||
|
||||
const message = error instanceof Error
|
||||
? error.message.toLowerCase()
|
||||
: typeof error === "string"
|
||||
? error.toLowerCase()
|
||||
: ""
|
||||
|
||||
return message.includes("timeout")
|
||||
|| message.includes("timed out")
|
||||
|| message.includes("failed to fetch")
|
||||
|| message.includes("networkerror")
|
||||
|| message.includes("network error")
|
||||
|| message.includes("gateway timeout")
|
||||
|| message.includes("econnreset")
|
||||
|| message.includes("socket hang up")
|
||||
}
|
||||
@@ -13,6 +13,10 @@ let permissionReplyError: unknown | null = null
|
||||
let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
|
||||
let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
|
||||
let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] }
|
||||
const sessionMessageRecords = new Map<string, Array<{ info: Message; parts: Part[] }>>()
|
||||
const failingRevertSessionIds = new Set<string>()
|
||||
const failingUnrevertSessionIds = new Set<string>()
|
||||
let afterUnrevertCall: ((sessionId: string) => void) | null = null
|
||||
let sessionDeleteError: unknown | null = null
|
||||
let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null
|
||||
let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null
|
||||
@@ -68,6 +72,14 @@ const mockSdk = {
|
||||
replyCalls.push({ method: "session.revert", params })
|
||||
return Promise.resolve(sessionRevertResult)
|
||||
}),
|
||||
unrevert: mock((params: Record<string, unknown>) => {
|
||||
replyCalls.push({ method: "session.unrevert", params })
|
||||
afterUnrevertCall?.(String(params.sessionID))
|
||||
if (failingUnrevertSessionIds.has(String(params.sessionID))) {
|
||||
return Promise.resolve({ error: { message: "rejected" }, response: { status: 500 } })
|
||||
}
|
||||
return Promise.resolve({ data: { id: params.sessionID, time: { created: 1 } } })
|
||||
}),
|
||||
abort: mock((params: Record<string, unknown>) => {
|
||||
replyCalls.push({ method: "session.abort", params })
|
||||
return Promise.resolve({ data: true })
|
||||
@@ -125,7 +137,12 @@ mock.module("@/lib/opencode/client", () => ({
|
||||
return mockScopedClient
|
||||
},
|
||||
getDirectory: () => "/test/project",
|
||||
getFilesystemHome: mock(async () => "/home/test"),
|
||||
getSdkClient: () => mockSdk,
|
||||
getSessionMessages: mock((sessionId: string, _limit?: number, directory?: string | null) => {
|
||||
replyCalls.push({ method: "session.messages", params: { sessionID: sessionId, directory } })
|
||||
return Promise.resolve(sessionMessageRecords.get(sessionId) ?? [])
|
||||
}),
|
||||
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
|
||||
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
|
||||
return Promise.resolve(true)
|
||||
@@ -139,11 +156,11 @@ mock.module("@/lib/opencode/client", () => ({
|
||||
method: "session.revert",
|
||||
params: { sessionID: sessionId, messageID: messageId, partID: partId, directory },
|
||||
})
|
||||
if (sessionRevertResult.error) {
|
||||
if (sessionRevertResult.error || failingRevertSessionIds.has(sessionId)) {
|
||||
const status = sessionRevertResult.response?.status
|
||||
throw new Error(`session.revert failed${status ? ` (${status})` : ""}: rejected`)
|
||||
}
|
||||
return Promise.resolve(sessionRevertResult.data)
|
||||
return Promise.resolve(sessionRevertResult.data ?? { id: sessionId, time: { created: 1 }, revert: { messageID: messageId } })
|
||||
}),
|
||||
updateSession: mock((sessionId: string, changes: Record<string, unknown>, directory?: string | null) => {
|
||||
replyCalls.push({ method: "session.update", params: { sessionID: sessionId, ...changes, directory } })
|
||||
@@ -944,12 +961,12 @@ describe("optimisticSend target directory", () => {
|
||||
})
|
||||
|
||||
test("commits the new branch locally and discards its optimistic shadow when sending after a revert", async () => {
|
||||
const retainedMessage = { id: "msg_1", role: "user", sessionID: "session-reverted" } as Message
|
||||
const revertedMessage = { id: "msg_2", role: "user", sessionID: "session-reverted" } as Message
|
||||
const retainedMessage = { id: "msg_ffffffffffffRetained", role: "user", sessionID: "session-reverted", time: { created: 1 } } as Message
|
||||
const revertedMessage = { id: "msg_000000000000Reverted", role: "user", sessionID: "session-reverted", time: { created: 2 } } as Message
|
||||
const targetStore = createStore({}, {
|
||||
session: [{ id: "session-reverted", revert: { messageID: "msg_2" } } as Session],
|
||||
session: [{ id: "session-reverted", revert: { messageID: revertedMessage.id } } as Session],
|
||||
message: { "session-reverted": [retainedMessage, revertedMessage] },
|
||||
part: { msg_2: [{ id: "part_2", type: "text", text: "old branch" } as Part] },
|
||||
part: { [revertedMessage.id]: [{ id: "part_2", type: "text", text: "old branch" } as Part] },
|
||||
})
|
||||
const childStores = createChildStores([["/target/project", targetStore]])
|
||||
let optimisticMessage: Message | null = null
|
||||
@@ -981,22 +998,22 @@ describe("optimisticSend target directory", () => {
|
||||
|
||||
expect(targetStore.getState().session[0].revert).toBe(undefined)
|
||||
expect(targetStore.getState().message["session-reverted"].map((message) => message.id)).toEqual([
|
||||
"msg_1",
|
||||
retainedMessage.id,
|
||||
(optimisticMessage as unknown as Message).id,
|
||||
])
|
||||
expect(targetStore.getState().part.msg_2).toBe(undefined)
|
||||
expect(targetStore.getState().part[revertedMessage.id]).toBe(undefined)
|
||||
expect(optimisticShadow.has(revertedMessage.id)).toBe(false)
|
||||
expect(optimisticShadow.has((optimisticMessage as unknown as Message).id)).toBe(true)
|
||||
})
|
||||
|
||||
test("restores the reverted branch when sending fails", async () => {
|
||||
const retainedMessage = { id: "msg_1", role: "user", sessionID: "session-reverted" } as Message
|
||||
const revertedMessage = { id: "msg_2", role: "user", sessionID: "session-reverted" } as Message
|
||||
const retainedMessage = { id: "msg_ffffffffffffRetained", role: "user", sessionID: "session-reverted", time: { created: 1 } } as Message
|
||||
const revertedMessage = { id: "msg_000000000000Reverted", role: "user", sessionID: "session-reverted", time: { created: 2 } } as Message
|
||||
const revertedPart = { id: "part_2", type: "text", text: "old branch" } as Part
|
||||
const targetStore = createStore({}, {
|
||||
session: [{ id: "session-reverted", revert: { messageID: "msg_2" } } as Session],
|
||||
session: [{ id: "session-reverted", revert: { messageID: revertedMessage.id } } as Session],
|
||||
message: { "session-reverted": [retainedMessage, revertedMessage] },
|
||||
part: { msg_2: [revertedPart] },
|
||||
part: { [revertedMessage.id]: [revertedPart] },
|
||||
})
|
||||
const childStores = createChildStores([["/target/project", targetStore]])
|
||||
|
||||
@@ -1022,12 +1039,12 @@ describe("optimisticSend target directory", () => {
|
||||
send: async () => { throw new Error("rejected") },
|
||||
})).rejects.toThrow("rejected")
|
||||
|
||||
expect(targetStore.getState().session[0].revert?.messageID).toBe("msg_2")
|
||||
expect(targetStore.getState().session[0].revert?.messageID).toBe(revertedMessage.id)
|
||||
expect(targetStore.getState().message["session-reverted"]).toEqual([retainedMessage, revertedMessage])
|
||||
expect(targetStore.getState().part.msg_2).toEqual([revertedPart])
|
||||
expect(targetStore.getState().part[revertedMessage.id]).toEqual([revertedPart])
|
||||
})
|
||||
|
||||
test("allows callers to block final send when runtime changes after optimistic insert", async () => {
|
||||
test("rolls back a captured send when the runtime changes after optimistic insert", async () => {
|
||||
const targetStore = createStore({})
|
||||
const childStores = createChildStores([["/target/project", targetStore]])
|
||||
let optimisticAdd: OptimisticAddCall | null = null
|
||||
@@ -1052,15 +1069,15 @@ describe("optimisticSend target directory", () => {
|
||||
await optimisticSend({
|
||||
sessionId: "session-race",
|
||||
directory: "/target/project",
|
||||
runtimeKey: "runtime-a",
|
||||
content: "hello",
|
||||
providerID: "provider",
|
||||
modelID: "model",
|
||||
beforeOptimisticInsert: () => {
|
||||
onOptimisticInsert: () => {
|
||||
expect(getRuntimeKey()).toBe("runtime-a")
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-b.test", runtimeKey: "runtime-b" })
|
||||
},
|
||||
send: async () => {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-b.test", runtimeKey: "runtime-b" })
|
||||
if (getRuntimeKey() !== "runtime-a") throw new Error("Auto-review stopped because the runtime changed.")
|
||||
finalSendCalled = true
|
||||
},
|
||||
})
|
||||
@@ -1292,6 +1309,8 @@ describe("revertToMessage passes session directory", () => {
|
||||
replyCalls.length = 0
|
||||
scopedClientDirectories.length = 0
|
||||
sessionRevertResult = {}
|
||||
sessionMessageRecords.clear()
|
||||
failingRevertSessionIds.clear()
|
||||
Object.assign(inputState, {
|
||||
pendingInputText: "previous draft",
|
||||
pendingInputMode: "normal" as const,
|
||||
@@ -1353,6 +1372,229 @@ describe("revertToMessage passes session directory", () => {
|
||||
expect((sessionStore.getState().session[0] as Session & { revert?: { messageID?: string } }).revert).toBe(undefined)
|
||||
expect(inputState.pendingInputText).toBe("previous draft")
|
||||
})
|
||||
|
||||
test("reverts recursive descendants at their first user message on or after the parent cutoff", async () => {
|
||||
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 } },
|
||||
{ id: "child", parentID: "root", directory: "/tree", time: { created: 2 } },
|
||||
{ id: "grandchild", parentID: "child", directory: "/tree", time: { created: 3 } },
|
||||
{ id: "old-child", parentID: "root", directory: "/tree", time: { created: 4 } },
|
||||
] as Session[]
|
||||
const store = createStore({}, { session: sessions, message: { root: [rootMessage] } })
|
||||
sessionMessageRecords.set("child", [
|
||||
{ info: { id: "child-before", sessionID: "child", role: "user", time: { created: 10 } } as Message, parts: [] },
|
||||
{ info: { id: "child-boundary", sessionID: "child", role: "user", time: { created: 20 } } as Message, parts: [] },
|
||||
{ info: { id: "child-later", sessionID: "child", role: "user", time: { created: 30 } } as Message, parts: [] },
|
||||
])
|
||||
sessionMessageRecords.set("grandchild", [
|
||||
{ info: { id: "grandchild-assistant", sessionID: "grandchild", role: "assistant", time: { created: 20 } } as Message, parts: [] },
|
||||
{ info: { id: "grandchild-user", sessionID: "grandchild", role: "user", time: { created: 21 } } as Message, parts: [] },
|
||||
])
|
||||
sessionMessageRecords.set("old-child", [
|
||||
{ info: { id: "old-child-user", sessionID: "old-child", role: "user", time: { created: 19 } } as Message, parts: [] },
|
||||
])
|
||||
|
||||
const { setActionRefs, revertToMessage } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
||||
|
||||
await revertToMessage("root", "root-cutoff")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => [
|
||||
call.params.sessionID,
|
||||
call.params.messageID,
|
||||
])).toEqual([
|
||||
["child", "child-boundary"],
|
||||
["grandchild", "grandchild-user"],
|
||||
["root", "root-cutoff"],
|
||||
])
|
||||
})
|
||||
|
||||
test("continues reverting other descendants and the parent when one child fails", async () => {
|
||||
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 } },
|
||||
{ id: "failing-child", parentID: "root", directory: "/tree", time: { created: 2 } },
|
||||
{ id: "healthy-child", parentID: "root", directory: "/tree", time: { created: 3 } },
|
||||
] as Session[]
|
||||
const store = createStore({}, { session: sessions, message: { root: [rootMessage] } })
|
||||
for (const id of ["failing-child", "healthy-child"]) {
|
||||
sessionMessageRecords.set(id, [{
|
||||
info: { id: `${id}-target`, sessionID: id, role: "user", time: { created: 20 } } as Message,
|
||||
parts: [],
|
||||
}])
|
||||
}
|
||||
failingRevertSessionIds.add("failing-child")
|
||||
|
||||
const { setActionRefs, revertToMessage } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
||||
|
||||
await revertToMessage("root", "root-cutoff")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => call.params.sessionID)).toEqual([
|
||||
"failing-child",
|
||||
"healthy-child",
|
||||
"root",
|
||||
])
|
||||
})
|
||||
|
||||
test("aborts a busy descendant before reverting it", async () => {
|
||||
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 } },
|
||||
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 } },
|
||||
{ id: "idle-child", parentID: "root", directory: "/tree", time: { created: 3 } },
|
||||
] as Session[]
|
||||
const store = createStore({}, {
|
||||
session: sessions,
|
||||
message: { root: [rootMessage] },
|
||||
session_status: { "busy-child": { type: "busy" }, "idle-child": { type: "idle" } },
|
||||
})
|
||||
for (const id of ["busy-child", "idle-child"]) {
|
||||
sessionMessageRecords.set(id, [{
|
||||
info: { id: `${id}-target`, sessionID: id, role: "user", time: { created: 20 } } as Message,
|
||||
parts: [],
|
||||
}])
|
||||
}
|
||||
|
||||
const { setActionRefs, revertToMessage } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
||||
|
||||
await revertToMessage("root", "root-cutoff")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
||||
.toEqual(["busy-child"])
|
||||
const busyAbortIndex = replyCalls.findIndex((call) => call.method === "session.abort")
|
||||
const busyRevertIndex = replyCalls.findIndex(
|
||||
(call) => call.method === "session.revert" && call.params.sessionID === "busy-child",
|
||||
)
|
||||
expect(busyAbortIndex).toBeLessThan(busyRevertIndex)
|
||||
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => call.params.sessionID)).toEqual([
|
||||
"busy-child",
|
||||
"idle-child",
|
||||
"root",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("unrevertSession descendant cascade", () => {
|
||||
beforeEach(() => {
|
||||
replyCalls.length = 0
|
||||
sessionMessagesResult = { data: [] }
|
||||
failingUnrevertSessionIds.clear()
|
||||
afterUnrevertCall = null
|
||||
})
|
||||
|
||||
test("unreverts only marked descendants before the parent", async () => {
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
||||
{ id: "marked-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "child-target" } },
|
||||
{ id: "plain-child", parentID: "root", directory: "/tree", time: { created: 3 } },
|
||||
{ id: "marked-grandchild", parentID: "plain-child", directory: "/tree", time: { created: 4 }, revert: { messageID: "grandchild-target" } },
|
||||
] as Session[]
|
||||
const store = createStore({}, { session: sessions })
|
||||
|
||||
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
||||
|
||||
await unrevertSession("root")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.unrevert").map((call) => call.params.sessionID)).toEqual([
|
||||
"marked-child",
|
||||
"marked-grandchild",
|
||||
"root",
|
||||
])
|
||||
})
|
||||
|
||||
test("continues after a descendant unrevert fails", async () => {
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
||||
{ id: "failing-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "first-target" } },
|
||||
{ id: "healthy-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "second-target" } },
|
||||
] as Session[]
|
||||
const store = createStore({}, { session: sessions })
|
||||
failingUnrevertSessionIds.add("failing-child")
|
||||
|
||||
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
||||
|
||||
await unrevertSession("root")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.unrevert").map((call) => call.params.sessionID)).toEqual([
|
||||
"failing-child",
|
||||
"healthy-child",
|
||||
"root",
|
||||
])
|
||||
})
|
||||
|
||||
test("aborts a busy descendant before unreverting it", async () => {
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
||||
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "busy-target" } },
|
||||
{ id: "idle-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "idle-target" } },
|
||||
] as Session[]
|
||||
const store = createStore({}, {
|
||||
session: sessions,
|
||||
session_status: { "busy-child": { type: "busy" }, "idle-child": { type: "idle" } },
|
||||
})
|
||||
|
||||
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
||||
|
||||
await unrevertSession("root")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
||||
.toEqual(["busy-child"])
|
||||
const abortIndex = replyCalls.findIndex((call) => call.method === "session.abort")
|
||||
const unrevertIndex = replyCalls.findIndex(
|
||||
(call) => call.method === "session.unrevert" && call.params.sessionID === "busy-child",
|
||||
)
|
||||
expect(abortIndex).toBeLessThan(unrevertIndex)
|
||||
})
|
||||
|
||||
test("treats a descendant as busy when any child store reports a non-idle status", async () => {
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
||||
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "busy-target" } },
|
||||
] as Session[]
|
||||
// The session list is deduped onto /tree, but the live status arrived in the
|
||||
// store for another directory.
|
||||
const treeStore = createStore({}, { session: sessions })
|
||||
const statusStore = createStore({}, { session_status: { "busy-child": { type: "busy" } } })
|
||||
|
||||
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
||||
setActionRefs(
|
||||
mockSdk as unknown as OpencodeClient,
|
||||
createChildStores([["/tree", treeStore], ["/other", statusStore]]),
|
||||
() => "/tree",
|
||||
)
|
||||
|
||||
await unrevertSession("root")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
||||
.toEqual(["busy-child"])
|
||||
})
|
||||
|
||||
test("aborts a descendant that turns busy after the subtree snapshot", async () => {
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
||||
{ id: "first-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "first-target" } },
|
||||
{ id: "second-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "second-target" } },
|
||||
] as Session[]
|
||||
const store = createStore({}, { session: sessions, session_status: {} })
|
||||
afterUnrevertCall = (sessionId) => {
|
||||
if (sessionId !== "first-child") return
|
||||
store.getState().patch({ session_status: { "second-child": { type: "busy" } } })
|
||||
}
|
||||
|
||||
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
||||
|
||||
await unrevertSession("root")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
||||
.toEqual(["second-child"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("dismissPermission passes directory", () => {
|
||||
@@ -1461,6 +1703,258 @@ describe("rejectQuestion passes directory", () => {
|
||||
})
|
||||
})
|
||||
|
||||
function sessionFixture(id: string): Session {
|
||||
// SAFETY: the question flow only reads session id/time; the fixture is
|
||||
// intentionally minimal and matches the existing fixtures in this file.
|
||||
return { id, time: { created: 1 } } as Session
|
||||
}
|
||||
|
||||
function actionsSdk(): OpencodeClient {
|
||||
// SAFETY: mockSdk implements the question/permission/session surface that
|
||||
// session-actions uses; this cast is the established pattern in this file.
|
||||
return mockSdk as never
|
||||
}
|
||||
|
||||
describe("question dismissal clears pending state without the SSE echo (issues #2911, #2448)", () => {
|
||||
beforeEach(() => {
|
||||
replyCalls.length = 0
|
||||
scopedClientDirectories.length = 0
|
||||
questionReplyError = null
|
||||
questionRejectError = null
|
||||
})
|
||||
|
||||
test("rejectQuestion clears the question from the child store on success", async () => {
|
||||
const question = buildQuestion("q-1", "session-a")
|
||||
const store = createStore({}, {
|
||||
session: [sessionFixture("session-a")],
|
||||
question: { "session-a": [question] },
|
||||
})
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
|
||||
const { setActionRefs, rejectQuestion } = await import("./session-actions")
|
||||
setActionRefs(actionsSdk(), childStores, () => "/test/project")
|
||||
|
||||
await rejectQuestion("session-a", "q-1")
|
||||
|
||||
// The backend confirmed the rejection. The local pending state must be gone
|
||||
// even if the SSE `question.rejected` event is lost (SSE gap), otherwise the
|
||||
// session stays in "waiting for answer" and the next task never renders
|
||||
// thinking/final response (issues #2911, #2448).
|
||||
expect(store.getState().question["session-a"]).toBe(undefined)
|
||||
})
|
||||
|
||||
test("respondToQuestion clears the question from the child store on success", async () => {
|
||||
const question = buildQuestion("q-1", "session-a")
|
||||
const store = createStore({}, {
|
||||
session: [sessionFixture("session-a")],
|
||||
question: { "session-a": [question] },
|
||||
})
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
|
||||
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
||||
setActionRefs(actionsSdk(), childStores, () => "/test/project")
|
||||
|
||||
await respondToQuestion("session-a", "q-1", [["Yes"]])
|
||||
|
||||
expect(store.getState().question["session-a"]).toBe(undefined)
|
||||
})
|
||||
|
||||
test("dismissOpenQuestionsForSession leaves the store cleared when the reject succeeds", async () => {
|
||||
const question = buildQuestion("q-root", "session-a")
|
||||
const store = createStore({}, {
|
||||
session: [sessionFixture("session-a")],
|
||||
question: { "session-a": [question] },
|
||||
})
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
|
||||
const { setActionRefs, dismissOpenQuestionsForSession } = await import("./session-actions")
|
||||
setActionRefs(actionsSdk(), childStores, () => "/test/project")
|
||||
|
||||
const dismissed = await dismissOpenQuestionsForSession("session-a")
|
||||
|
||||
expect(dismissed).toBe(true)
|
||||
// The optimistic clear already removed it before the round-trip; the
|
||||
// successful reject must not resurrect it.
|
||||
expect(store.getState().question["session-a"]).toBe(undefined)
|
||||
})
|
||||
|
||||
test("reply/reject actions on an already-cleared store stay no-ops (SSE echo equivalent)", async () => {
|
||||
// A later (or duplicated) SSE echo for an already-cleared request must not
|
||||
// error or resurrect state — the reducer only removes when present.
|
||||
const store = createStore({}, {
|
||||
session: [sessionFixture("session-a")],
|
||||
question: {},
|
||||
})
|
||||
|
||||
const { setActionRefs, rejectQuestion, respondToQuestion } = await import("./session-actions")
|
||||
setActionRefs(actionsSdk(), createChildStores([["/test/project", store]]), () => "/test/project")
|
||||
|
||||
await respondToQuestion("session-a", "q-gone", [["Yes"]])
|
||||
await rejectQuestion("session-a", "q-gone")
|
||||
|
||||
expect(store.getState().question["session-a"]).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("blocking request reply routing and stale recovery (issue OPE-236)", () => {
|
||||
const materializationCalls: Array<{ directory: string; sessionID: string; messageID: string }> = []
|
||||
const enqueueMaterialization = (directory: string, sessionID: string, messageID: string) => {
|
||||
materializationCalls.push({ directory, sessionID, messageID })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
replyCalls.length = 0
|
||||
scopedClientDirectories.length = 0
|
||||
questionReplyError = null
|
||||
questionRejectError = null
|
||||
materializationCalls.length = 0
|
||||
})
|
||||
|
||||
test("routes the question reply by the request's own session directory, not the containing store key", async () => {
|
||||
// The question was asked by a worktree session whose record lives in the
|
||||
// parent store (containment). The reply must be addressed to the session's
|
||||
// own server-confirmed directory — otherwise the server resolves the
|
||||
// parent instance, does not find the pending question, and answers
|
||||
// QuestionNotFoundError, leaving the session stuck on "asking question".
|
||||
const question = buildQuestion("q-wt", "session-wt")
|
||||
const store = createStore({}, {
|
||||
session: [{ id: "session-wt", directory: "/test/project/wt" } as Session],
|
||||
question: { "session-wt": [question] },
|
||||
})
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
|
||||
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
||||
|
||||
await respondToQuestion("session-wt", "q-wt", [["Yes"]])
|
||||
|
||||
expect(scopedClientDirectories).toEqual(["/test/project/wt"])
|
||||
expect(replyCalls[0]?.params.directory).toBe("/test/project/wt")
|
||||
expect(replyCalls[0]?.params.requestID).toBe("q-wt")
|
||||
})
|
||||
|
||||
test("routes permission replies by the request's own session directory", async () => {
|
||||
const permission = buildPermission("perm-wt", "session-wt")
|
||||
const store = createStore(
|
||||
{ "session-wt": [permission] },
|
||||
{
|
||||
session: [{ id: "session-wt", directory: "/test/project/wt" } as Session],
|
||||
},
|
||||
)
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
|
||||
const { setActionRefs, respondToPermission } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
||||
|
||||
await respondToPermission("session-wt", "perm-wt", "once")
|
||||
|
||||
expect(scopedClientDirectories).toEqual(["/test/project/wt"])
|
||||
expect(replyCalls[0]?.params.directory).toBe("/test/project/wt")
|
||||
expect(replyCalls[0]?.params.requestID).toBe("perm-wt")
|
||||
})
|
||||
|
||||
test("falls back to the containing store key when the session record carries no directory", async () => {
|
||||
const question = buildQuestion("q-1", "session-a")
|
||||
const store = createStore({}, {
|
||||
session: [{ id: "session-a" } as Session],
|
||||
question: { "session-a": [question] },
|
||||
})
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
|
||||
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
||||
|
||||
await respondToQuestion("session-a", "q-1", [["Yes"]])
|
||||
|
||||
expect(scopedClientDirectories).toEqual(["/test/project"])
|
||||
expect(replyCalls[0]?.params.directory).toBe("/test/project")
|
||||
})
|
||||
|
||||
test("enqueues settled-running-tool tail recovery when the question reply is not found", async () => {
|
||||
const question = buildQuestion("q-stale", "session-a")
|
||||
const store = createStore({}, {
|
||||
session: [{ id: "session-a" } as Session],
|
||||
question: { "session-a": [question] },
|
||||
message: {
|
||||
"session-a": [{ id: "msg-1", sessionID: "session-a", role: "assistant", time: { created: 1 } } as Message],
|
||||
},
|
||||
part: {
|
||||
"msg-1": [{
|
||||
id: "prt-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: "session-a",
|
||||
type: "tool",
|
||||
tool: "question",
|
||||
state: { status: "running" },
|
||||
} as Part],
|
||||
},
|
||||
})
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
questionReplyError = Object.assign(new Error("question.reply failed (404): QuestionNotFoundError"), { status: 404 })
|
||||
|
||||
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
||||
|
||||
let thrown: unknown
|
||||
try {
|
||||
await respondToQuestion("session-a", "q-stale", [["Yes"]])
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error)
|
||||
// The stale request is gone from the store and the trailing running tool
|
||||
// part is reconciled instead of leaving the UI stuck on "asking question".
|
||||
expect(store.getState().question["session-a"]).toBe(undefined)
|
||||
expect(materializationCalls).toEqual([{ directory: "/test/project", sessionID: "session-a", messageID: "msg-1" }])
|
||||
})
|
||||
|
||||
test("enqueues tail recovery on reject not-found but not on success", async () => {
|
||||
const question = buildQuestion("q-1", "session-a")
|
||||
const store = createStore({}, {
|
||||
session: [{ id: "session-a" } as Session],
|
||||
question: { "session-a": [question] },
|
||||
message: {
|
||||
"session-a": [{ id: "msg-1", sessionID: "session-a", role: "assistant", time: { created: 1 } } as Message],
|
||||
},
|
||||
part: {
|
||||
"msg-1": [{
|
||||
id: "prt-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: "session-a",
|
||||
type: "tool",
|
||||
tool: "question",
|
||||
state: { status: "running" },
|
||||
} as Part],
|
||||
},
|
||||
})
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
|
||||
const { setActionRefs, rejectQuestion } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
||||
|
||||
// Success: no recovery enqueued — the normal question.rejected event flow clears state.
|
||||
await rejectQuestion("session-a", "q-1")
|
||||
expect(materializationCalls).toEqual([])
|
||||
|
||||
// Not-found: the request is stale server-side; the tail must be reconciled.
|
||||
questionRejectError = Object.assign(new Error("question.reject failed (404): QuestionNotFoundError"), { status: 404 })
|
||||
const stale = buildQuestion("q-stale", "session-a")
|
||||
store.setState({ question: { "session-a": [stale] } })
|
||||
|
||||
let thrown: unknown
|
||||
try {
|
||||
await rejectQuestion("session-a", "q-stale")
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error)
|
||||
expect(store.getState().question["session-a"]).toBe(undefined)
|
||||
expect(materializationCalls).toEqual([{ directory: "/test/project", sessionID: "session-a", messageID: "msg-1" }])
|
||||
})
|
||||
})
|
||||
|
||||
function buildQuestion(id: string, sessionId: string): QuestionRequest {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { mergeSessionDirectoryMetadata, resolveGlobalSessionDirectory, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||||
import { useConfigStore } from "@/stores/useConfigStore"
|
||||
import { registerSessionDirectory } from "./sync-refs"
|
||||
import { useGlobalSessionStatusStore } from "./global-session-status"
|
||||
import { recordSendFailure } from "./send-failure-log"
|
||||
import { isSyntheticPart } from "@/lib/messages/synthetic"
|
||||
import { materializeSessionSnapshots } from "./materialization"
|
||||
@@ -26,10 +27,18 @@ import {
|
||||
type SessionMetadataRecord,
|
||||
} from "@/lib/sessionReviewMetadata"
|
||||
import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/lib/contextObligatoryMessages"
|
||||
import { getBtwOriginalSessionID, getBtwSessionID, isBtwSession, withoutBtwSessionLink } from "@/lib/sessionBtwMetadata"
|
||||
import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues"
|
||||
import { getImperativeSessionMessageLoader } from "./session-message-loader"
|
||||
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error"
|
||||
import { markAmbiguousTransportFailure } from "@/lib/relay/transport-error"
|
||||
import { getErrorStatus, isAmbiguousSendFailure } from "./send-failure-classification"
|
||||
import { getStaleRunningToolMessageID } from "./materialization"
|
||||
import { normalizePath } from "@/lib/pathNormalization"
|
||||
import { mergeMessages } from "./optimistic"
|
||||
import { messagesBefore, messagesFrom } from "./message-ordering"
|
||||
import { deleteChatDirectory } from "@/lib/chatDirectories"
|
||||
|
||||
const MESSAGE_REFETCH_LIMIT = 100
|
||||
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
|
||||
@@ -52,6 +61,10 @@ const UNREVERT_REFETCH_RETRY_MS = 150
|
||||
let _sdk: OpencodeClient | null = null
|
||||
let _childStores: ChildStoreManager | null = null
|
||||
let _getDirectory: () => string = () => ""
|
||||
// Optional ref into the sync layer's session-tail materialization queue. Used
|
||||
// to reconcile a trailing running tool part after a blocking request is
|
||||
// confirmed stale server-side (see recoverStaleBlockingRequest).
|
||||
let _enqueueSessionMaterialization: ((directory: string, sessionID: string, messageID: string) => void) | null = null
|
||||
type OptimisticAddInput = { sessionID: string; directory?: string | null; message: Message; parts: Part[] }
|
||||
type OptimisticRemoveInput = { sessionID: string; directory?: string | null; messageID: string }
|
||||
type OptimisticConfirmInput = OptimisticRemoveInput
|
||||
@@ -124,7 +137,11 @@ function assertSdkSuccess<T>(result: SdkResult<T>, operation: string): T | undef
|
||||
const status = result.response?.status
|
||||
const error = new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) as Error & { status?: number }
|
||||
if (status !== undefined) error.status = status
|
||||
throw error
|
||||
// Wrapping loses the original error's identity: the transport's
|
||||
// "dispatched, outcome unknown" tag, a DOMException abort, a TypeError from
|
||||
// fetch. Re-tag the wrapper so `isAmbiguousSendFailure` still classifies it
|
||||
// as ambiguous instead of reading it as a definite server rejection.
|
||||
throw isAmbiguousSendFailure(result.error) ? markAmbiguousTransportFailure(error) : error
|
||||
}
|
||||
|
||||
function assertSdkData<T>(result: SdkResult<T>, operation: string): T {
|
||||
@@ -139,10 +156,12 @@ export function setActionRefs(
|
||||
sdk: OpencodeClient,
|
||||
childStores: ChildStoreManager,
|
||||
getDirectory: () => string,
|
||||
enqueueSessionMaterialization?: (directory: string, sessionID: string, messageID: string) => void,
|
||||
) {
|
||||
_sdk = sdk
|
||||
_childStores = childStores
|
||||
_getDirectory = getDirectory
|
||||
_enqueueSessionMaterialization = enqueueSessionMaterialization ?? null
|
||||
}
|
||||
|
||||
export function setOptimisticRefs(
|
||||
@@ -227,7 +246,7 @@ function updateLiveSession(session: Session, directory?: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function mirrorSessionIntoLiveStores(session: Session, directory?: string): void {
|
||||
function mirrorSessionIntoLiveStores(session: Session, directory?: string): void {
|
||||
if (directory && updateLiveSession(session, directory)) {
|
||||
return
|
||||
}
|
||||
@@ -361,43 +380,6 @@ function connectionLostError(): Error {
|
||||
return new Error(`Connection lost${suffix}. Please wait for reconnection.`)
|
||||
}
|
||||
|
||||
function getErrorStatus(error: unknown): number | null {
|
||||
if (!error || typeof error !== "object") return null
|
||||
const direct = (error as { status?: unknown }).status
|
||||
if (typeof direct === "number") return direct
|
||||
const response = (error as { response?: { status?: unknown } }).response
|
||||
return typeof response?.status === "number" ? response.status : null
|
||||
}
|
||||
|
||||
function isAmbiguousSendFailure(error: unknown): boolean {
|
||||
// Authoritative first: the transport that lost the request says whether it
|
||||
// had already been dispatched. The text matching below only covers direct
|
||||
// fetch/HTTP failures, whose wording we do not control either — relay tunnel
|
||||
// aborts ("stream aborted by host", "relay keepalive timeout", …) match none
|
||||
// of those patterns and used to be misread as definite failures.
|
||||
if (isAmbiguousTransportFailure(error)) return true
|
||||
|
||||
const status = getErrorStatus(error)
|
||||
if (status === 503 || status === 504 || status === 408) return true
|
||||
if (error instanceof TypeError) return true
|
||||
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true
|
||||
|
||||
const message = error instanceof Error
|
||||
? error.message.toLowerCase()
|
||||
: typeof error === "string"
|
||||
? error.toLowerCase()
|
||||
: ""
|
||||
|
||||
return message.includes("timeout")
|
||||
|| message.includes("timed out")
|
||||
|| message.includes("failed to fetch")
|
||||
|| message.includes("networkerror")
|
||||
|| message.includes("network error")
|
||||
|| message.includes("gateway timeout")
|
||||
|| message.includes("econnreset")
|
||||
|| message.includes("socket hang up")
|
||||
}
|
||||
|
||||
// Wait briefly for the pipeline to re-establish connection before failing a
|
||||
// send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network
|
||||
// blip) otherwise surface as a hard "Connection lost" toast even though the
|
||||
@@ -425,6 +407,142 @@ type SessionListSnapshot = {
|
||||
|
||||
type DirectoryStoreApi = ReturnType<ChildStoreManager["ensureChild"]>
|
||||
|
||||
type DescendantSession = {
|
||||
session: Session
|
||||
directory: string
|
||||
}
|
||||
|
||||
/** "unknown" means no live source covers this session right now, so no caller
|
||||
* may treat it as idle on this answer. "idle" requires positive coverage. */
|
||||
export type SessionLiveActivity = "unknown" | "idle" | "active"
|
||||
|
||||
/**
|
||||
* A session's live status can live in a different child store than the one that
|
||||
* wins the directory dedup, so any store reporting a non-idle status counts.
|
||||
* Read at the moment of use: a descendant can start working after the subtree
|
||||
* snapshot was taken.
|
||||
*
|
||||
* Absence of a non-idle status is not proof of idleness. Child stores are
|
||||
* evicted for background directories, and the global status index keeps only
|
||||
* non-idle entries, so "no report" and "idle" are different answers: report
|
||||
* "idle" only when a child store actually covers the session's directory.
|
||||
*/
|
||||
export function getSessionLiveActivity(sessionId: string): SessionLiveActivity {
|
||||
const stores = _childStores
|
||||
|
||||
if (stores) {
|
||||
for (const [, store] of stores.children) {
|
||||
const status = store.getState().session_status?.[sessionId]
|
||||
if (status && status.type !== "idle") return "active"
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-directory live index: populated by global events and authoritative
|
||||
// per-directory status snapshots, and it survives child-store eviction.
|
||||
if (useGlobalSessionStatusStore.getState().statusById.has(sessionId)) return "active"
|
||||
|
||||
if (!stores) return "unknown"
|
||||
return isSessionCoveredByChildStore(sessionId, stores) ? "idle" : "unknown"
|
||||
}
|
||||
|
||||
function isSessionCoveredByChildStore(sessionId: string, stores: ChildStoreManager): boolean {
|
||||
if (findSessionDirectoryInChildStores(sessionId)) return true
|
||||
const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId)
|
||||
?? resolveKnownSessionDirectory(sessionId)
|
||||
if (!directory) return false
|
||||
return stores.children.has(normalizePath(directory) ?? directory)
|
||||
}
|
||||
|
||||
function resolveKnownSessionDirectory(sessionId: string): string | null {
|
||||
const globalSession = getGlobalSessionSnapshot(sessionId)
|
||||
return globalSession ? resolveGlobalSessionDirectory(globalSession) : null
|
||||
}
|
||||
|
||||
export function isSessionBusyNow(sessionId: string): boolean {
|
||||
return getSessionLiveActivity(sessionId) === "active"
|
||||
}
|
||||
|
||||
async function abortDescendantIfBusy(sessionId: string, directory: string): Promise<void> {
|
||||
if (!isSessionBusyNow(sessionId)) return
|
||||
try {
|
||||
await sdk().session.abort({ sessionID: sessionId, directory })
|
||||
} catch {
|
||||
// ignore abort errors
|
||||
}
|
||||
}
|
||||
|
||||
function getDescendantSessions(rootId: string): DescendantSession[] {
|
||||
const stores = _childStores
|
||||
if (!stores) return []
|
||||
|
||||
const sessionsById = new Map<string, DescendantSession>()
|
||||
for (const [storeDirectory, store] of stores.children) {
|
||||
const state = store.getState()
|
||||
for (const session of state.session) {
|
||||
const directory = session.directory || storeDirectory
|
||||
const current = sessionsById.get(session.id)
|
||||
if (!current || session.directory) sessionsById.set(session.id, { session, directory })
|
||||
}
|
||||
}
|
||||
|
||||
const subtreeIds = computeSubtreeIds(
|
||||
[...sessionsById.values()].map(({ session }) => session),
|
||||
rootId,
|
||||
)
|
||||
subtreeIds.delete(rootId)
|
||||
return [...subtreeIds]
|
||||
.map((id) => sessionsById.get(id))
|
||||
.filter((entry): entry is DescendantSession => !!entry)
|
||||
}
|
||||
|
||||
function firstUserMessageAtOrAfter(messages: Message[], cutoff: number): Message | null {
|
||||
let target: Message | null = null
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user" || message.time.created < cutoff) continue
|
||||
if (!target || message.time.created < target.time.created) target = message
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
async function fetchSessionMessages(sessionId: string, directory?: string | null): Promise<Message[]> {
|
||||
const records = await opencodeClient.getSessionMessages(sessionId, undefined, directory)
|
||||
return records.map(({ info }) => info)
|
||||
}
|
||||
|
||||
async function cascadeRevertToDescendants(rootId: string, cutoff: number): Promise<void> {
|
||||
for (const { session, directory } of getDescendantSessions(rootId)) {
|
||||
try {
|
||||
// A running descendant would keep writing messages past the revert
|
||||
// boundary, so stop it first for the same reason the parent is aborted.
|
||||
await abortDescendantIfBusy(session.id, directory)
|
||||
const messages = await fetchSessionMessages(session.id, directory)
|
||||
// Equal timestamps belong to the reverted side of the boundary. Keeping
|
||||
// them would rely on unrelated message IDs to decide chronology.
|
||||
const target = firstUserMessageAtOrAfter(messages, cutoff)
|
||||
if (!target) continue
|
||||
const reverted = await opencodeClient.revertSession(session.id, target.id, undefined, directory)
|
||||
mirrorSessionIntoLiveStores(reverted, directory)
|
||||
} catch (error) {
|
||||
console.error(`[session-actions] Failed to cascade revert to descendant ${session.id}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cascadeUnrevertToDescendants(rootId: string): Promise<void> {
|
||||
for (const { session, directory } of getDescendantSessions(rootId)) {
|
||||
if (!session.revert) continue
|
||||
try {
|
||||
// Same reason as the revert cascade: a running descendant keeps writing
|
||||
// messages that the unrevert would race against.
|
||||
await abortDescendantIfBusy(session.id, directory)
|
||||
const result = await sdk().session.unrevert({ sessionID: session.id, directory })
|
||||
mirrorSessionIntoLiveStores(assertSdkData(result, "session.unrevert"), directory)
|
||||
} catch (error) {
|
||||
console.error(`[session-actions] Failed to cascade unrevert to descendant ${session.id}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getGlobalSessionSnapshot(sessionId: string): Session | null {
|
||||
const global = useGlobalSessionsStore.getState()
|
||||
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
|
||||
@@ -480,6 +598,29 @@ function restoreFilePartsToInput(fileParts: Array<Record<string, unknown>>): voi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-confirmed directory that owns a session, from the session record
|
||||
* (`directory`, then `project.worktree`). Mirrors the authoritative source in
|
||||
* session-directory-resolution: holding a session in a child store proves
|
||||
* containment, not ownership — a project's session list legitimately includes
|
||||
* the sessions of its worktrees so the sidebar can group them — so reading
|
||||
* ownership from the containing store reports the parent for a session that
|
||||
* lives in a worktree, and every fetch is then addressed to a directory that
|
||||
* does not own it.
|
||||
*/
|
||||
function resolveSessionOwnedDirectory(session: Session): string | null {
|
||||
const record = session as Session & {
|
||||
directory?: string | null
|
||||
project?: { worktree?: string | null } | null
|
||||
}
|
||||
const raw = typeof record.directory === "string" && record.directory.trim().length > 0
|
||||
? record.directory
|
||||
: typeof record.project?.worktree === "string" && record.project.worktree.trim().length > 0
|
||||
? record.project.worktree
|
||||
: null
|
||||
return raw ? normalizePath(raw) : null
|
||||
}
|
||||
|
||||
function resolveDirectoryForBlockingRequest(
|
||||
type: "permission" | "question",
|
||||
sessionId: string,
|
||||
@@ -493,10 +634,28 @@ function resolveDirectoryForBlockingRequest(
|
||||
for (const [directory, store] of stores.children) {
|
||||
const state = store.getState()
|
||||
const requestMap = type === "permission" ? state.permission : state.question
|
||||
for (const requests of Object.values(requestMap) as Array<Array<{ id: string }> | undefined>) {
|
||||
if (requests?.some((request) => request.id === requestId)) {
|
||||
return directory
|
||||
}
|
||||
for (const requests of Object.values(requestMap) as Array<Array<{ id: string; sessionID?: string }> | undefined>) {
|
||||
const request = requests?.find((candidate) => candidate.id === requestId)
|
||||
if (!request) continue
|
||||
|
||||
// Ownership beats containment. The request belongs to one specific
|
||||
// session, and the reply must reach the instance that actually tracks
|
||||
// it — the directory the session record's server-confirmed `directory`
|
||||
// names. The containing store's key only proves containment: a project
|
||||
// store holds its worktree sessions too, and a reply addressed to the
|
||||
// parent instance makes the server answer QuestionNotFoundError while
|
||||
// the question stays pending in the worktree instance, leaving the
|
||||
// session stuck on the running question tool. Fall back to the store
|
||||
// key only when the session record carries no directory.
|
||||
const requestSessionID = typeof request.sessionID === "string" && request.sessionID.length > 0
|
||||
? request.sessionID
|
||||
: sessionId
|
||||
const sessionRecord = requestSessionID
|
||||
? state.session.find((s) => s.id === requestSessionID)
|
||||
: undefined
|
||||
const ownedDirectory = sessionRecord ? resolveSessionOwnedDirectory(sessionRecord) : null
|
||||
if (ownedDirectory) return ownedDirectory
|
||||
return directory
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +696,38 @@ export function isQuestionRequestNotFoundError(error: unknown): boolean {
|
||||
return /Question(?:\.)?NotFoundError|Question request not found/i.test(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the trailing assistant tool part after a blocking request turned
|
||||
* out to be stale server-side (reply/reject answered with not-found). The
|
||||
* local request is removed (the server no longer tracks it), but the
|
||||
* question/permission tool part can remain `running` with the session busy —
|
||||
* the UI would stay on "asking question" with no recovery until the user
|
||||
* stops the run. Enqueue the sync layer's settled-running-tool tail
|
||||
* materialization so the part converges to the server's actual state.
|
||||
*/
|
||||
function recoverStaleBlockingRequest(sessionId: string): void {
|
||||
const stores = _childStores
|
||||
const enqueue = _enqueueSessionMaterialization
|
||||
if (!stores || !enqueue || !sessionId) return
|
||||
|
||||
for (const [directory, store] of stores.children) {
|
||||
const state = store.getState()
|
||||
if (
|
||||
!state.session.some((session) => session.id === sessionId)
|
||||
&& !Object.prototype.hasOwnProperty.call(state.message, sessionId)
|
||||
&& !Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionId)
|
||||
&& !Object.prototype.hasOwnProperty.call(state.question ?? {}, sessionId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const messageID = getStaleRunningToolMessageID(state, sessionId)
|
||||
if (messageID) {
|
||||
enqueue(directory, sessionId, messageID)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function removeQuestionRequestFromChildStores(sessionId: string, requestId: string): boolean {
|
||||
const stores = _childStores
|
||||
if (!stores || !requestId) return false
|
||||
@@ -642,6 +833,7 @@ export async function createSession(
|
||||
directoryOverride?: string | null,
|
||||
parentID?: string | null,
|
||||
metadata?: Record<string, unknown>,
|
||||
selectionTransition?: "submitted-draft",
|
||||
): Promise<Session | null> {
|
||||
try {
|
||||
// Capture the effective directory used for session creation so we can fall
|
||||
@@ -662,7 +854,7 @@ export async function createSession(
|
||||
if (sessionDirectory) {
|
||||
registerSessionDirectory(session.id, sessionDirectory)
|
||||
}
|
||||
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory)
|
||||
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory, selectionTransition)
|
||||
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
|
||||
useGlobalSessionsStore.getState().upsertSession(session)
|
||||
return session
|
||||
@@ -708,20 +900,28 @@ export async function patchSessionMetadata(
|
||||
useGlobalSessionsStore.getState().upsertSession(updated)
|
||||
const sessionDirectory = (updated as { directory?: string | null }).directory ?? targetDirectory
|
||||
if (sessionDirectory) registerSessionDirectory(updated.id, sessionDirectory)
|
||||
mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined)
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function setLinkedIssue(
|
||||
sessionId: string,
|
||||
directory: string | null | undefined,
|
||||
issue: LinkedIssue,
|
||||
linked: boolean,
|
||||
): Promise<Session> {
|
||||
return patchSessionMetadata(sessionId, directory, (metadata) =>
|
||||
withLinkedIssue(metadata, issue, linked))
|
||||
}
|
||||
|
||||
export async function setContextObligatoryMessage(
|
||||
sessionId: string,
|
||||
directory: string | null | undefined,
|
||||
message: ContextObligatoryMessage,
|
||||
pinned: boolean,
|
||||
): Promise<Session> {
|
||||
const updated = await patchSessionMetadata(sessionId, directory, (metadata) =>
|
||||
return patchSessionMetadata(sessionId, directory, (metadata) =>
|
||||
withContextObligatoryMessage(metadata, message, pinned))
|
||||
const sessionDirectory = (updated as Session & { directory?: string | null }).directory ?? directory ?? undefined
|
||||
mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined)
|
||||
return updated
|
||||
}
|
||||
|
||||
async function cleanupReviewMetadataBeforeDelete(
|
||||
@@ -737,18 +937,41 @@ async function cleanupReviewMetadataBeforeDelete(
|
||||
return
|
||||
}
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return
|
||||
if (!isReviewSession(session)) return
|
||||
const originalSessionID = getOriginalSessionID(session)
|
||||
if (!originalSessionID) return
|
||||
try {
|
||||
await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), (metadata) =>
|
||||
withoutReviewSessionLink(metadata, sessionId),
|
||||
expectedRuntimeKey,
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (/not found/i.test(message)) return
|
||||
console.warn("[session-actions] review metadata cleanup failed before delete", error)
|
||||
|
||||
const unlinkParent = async (originalSessionID: string, unlink: (metadata: SessionMetadataRecord) => SessionMetadataRecord) => {
|
||||
try {
|
||||
await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), unlink, expectedRuntimeKey)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (/not found/i.test(message)) return
|
||||
console.warn("[session-actions] linked-session metadata cleanup failed before delete", error)
|
||||
}
|
||||
}
|
||||
|
||||
if (isReviewSession(session)) {
|
||||
const originalSessionID = getOriginalSessionID(session)
|
||||
if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutReviewSessionLink(metadata, sessionId))
|
||||
return
|
||||
}
|
||||
|
||||
if (isBtwSession(session)) {
|
||||
const originalSessionID = getBtwOriginalSessionID(session)
|
||||
if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutBtwSessionLink(metadata, sessionId))
|
||||
return
|
||||
}
|
||||
|
||||
// Deleting or archiving a session that has an active btw fork also removes
|
||||
// the fork: it is a temporary session that only exists for its parent's
|
||||
// panel. Best-effort — a failed fork delete must not block the parent's
|
||||
// operation; the orphaned fork stays visible in the sidebar.
|
||||
const btwSessionID = getBtwSessionID(session)
|
||||
if (btwSessionID) {
|
||||
try {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return
|
||||
await deleteSession(btwSessionID, { expectedRuntimeKey })
|
||||
} catch (error) {
|
||||
console.warn("[session-actions] failed to delete btw fork before parent delete", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,6 +1045,15 @@ function finalizeConfirmedSessionDeletion(
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDeletedChatDirectory(directory: string | undefined, deleteDirectory: boolean): Promise<void> {
|
||||
if (!directory || !deleteDirectory) return
|
||||
try {
|
||||
await deleteChatDirectory(directory)
|
||||
} catch (error) {
|
||||
console.warn("[session-actions] deleted chat directory cleanup failed", error)
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteSessionOptions = {
|
||||
/**
|
||||
* Runtime key the deletion is scoped to. Defaults to the active runtime when
|
||||
@@ -850,6 +1082,8 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
|
||||
const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey()
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
const sessionSnapshot = getGlobalSessionSnapshot(sessionId)
|
||||
const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null)
|
||||
try {
|
||||
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey)
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
@@ -859,6 +1093,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
|
||||
throw new Error("session.delete failed: server did not confirm deletion")
|
||||
}
|
||||
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey)
|
||||
await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("[session-actions] deleteSession failed", error)
|
||||
@@ -868,6 +1103,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
|
||||
if ((error as { status?: number })?.status === 404) {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey)
|
||||
await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -881,6 +1117,8 @@ export async function deleteSessionInDirectory(
|
||||
expectedRuntimeKey = getRuntimeKey(),
|
||||
): Promise<boolean> {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
const sessionSnapshot = getGlobalSessionSnapshot(sessionId)
|
||||
const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null)
|
||||
try {
|
||||
await cleanupReviewMetadataBeforeDelete(sessionId, directory, expectedRuntimeKey)
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
@@ -890,12 +1128,14 @@ export async function deleteSessionInDirectory(
|
||||
throw new Error("session.delete failed: server did not confirm deletion")
|
||||
}
|
||||
finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey)
|
||||
await cleanupDeletedChatDirectory(directory, deleteManagedDirectory)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("[session-actions] deleteSessionInDirectory failed", error)
|
||||
if ((error as { status?: number })?.status === 404) {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey)
|
||||
await cleanupDeletedChatDirectory(directory, deleteManagedDirectory)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -1135,9 +1375,10 @@ export async function unshareSession(sessionId: string): Promise<Session | null>
|
||||
// Optimistic message send — insert user message before API call, rollback on error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ID generator matching OpenCode's Identifier.ascending format.
|
||||
// ID generator matching OpenCode's Identifier.ascending wire format.
|
||||
// Uses BigInt(timestamp) * 0x1000 + counter, encoded as 6 hex bytes + random base62.
|
||||
// This ensures client-generated IDs sort correctly with server-generated ones.
|
||||
// The 6-byte prefix rolls over, so this value is identity only; transcript
|
||||
// chronology is always derived from message.time.created.
|
||||
let lastIdTimestamp = 0
|
||||
let idCounter = 0
|
||||
|
||||
@@ -1176,6 +1417,7 @@ function ascendingId(prefix: string): string {
|
||||
* handles deduplication when the server echoes back the real message.
|
||||
*/
|
||||
export async function optimisticSend(input: {
|
||||
runtimeKey?: string
|
||||
sessionId: string
|
||||
content: string
|
||||
providerID: string
|
||||
@@ -1192,18 +1434,28 @@ export async function optimisticSend(input: {
|
||||
if (!_optimisticAdd || !_optimisticRemove) {
|
||||
throw new Error("Optimistic refs not set — is useSync() mounted?")
|
||||
}
|
||||
const optimisticAdd = _optimisticAdd
|
||||
const optimisticRemove = _optimisticRemove
|
||||
const optimisticConfirm = _optimisticConfirm
|
||||
|
||||
const assertRuntimeUnchanged = () => {
|
||||
if (input.runtimeKey && input.runtimeKey !== getRuntimeKey()) {
|
||||
throw new Error("Message was not sent because the runtime changed.")
|
||||
}
|
||||
}
|
||||
|
||||
assertRuntimeUnchanged()
|
||||
await waitForConnectionOrThrow()
|
||||
input.beforeOptimisticInsert?.()
|
||||
assertRuntimeUnchanged()
|
||||
|
||||
const targetDirectory = input.directory ?? dir()
|
||||
const store = targetDirectory ? dirStoreForDirectory(targetDirectory) : dirStore()
|
||||
const stateBeforeSend = store.getState()
|
||||
const sessionBeforeSend = stateBeforeSend.session.find((session) => session.id === input.sessionId)
|
||||
const revertMessageID = sessionBeforeSend?.revert?.messageID
|
||||
const revertedMessages = revertMessageID
|
||||
? (stateBeforeSend.message[input.sessionId] ?? []).filter((message) => message.id >= revertMessageID)
|
||||
: []
|
||||
const messagesBeforeSend = stateBeforeSend.message[input.sessionId] ?? []
|
||||
const revertedMessages = messagesFrom(messagesBeforeSend, revertMessageID)
|
||||
const revertedParts = new Map(
|
||||
revertedMessages.map((message) => [message.id, stateBeforeSend.part[message.id] ?? []] as const),
|
||||
)
|
||||
@@ -1214,7 +1466,7 @@ export async function optimisticSend(input: {
|
||||
))
|
||||
const message = {
|
||||
...stateBeforeSend.message,
|
||||
[input.sessionId]: (stateBeforeSend.message[input.sessionId] ?? []).filter((candidate) => candidate.id < revertMessageID),
|
||||
[input.sessionId]: messagesBefore(messagesBeforeSend, revertMessageID),
|
||||
}
|
||||
const part = { ...stateBeforeSend.part }
|
||||
for (const revertedMessage of revertedMessages) delete part[revertedMessage.id]
|
||||
@@ -1260,7 +1512,7 @@ export async function optimisticSend(input: {
|
||||
} as unknown as Message
|
||||
|
||||
// Insert into store + register in shadow Map (for mergeOptimisticPage cleanup)
|
||||
_optimisticAdd({
|
||||
optimisticAdd({
|
||||
sessionID: input.sessionId,
|
||||
directory: targetDirectory,
|
||||
message: optimisticMessage,
|
||||
@@ -1278,6 +1530,7 @@ export async function optimisticSend(input: {
|
||||
})
|
||||
|
||||
try {
|
||||
assertRuntimeUnchanged()
|
||||
await input.send(messageID)
|
||||
} catch (error) {
|
||||
const status = getErrorStatus(error)
|
||||
@@ -1288,7 +1541,7 @@ export async function optimisticSend(input: {
|
||||
|
||||
if (acceptedRecords) {
|
||||
materializeConfirmedSendRecords(store, input.sessionId, messageID, acceptedRecords)
|
||||
_optimisticConfirm?.({
|
||||
optimisticConfirm?.({
|
||||
sessionID: input.sessionId,
|
||||
directory: targetDirectory,
|
||||
messageID,
|
||||
@@ -1315,7 +1568,7 @@ export async function optimisticSend(input: {
|
||||
console.warn("[session-actions] prompt send rejected; rolling back optimistic message", failureRecord)
|
||||
|
||||
// Rollback via optimistic infrastructure
|
||||
_optimisticRemove({
|
||||
optimisticRemove({
|
||||
sessionID: input.sessionId,
|
||||
directory: targetDirectory,
|
||||
messageID,
|
||||
@@ -1331,8 +1584,7 @@ export async function optimisticSend(input: {
|
||||
))
|
||||
message = {
|
||||
...rollbackState.message,
|
||||
[input.sessionId]: [...(rollbackState.message[input.sessionId] ?? []), ...revertedMessages]
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
||||
[input.sessionId]: mergeMessages(rollbackState.message[input.sessionId] ?? [], revertedMessages),
|
||||
}
|
||||
part = { ...rollbackState.part }
|
||||
for (const [revertedMessageID, parts] of revertedParts) {
|
||||
@@ -1578,9 +1830,18 @@ export async function respondToQuestion(
|
||||
if (assertSdkData(result, "question.reply") !== true) {
|
||||
throw new Error("Question reply failed")
|
||||
}
|
||||
// A successful reply is authoritative: the backend resolved the question,
|
||||
// so clear it from the local store deterministically instead of waiting
|
||||
// for the SSE `question.replied` event. A lost event (SSE gap) would leave
|
||||
// the question pending forever, which keeps the session in "waiting for
|
||||
// answer" — the next task's thinking and final response never render
|
||||
// (issues #2911, #2448). The later SSE event is a no-op (the reducer only
|
||||
// removes when present).
|
||||
removeQuestionRequestFromChildStores(sessionId, requestId)
|
||||
} catch (error) {
|
||||
if (isQuestionRequestNotFoundError(error)) {
|
||||
removeQuestionRequestFromChildStores(sessionId, requestId)
|
||||
recoverStaleBlockingRequest(sessionId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -1602,9 +1863,15 @@ export async function rejectQuestion(
|
||||
if (assertSdkData(result, "question.reject") !== true) {
|
||||
throw new Error("Question rejection failed")
|
||||
}
|
||||
// A successful rejection is authoritative: the backend resolved the
|
||||
// question, so clear it from the local store deterministically (see
|
||||
// respondToQuestion for the lost-SSE-event rationale — issues #2911,
|
||||
// #2448). The later SSE `question.rejected` event is a no-op.
|
||||
removeQuestionRequestFromChildStores(sessionId, requestId)
|
||||
} catch (error) {
|
||||
if (isQuestionRequestNotFoundError(error)) {
|
||||
removeQuestionRequestFromChildStores(sessionId, requestId)
|
||||
recoverStaleBlockingRequest(sessionId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -1632,6 +1899,10 @@ export async function rejectQuestion(
|
||||
* abort the session so the OpenCode runner reaches `idle` — otherwise the new
|
||||
* prompt arrives while the run is still active and is discarded by the runner's
|
||||
* `ensureRunning`.
|
||||
*
|
||||
* A successful reject clears the local store deterministically (see
|
||||
* {@link rejectQuestion}) so a lost `question.rejected` SSE event cannot leave
|
||||
* the session in the pending "waiting for answer" state (issues #2911, #2448).
|
||||
*/
|
||||
export async function dismissOpenQuestionsForSession(sessionId: string): Promise<boolean> {
|
||||
if (!sessionId) return false
|
||||
@@ -1693,6 +1964,11 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
const { store, directory } = dirStoreForSession(sessionId)
|
||||
const state = store.getState()
|
||||
|
||||
const localTarget = state.message[sessionId]?.find((message) => message.id === messageId)
|
||||
const targetMessage = localTarget
|
||||
?? (await fetchSessionMessages(sessionId, directory)).find((message) => message.id === messageId)
|
||||
if (!targetMessage) throw new Error(`Cannot revert session: message ${messageId} was not found`)
|
||||
|
||||
// Abort if busy before mutating session state
|
||||
const status = state.session_status[sessionId]
|
||||
if (status && status.type !== "idle") {
|
||||
@@ -1763,6 +2039,9 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
|
||||
// Call SDK and merge authoritative result into store
|
||||
try {
|
||||
// Descendants go first because OpenCode also restores file snapshots during
|
||||
// revert. All sessions share a directory, so the parent's snapshot must win.
|
||||
await cascadeRevertToDescendants(sessionId, targetMessage.time.created)
|
||||
const revertedSession = await opencodeClient.revertSession(sessionId, messageId, undefined, directory)
|
||||
const current = store.getState()
|
||||
const updated = [...current.session]
|
||||
@@ -1845,6 +2124,9 @@ export async function unrevertSession(sessionId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Descendants go first because unrevert can also restore shared file state.
|
||||
// Applying the parent last leaves the working tree at the parent's snapshot.
|
||||
await cascadeUnrevertToDescendants(sessionId)
|
||||
const result = await sdk().session.unrevert({ sessionID: sessionId, directory })
|
||||
const unrevertedSession = assertSdkData(result, "session.unrevert")
|
||||
const current = store.getState()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { getSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { countSyncPerformance } from './performance-diagnostics';
|
||||
|
||||
// Per-session turn timing behind the sidebar activity readout.
|
||||
//
|
||||
@@ -50,6 +51,14 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
|
||||
|
||||
type SessionActivityPhase = 'active' | 'settled';
|
||||
|
||||
export type SessionActivityTimingMutation =
|
||||
| { type: 'observe'; sessionId: string; phase: SessionActivityPhase }
|
||||
| { type: 'remove'; sessionId: string };
|
||||
|
||||
type ActivityTimingDraft = {
|
||||
startedAt: Map<string, number> | null;
|
||||
settledMs: Map<string, number> | null;
|
||||
};
|
||||
|
||||
type SessionActivityTimingState = {
|
||||
startedAt: ReadonlyMap<string, number>;
|
||||
@@ -87,13 +96,13 @@ const RESTORE_ADOPTION_WINDOW_MS = 90_000;
|
||||
// page did not write to looks exactly like "no turn was running".
|
||||
const STORAGE_KEY = 'oc.session-activity.v1';
|
||||
|
||||
const EMPTY_ACTIVE: ReadonlySet<string> = new Set();
|
||||
const EMPTY_RESTORED: ReadonlyMap<string, PersistedStart> = new Map();
|
||||
|
||||
export const useSessionActivityTimingStore = create<SessionActivityTimingState>(() => ({
|
||||
startedAt: new Map(),
|
||||
settledMs: new Map(),
|
||||
}));
|
||||
useSessionActivityTimingStore.subscribe(() => countSyncPerformance('timingPublications'));
|
||||
|
||||
/** Last moment each live start was observed active, for the liveness stamp. */
|
||||
const liveSeen = new Map<string, number>();
|
||||
@@ -355,11 +364,66 @@ export const observeSessionActivityTiming = (
|
||||
sessionId: string,
|
||||
phase: SessionActivityPhase,
|
||||
): void => {
|
||||
if (phase === 'active') {
|
||||
applyTransitions(new Set([sessionId]), null);
|
||||
return;
|
||||
applySessionActivityTimingMutations([{ type: 'observe', sessionId, phase }]);
|
||||
};
|
||||
|
||||
export const applySessionActivityTimingMutations = (
|
||||
mutations: readonly SessionActivityTimingMutation[],
|
||||
): void => {
|
||||
if (mutations.length === 0) return;
|
||||
const now = Date.now();
|
||||
const restored = getAdoptableStarts(now);
|
||||
const state = useSessionActivityTimingStore.getState();
|
||||
const next: ActivityTimingDraft = { startedAt: null, settledMs: null };
|
||||
let restoredChanged = false;
|
||||
let sawActive = false;
|
||||
const currentStarted = (): ReadonlyMap<string, number> => next.startedAt ?? state.startedAt;
|
||||
const currentSettled = (): ReadonlyMap<string, number> => next.settledMs ?? state.settledMs;
|
||||
const draftStarted = (): Map<string, number> => (next.startedAt ??= new Map(state.startedAt));
|
||||
const draftSettled = (): Map<string, number> => (next.settledMs ??= new Map(state.settledMs));
|
||||
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'remove') {
|
||||
if (getRestoredStarts().delete(mutation.sessionId)) restoredChanged = true;
|
||||
liveSeen.delete(mutation.sessionId);
|
||||
if (currentStarted().has(mutation.sessionId)) draftStarted().delete(mutation.sessionId);
|
||||
if (currentSettled().has(mutation.sessionId)) draftSettled().delete(mutation.sessionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mutation.phase === 'active') {
|
||||
sawActive = true;
|
||||
liveSeen.set(mutation.sessionId, now);
|
||||
if (!currentStarted().has(mutation.sessionId)) {
|
||||
draftStarted().set(mutation.sessionId, restored.get(mutation.sessionId)?.start ?? now);
|
||||
}
|
||||
if (currentSettled().has(mutation.sessionId)) draftSettled().delete(mutation.sessionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (getRestoredStarts().delete(mutation.sessionId)) restoredChanged = true;
|
||||
const start = currentStarted().get(mutation.sessionId);
|
||||
if (start === undefined) continue;
|
||||
draftStarted().delete(mutation.sessionId);
|
||||
liveSeen.delete(mutation.sessionId);
|
||||
draftSettled().set(mutation.sessionId, Math.max(0, now - start));
|
||||
}
|
||||
|
||||
if (next.settledMs) trimSettled(next.settledMs);
|
||||
if (next.startedAt || next.settledMs) {
|
||||
useSessionActivityTimingStore.setState({
|
||||
startedAt: next.startedAt ?? state.startedAt,
|
||||
settledMs: next.settledMs ?? state.settledMs,
|
||||
});
|
||||
}
|
||||
if (next.startedAt) {
|
||||
if (next.startedAt.size > 0) ensureLivenessStampOnHide();
|
||||
persistStarts(next.startedAt, now);
|
||||
} else if (restoredChanged) {
|
||||
persistStarts(state.startedAt, now);
|
||||
} else if (sawActive && state.startedAt.size > 0 && now - lastPersistAt >= LIVENESS_PERSIST_INTERVAL_MS) {
|
||||
persistStarts(state.startedAt, now);
|
||||
}
|
||||
applyTransitions(EMPTY_ACTIVE, { source: 'event', sessionId });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -377,32 +441,7 @@ export const reconcileSessionActivityTiming = (
|
||||
};
|
||||
|
||||
export const removeSessionActivityTiming = (sessionId: string): void => {
|
||||
const restoredChanged = getRestoredStarts().delete(sessionId);
|
||||
const state = useSessionActivityTimingStore.getState();
|
||||
const hadStart = state.startedAt.has(sessionId);
|
||||
const hadSettled = state.settledMs.has(sessionId);
|
||||
liveSeen.delete(sessionId);
|
||||
|
||||
if (!hadStart && !hadSettled) {
|
||||
if (restoredChanged) persistStarts(state.startedAt, Date.now());
|
||||
return;
|
||||
}
|
||||
|
||||
let startedAt = state.startedAt;
|
||||
if (hadStart) {
|
||||
const draft = new Map(state.startedAt);
|
||||
draft.delete(sessionId);
|
||||
startedAt = draft;
|
||||
}
|
||||
let settledMs = state.settledMs;
|
||||
if (hadSettled) {
|
||||
const draft = new Map(state.settledMs);
|
||||
draft.delete(sessionId);
|
||||
settledMs = draft;
|
||||
}
|
||||
|
||||
useSessionActivityTimingStore.setState({ startedAt, settledMs });
|
||||
if (hadStart || restoredChanged) persistStarts(startedAt, Date.now());
|
||||
applySessionActivityTimingMutations([{ type: 'remove', sessionId }]);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Event, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { isGlobalSessionRecencyOnlyUpdate, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||||
import {
|
||||
isGlobalSessionRecencyOnlyUpdate,
|
||||
mergeSessionDirectoryMetadata,
|
||||
useGlobalSessionsStore,
|
||||
type GlobalSessionMutation,
|
||||
} from "@/stores/useGlobalSessionsStore"
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch"
|
||||
import { streamPerfCount, streamPerfMark } from "@/stores/utils/streamDebug"
|
||||
import { stripSessionDiffSnapshots } from "./sanitize"
|
||||
@@ -11,23 +16,6 @@ const clearPendingGlobalSessionUpdates = (): void => {
|
||||
pendingGlobalSessionUpdates.clear()
|
||||
}
|
||||
|
||||
const flushPendingGlobalSessionUpdate = (sessionID: string): void => {
|
||||
const update = pendingGlobalSessionUpdates.get(sessionID)
|
||||
pendingGlobalSessionUpdates.delete(sessionID)
|
||||
if (!update) return
|
||||
const runtimeKey = getRuntimeKey()
|
||||
if (update.runtimeKey !== runtimeKey) return
|
||||
const currentSession = getGlobalSessionSnapshot(update.session.id)
|
||||
if (
|
||||
!currentSession
|
||||
|| shouldSkipStaleSessionEvent(currentSession, update.session)
|
||||
|| !isGlobalSessionRecencyOnlyUpdate(currentSession, update.session)
|
||||
) return
|
||||
streamPerfMark("global_sessions.event_update_flush")
|
||||
useGlobalSessionsStore.getState().upsertSession(update.session)
|
||||
streamPerfCount("ui.global_sessions.event_update_publication")
|
||||
}
|
||||
|
||||
const scheduleGlobalSessionUpdate = (session: Session): void => {
|
||||
pendingGlobalSessionUpdates.set(session.id, { runtimeKey: getRuntimeKey(), session })
|
||||
streamPerfCount("ui.global_sessions.event_update_deferred")
|
||||
@@ -58,51 +46,82 @@ const getSessionInfoFromPayload = (event: Event): Session | null => {
|
||||
return stripSessionDiffSnapshots(session as Session)
|
||||
}
|
||||
|
||||
const getGlobalSessionSnapshot = (sessionId: string): Session | null => {
|
||||
const global = useGlobalSessionsStore.getState()
|
||||
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
|
||||
export const applySessionEventsToGlobalSessions = (payloads: readonly Event[]): void => {
|
||||
if (payloads.length === 0) return
|
||||
const runtimeKey = getRuntimeKey()
|
||||
const store = useGlobalSessionsStore.getState()
|
||||
const overlay = new Map(store.entityById)
|
||||
const mutations: GlobalSessionMutation[] = []
|
||||
let flushedRecency = false
|
||||
|
||||
const appendUpsert = (session: Session): void => {
|
||||
const existing = overlay.get(session.id) ?? null
|
||||
const merged = mergeSessionDirectoryMetadata(session, existing)
|
||||
overlay.set(session.id, merged)
|
||||
mutations.push({ type: "upsert", session: merged })
|
||||
}
|
||||
|
||||
for (const payload of payloads) {
|
||||
if (payload.type === "session.idle" || payload.type === "session.error") {
|
||||
const sessionID = (payload as { properties?: { sessionID?: unknown } }).properties?.sessionID
|
||||
if (typeof sessionID !== "string") continue
|
||||
const update = pendingGlobalSessionUpdates.get(sessionID)
|
||||
pendingGlobalSessionUpdates.delete(sessionID)
|
||||
if (!update || update.runtimeKey !== runtimeKey) continue
|
||||
const currentSession = overlay.get(sessionID) ?? null
|
||||
if (
|
||||
!currentSession
|
||||
|| shouldSkipStaleSessionEvent(currentSession, update.session)
|
||||
|| !isGlobalSessionRecencyOnlyUpdate(currentSession, update.session)
|
||||
) continue
|
||||
appendUpsert(update.session)
|
||||
flushedRecency = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === "session.created") {
|
||||
const session = getSessionInfoFromPayload(payload)
|
||||
if (session) {
|
||||
const currentSession = overlay.get(session.id) ?? null
|
||||
if (!shouldSkipStaleSessionEvent(currentSession, session)) appendUpsert(session)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === "session.updated") {
|
||||
const session = getSessionInfoFromPayload(payload)
|
||||
if (session) {
|
||||
const currentSession = overlay.get(session.id) ?? null
|
||||
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
|
||||
if (currentSession && isGlobalSessionRecencyOnlyUpdate(currentSession, session)) {
|
||||
scheduleGlobalSessionUpdate(session)
|
||||
} else {
|
||||
pendingGlobalSessionUpdates.delete(session.id)
|
||||
appendUpsert(session)
|
||||
streamPerfCount("ui.global_sessions.event_update_immediate")
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === "session.deleted") {
|
||||
const sessionID = (payload as { properties?: { sessionID?: string } }).properties?.sessionID
|
||||
?? getSessionInfoFromPayload(payload)?.id
|
||||
if (sessionID) {
|
||||
pendingGlobalSessionUpdates.delete(sessionID)
|
||||
overlay.delete(sessionID)
|
||||
mutations.push({ type: "remove", sessionId: sessionID })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mutations.length === 0 || runtimeKey !== getRuntimeKey()) return
|
||||
if (flushedRecency) streamPerfMark("global_sessions.event_update_flush")
|
||||
store.applySessionMutations(mutations)
|
||||
streamPerfCount("ui.global_sessions.event_update_publication")
|
||||
}
|
||||
|
||||
export const applySessionEventToGlobalSessions = (payload: Event): void => {
|
||||
if (payload.type === "session.idle" || payload.type === "session.error") {
|
||||
const sessionID = (payload as { properties?: { sessionID?: unknown } }).properties?.sessionID
|
||||
if (typeof sessionID === "string") flushPendingGlobalSessionUpdate(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.type === "session.created") {
|
||||
const session = getSessionInfoFromPayload(payload)
|
||||
if (session) {
|
||||
const currentSession = getGlobalSessionSnapshot(session.id)
|
||||
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
|
||||
useGlobalSessionsStore.getState().upsertSession(session)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.type === "session.updated") {
|
||||
const session = getSessionInfoFromPayload(payload)
|
||||
if (session) {
|
||||
const currentSession = getGlobalSessionSnapshot(session.id)
|
||||
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
|
||||
if (currentSession && isGlobalSessionRecencyOnlyUpdate(currentSession, session)) {
|
||||
scheduleGlobalSessionUpdate(session)
|
||||
} else {
|
||||
pendingGlobalSessionUpdates.delete(session.id)
|
||||
useGlobalSessionsStore.getState().upsertSession(session)
|
||||
streamPerfCount("ui.global_sessions.event_update_immediate")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.type === "session.deleted") {
|
||||
const sessionID = (payload as { properties?: { sessionID?: string } }).properties?.sessionID ?? getSessionInfoFromPayload(payload)?.id
|
||||
if (sessionID) {
|
||||
pendingGlobalSessionUpdates.delete(sessionID)
|
||||
useGlobalSessionsStore.getState().removeSessions([sessionID])
|
||||
}
|
||||
}
|
||||
applySessionEventsToGlobalSessions([payload])
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
startSessionLoadPerformanceEvent,
|
||||
} from "./session-load-performance"
|
||||
|
||||
const createRecord = (sessionID: string, id = "msg_1") => ({
|
||||
info: { id, sessionID, role: "user", time: { created: 1 } } as Message,
|
||||
const createRecord = (sessionID: string, id = "msg_1", created = 1) => ({
|
||||
info: { id, sessionID, role: "user", time: { created } } as Message,
|
||||
parts: [{ id: `part_${id}`, messageID: id, sessionID, type: "text", text: "hello" }] as Part[],
|
||||
})
|
||||
|
||||
@@ -65,8 +65,8 @@ describe("SessionMessageLoader", () => {
|
||||
const { childStores, loader } = createLoader(async ({ sessionID, limit, before }) => {
|
||||
calls.push({ limit, before })
|
||||
return before
|
||||
? response([createRecord(sessionID, "msg_older")])
|
||||
: response([createRecord(sessionID, "msg_latest")], "older-cursor")
|
||||
? response([createRecord(sessionID, "msg_older", 1)])
|
||||
: response([createRecord(sessionID, "msg_latest", 2)], "older-cursor")
|
||||
})
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
|
||||
@@ -83,11 +83,35 @@ describe("SessionMessageLoader", () => {
|
||||
{ limit: 100, before: "older-cursor" },
|
||||
])
|
||||
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]?.map((message) => message.id))
|
||||
.toEqual(["msg_latest", "msg_older"].sort())
|
||||
.toEqual(["msg_older", "msg_latest"])
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("keeps a post-rollover tail after legacy messages for shared runtime identities", async () => {
|
||||
const runtimes = ["web", "desktop", "vscode", "mobile"]
|
||||
for (const runtimeKey of runtimes) {
|
||||
const childStores = new ChildStoreManager()
|
||||
const sdk = {
|
||||
session: {
|
||||
messages: async ({ sessionID }: { sessionID: string }) => response([
|
||||
createRecord(sessionID, "msg_000000000000Current", 200),
|
||||
createRecord(sessionID, "msg_ffffffffffffLegacy", 100),
|
||||
]),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const loader = new SessionMessageLoader(childStores, { sdk, runtimeKey })
|
||||
const target = { directory: `/repo-${runtimeKey}`, sessionID: "session-a" }
|
||||
|
||||
await loader.ensure(target)
|
||||
|
||||
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]?.map((message) => message.id))
|
||||
.toEqual(["msg_ffffffffffffLegacy", "msg_000000000000Current"])
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
test("loads every history page for an explicit complete-history request", async () => {
|
||||
const calls: Array<{ before?: string }> = []
|
||||
const { childStores, loader } = createLoader(async ({ sessionID, before }) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ChildStoreManager, DirectoryStore } from "./child-store"
|
||||
import { Binary } from "./binary"
|
||||
import { retry } from "./retry"
|
||||
import { mergeOptimisticPage, type OptimisticItem } from "./optimistic"
|
||||
import { findMessageIndex, insertMessageChronologically, sortMessagesChronologically } from "./message-ordering"
|
||||
import { stripMessageDiffSnapshots } from "./sanitize"
|
||||
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
||||
import {
|
||||
@@ -23,7 +23,6 @@ const CONSTRAINED_INITIAL_MESSAGE_PAGE_SIZE = 30
|
||||
const HISTORY_MESSAGE_PAGE_SIZE = 100
|
||||
const INITIAL_PAGE_EXPANSION_LIMITS = [100, 150] as const
|
||||
const CONSTRAINED_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
|
||||
const cmp = (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0
|
||||
|
||||
export type SessionMessageTarget = {
|
||||
directory: string
|
||||
@@ -109,9 +108,8 @@ const assertSdkSuccess = (result: {
|
||||
throw error
|
||||
}
|
||||
|
||||
const sortParts = (parts: Part[]): Part[] => parts
|
||||
const filterIdentifiedParts = (parts: Part[]): Part[] => parts
|
||||
.filter((part) => Boolean(part?.id))
|
||||
.sort((left, right) => cmp(left.id, right.id))
|
||||
|
||||
const createDefaultState = (generation = 0): SessionMessageLoadState => ({
|
||||
status: "idle",
|
||||
@@ -341,15 +339,16 @@ export class SessionMessageLoader {
|
||||
const target = this.normalizeTarget(input)
|
||||
if (!target) return
|
||||
const entry = this.getEntry(target)
|
||||
entry.optimistic.set(input.message.id, { message: input.message, parts: sortParts(input.parts) })
|
||||
entry.optimistic.set(input.message.id, { message: input.message, parts: filterIdentifiedParts(input.parts) })
|
||||
const store = this.childStores.ensureChild(target.directory, { bootstrap: false })
|
||||
const current = store.getState()
|
||||
const messages = current.message[target.sessionID] ? [...current.message[target.sessionID]] : []
|
||||
const result = Binary.search(messages, input.message.id, (message) => message.id)
|
||||
if (!result.found) messages.splice(result.index, 0, input.message)
|
||||
if (findMessageIndex(messages, input.message.id) < 0) {
|
||||
insertMessageChronologically(messages, input.message)
|
||||
}
|
||||
store.setState({
|
||||
message: { ...current.message, [target.sessionID]: messages },
|
||||
part: { ...current.part, [input.message.id]: sortParts(input.parts) },
|
||||
part: { ...current.part, [input.message.id]: filterIdentifiedParts(input.parts) },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -601,12 +600,12 @@ export class SessionMessageLoader {
|
||||
const records = result.data.filter((record: { info?: { id?: string } }) => Boolean(record?.info?.id))
|
||||
recordCount = records.length
|
||||
if (performance) performance.recordCount += recordCount
|
||||
const session = records
|
||||
.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info))
|
||||
.sort((left: Message, right: Message) => cmp(left.id, right.id))
|
||||
const session = sortMessagesChronologically(
|
||||
records.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info)),
|
||||
)
|
||||
const partsByMessageID = new Map<string, Part[]>()
|
||||
for (const record of records as Array<{ info: { id: string }; parts?: Part[] }>) {
|
||||
partsByMessageID.set(record.info.id, sortParts(record.parts ?? []))
|
||||
partsByMessageID.set(record.info.id, filterIdentifiedParts(record.parts ?? []))
|
||||
}
|
||||
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
|
||||
finishPagePerformance("complete", { retryCount: Math.max(0, attempts - 1), recordCount })
|
||||
|
||||
@@ -4,12 +4,12 @@ import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
import { buildSessionMessageRecordsSnapshot } from './sync-context';
|
||||
import { INITIAL_STATE, type State } from './types';
|
||||
|
||||
const message = (id: string, role: 'user' | 'assistant', parentID?: string): Message => ({
|
||||
const message = (id: string, role: 'user' | 'assistant', parentID?: string, created = 1): Message => ({
|
||||
id,
|
||||
role,
|
||||
sessionID: 'ses_1',
|
||||
...(parentID ? { parentID } : {}),
|
||||
time: { created: 1 },
|
||||
time: { created },
|
||||
} as Message);
|
||||
|
||||
const textPart = (id: string, text: string): Part => ({
|
||||
@@ -34,6 +34,22 @@ const state = (partial: Partial<State>): State => ({
|
||||
});
|
||||
|
||||
describe('buildSessionMessageRecordsSnapshot', () => {
|
||||
test('renders and reverts a rollover-spanning transcript by array chronology', () => {
|
||||
const before = message('msg_ffffffffffffBefore', 'user', undefined, 100);
|
||||
const marker = message('msg_000000000000Marker', 'user', undefined, 200);
|
||||
const after = message('msg_000000000001After', 'assistant', marker.id, 300);
|
||||
|
||||
const snapshot = buildSessionMessageRecordsSnapshot(
|
||||
state({
|
||||
session: [{ id: 'ses_1', revert: { messageID: marker.id } } as State['session'][number]],
|
||||
message: { ses_1: [before, marker, after] },
|
||||
}),
|
||||
'ses_1',
|
||||
);
|
||||
|
||||
expect(snapshot.list.map((record) => record.info.id)).toEqual([before.id]);
|
||||
});
|
||||
|
||||
test('only suspends part updates for the active streaming message', () => {
|
||||
const user = message('user_1', 'user');
|
||||
const assistant1 = message('assistant_1', 'assistant', 'user_1');
|
||||
|
||||
@@ -119,6 +119,32 @@ describe('session lifecycle ordering', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('orders roots, siblings, orphan parents, and cyclic parent scopes deterministically', () => {
|
||||
const rootOlder = session('root-older', 10);
|
||||
const rootNewer = session('root-newer', 20);
|
||||
const childOlder = session('child-older', 5, 'root-older');
|
||||
const childNewer = session('child-newer', 6, 'root-older');
|
||||
const orphanOlder = session('orphan-older', 10, 'missing-parent');
|
||||
const orphanNewer = session('orphan-newer', 20, 'missing-parent');
|
||||
const cycleOlder = session('cycle-older', 10, 'cycle-newer');
|
||||
const cycleNewer = session('cycle-newer', 20, 'cycle-older');
|
||||
|
||||
expect(orderSessionsByLifecycleScopes(
|
||||
[cycleOlder, rootOlder, childOlder, orphanOlder, cycleNewer, rootNewer, childNewer, orphanNewer],
|
||||
new Set(),
|
||||
new Map(),
|
||||
).map((item) => item.id)).toEqual([
|
||||
'orphan-newer',
|
||||
'root-newer',
|
||||
'orphan-older',
|
||||
'root-older',
|
||||
'child-newer',
|
||||
'child-older',
|
||||
'cycle-newer',
|
||||
'cycle-older',
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not promote a root when only its child has lifecycle activity', () => {
|
||||
const rootOlder = session('root-older', 10);
|
||||
const rootNewer = session('root-newer', 20);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { isSessionPinned } from '@/stores/useSessionPinnedStore';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { countSyncPerformance } from './performance-diagnostics';
|
||||
|
||||
type SessionActivityPhase = 'active' | 'settled';
|
||||
export type SessionActivityPhase = 'active' | 'settled';
|
||||
|
||||
export type SessionOrderingMutation =
|
||||
| { type: 'observe'; sessionId: string; phase: SessionActivityPhase }
|
||||
| { type: 'remove'; sessionId: string };
|
||||
|
||||
type SessionOrderingState = {
|
||||
rankById: Map<string, number>;
|
||||
@@ -18,6 +22,7 @@ let lastRank = 0;
|
||||
export const useSessionOrderingStore = create<SessionOrderingState>(() => ({
|
||||
rankById: new Map(),
|
||||
}));
|
||||
useSessionOrderingStore.subscribe(() => countSyncPerformance('orderingPublications'));
|
||||
|
||||
const nextRank = (): number => {
|
||||
lastRank = Math.max(lastRank + 1, Date.now());
|
||||
@@ -42,12 +47,36 @@ export const observeSessionActivityEvent = (
|
||||
sessionId: string,
|
||||
phase: SessionActivityPhase,
|
||||
): void => {
|
||||
const previous = phaseById.get(sessionId);
|
||||
phaseById.set(sessionId, phase);
|
||||
applySessionOrderingMutations([{ type: 'observe', sessionId, phase }]);
|
||||
};
|
||||
|
||||
if (previous === phase) return;
|
||||
if (previous === undefined && phase === 'settled') return;
|
||||
promoteSessions([sessionId]);
|
||||
export const applySessionOrderingMutations = (
|
||||
mutations: readonly SessionOrderingMutation[],
|
||||
): void => {
|
||||
if (mutations.length === 0) return;
|
||||
const currentRanks = useSessionOrderingStore.getState().rankById;
|
||||
let rankById: Map<string, number> | null = null;
|
||||
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'remove') {
|
||||
phaseById.delete(mutation.sessionId);
|
||||
baselineRankById.delete(mutation.sessionId);
|
||||
if ((rankById ?? currentRanks).has(mutation.sessionId)) {
|
||||
rankById ??= new Map(currentRanks);
|
||||
rankById.delete(mutation.sessionId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = phaseById.get(mutation.sessionId);
|
||||
phaseById.set(mutation.sessionId, mutation.phase);
|
||||
if (previous === mutation.phase) continue;
|
||||
if (previous === undefined && mutation.phase === 'settled') continue;
|
||||
rankById ??= new Map(currentRanks);
|
||||
rankById.set(mutation.sessionId, nextRank());
|
||||
}
|
||||
|
||||
if (rankById) useSessionOrderingStore.setState({ rankById });
|
||||
};
|
||||
|
||||
export const reconcileSessionActivitySnapshot = (
|
||||
@@ -71,14 +100,7 @@ export const reconcileSessionActivitySnapshot = (
|
||||
};
|
||||
|
||||
export const removeSessionOrdering = (sessionId: string): void => {
|
||||
phaseById.delete(sessionId);
|
||||
baselineRankById.delete(sessionId);
|
||||
useSessionOrderingStore.setState((state) => {
|
||||
if (!state.rankById.has(sessionId)) return state;
|
||||
const rankById = new Map(state.rankById);
|
||||
rankById.delete(sessionId);
|
||||
return { rankById };
|
||||
});
|
||||
applySessionOrderingMutations([{ type: 'remove', sessionId }]);
|
||||
};
|
||||
|
||||
export const resetSessionOrdering = (): void => {
|
||||
@@ -107,7 +129,7 @@ const sessionDirectory = (session: Session): string | null => {
|
||||
directory?: string | null;
|
||||
project?: { worktree?: string | null } | null;
|
||||
};
|
||||
return normalizePath(record.directory ?? null) ?? normalizePath(record.project?.worktree ?? null);
|
||||
return record.directory ?? record.project?.worktree ?? null;
|
||||
};
|
||||
|
||||
const baselineRank = (session: Session, pinned: boolean): number => {
|
||||
@@ -197,12 +219,39 @@ export const orderSessionsByLifecycleScopes = (
|
||||
sessions: Session[],
|
||||
pinnedSessionIds: Set<string>,
|
||||
rankById: ReadonlyMap<string, number>,
|
||||
hierarchy?: {
|
||||
rootIds: readonly string[];
|
||||
childrenByParentId: ReadonlyMap<string, readonly string[]>;
|
||||
},
|
||||
): Session[] => {
|
||||
countSyncPerformance('sidebarOrderBuilds');
|
||||
const sessionIds = new Set(sessions.map((session) => session.id));
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
const roots: Session[] = [];
|
||||
const childrenByParent = new Map<string, Session[]>();
|
||||
const indexedIds = new Set<string>();
|
||||
|
||||
if (hierarchy) {
|
||||
for (const sessionId of hierarchy.rootIds) {
|
||||
const session = sessionById.get(sessionId);
|
||||
if (!session) continue;
|
||||
indexedIds.add(sessionId);
|
||||
roots.push(session);
|
||||
}
|
||||
for (const [parentId, childIds] of hierarchy.childrenByParentId) {
|
||||
if (!sessionIds.has(parentId)) continue;
|
||||
const children = childIds.flatMap((sessionId) => {
|
||||
const session = sessionById.get(sessionId);
|
||||
if (!session) return [];
|
||||
indexedIds.add(sessionId);
|
||||
return [session];
|
||||
});
|
||||
if (children.length > 0) childrenByParent.set(parentId, children);
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
if (indexedIds.has(session.id)) continue;
|
||||
const parentId = parentIdOf(session);
|
||||
if (!parentId || !sessionIds.has(parentId)) {
|
||||
roots.push(session);
|
||||
@@ -217,9 +266,34 @@ export const orderSessionsByLifecycleScopes = (
|
||||
}
|
||||
}
|
||||
|
||||
const compare = (left: Session, right: Session) => (
|
||||
compareSessionsByLifecycleOrder(left, right, pinnedSessionIds, rankById)
|
||||
);
|
||||
const metadataById = new Map(sessions.map((session) => {
|
||||
const parentId = parentIdOf(session);
|
||||
const pinned = isSessionPinned(pinnedSessionIds, sessionDirectory(session), session.id);
|
||||
const fallback = baselineRank(session, pinned);
|
||||
return [session.id, {
|
||||
parentId,
|
||||
pinned,
|
||||
fallback,
|
||||
lifecycle: rankById.get(session.id) ?? fallback,
|
||||
created: baselineRank(session, true),
|
||||
}] as const;
|
||||
}));
|
||||
countSyncPerformance('sidebarOrderMetadataEntries', metadataById.size);
|
||||
const compare = (left: Session, right: Session): number => {
|
||||
const leftMetadata = metadataById.get(left.id);
|
||||
const rightMetadata = metadataById.get(right.id);
|
||||
if (!leftMetadata || !rightMetadata) return left.id.localeCompare(right.id);
|
||||
if (leftMetadata.pinned !== rightMetadata.pinned) return leftMetadata.pinned ? -1 : 1;
|
||||
if (leftMetadata.parentId === rightMetadata.parentId) {
|
||||
const rankDelta = rightMetadata.lifecycle - leftMetadata.lifecycle;
|
||||
if (rankDelta !== 0) return rankDelta;
|
||||
}
|
||||
const baselineDelta = rightMetadata.fallback - leftMetadata.fallback;
|
||||
if (baselineDelta !== 0) return baselineDelta;
|
||||
const createdDelta = rightMetadata.created - leftMetadata.created;
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
};
|
||||
roots.sort(compare);
|
||||
for (const siblings of childrenByParent.values()) {
|
||||
siblings.sort(compare);
|
||||
@@ -238,7 +312,8 @@ export const orderSessionsByLifecycleScopes = (
|
||||
for (const root of roots) {
|
||||
append(root);
|
||||
}
|
||||
for (const session of sessions) {
|
||||
const remaining = sessions.filter((session) => !visited.has(session.id)).sort(compare);
|
||||
for (const session of remaining) {
|
||||
append(session);
|
||||
}
|
||||
return ordered;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { upsertSessionRecord } from "./session-records"
|
||||
|
||||
const session = (id: string, overrides: Partial<Session> = {}): Session => ({
|
||||
id, slug: id, projectID: "project", directory: "/workspace", title: id, version: "1",
|
||||
time: { created: 1, updated: 1 }, ...overrides,
|
||||
})
|
||||
|
||||
describe("upsertSessionRecord", () => {
|
||||
test("inserts missing IDs in binary order", () => {
|
||||
expect(upsertSessionRecord([session("a"), session("c")], session("b")).map((item) => item.id)).toEqual(["a", "b", "c"])
|
||||
})
|
||||
|
||||
test("preserves references for separately allocated equivalent metadata", () => {
|
||||
const current = [session("a", {
|
||||
metadata: { nested: ["value", { count: 1 }] },
|
||||
summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] },
|
||||
}), session("b")]
|
||||
const incoming = session("a", {
|
||||
metadata: { nested: ["value", { count: 1 }] },
|
||||
summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] },
|
||||
})
|
||||
const result = upsertSessionRecord(current, incoming)
|
||||
expect(result).toBe(current)
|
||||
expect(result[0]).toBe(current[0])
|
||||
expect(result[1]).toBe(current[1])
|
||||
})
|
||||
|
||||
test("replaces a same-ID record when an unlisted semantic field changes", () => {
|
||||
const current = [session("a")]
|
||||
// SAFETY: The runtime SDK payload may contain an additive field before the local Session type is updated.
|
||||
const incoming = {
|
||||
...session("a"),
|
||||
// SDK records can gain fields independently of this synchronization boundary.
|
||||
customField: "changed",
|
||||
} as Session
|
||||
|
||||
expect(upsertSessionRecord(current, incoming)).not.toBe(current)
|
||||
})
|
||||
|
||||
const changes: Array<[string, Partial<Session>, Partial<Session>]> = [
|
||||
["scalars", { workspaceID: "one", path: "a", parentID: "p", cost: 1, agent: "a" }, { workspaceID: "two", path: "b", parentID: "q", cost: 2, agent: "b" }],
|
||||
["tokens", { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } } }, { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 6 } } }],
|
||||
["share and model", { share: { url: "a" }, model: { id: "m", providerID: "p", variant: "a" } }, { share: { url: "b" }, model: { id: "m", providerID: "p", variant: "b" } }],
|
||||
["metadata", { metadata: { key: "a" } }, { metadata: { key: "b" } }],
|
||||
["permission", { permission: [{ permission: "bash", pattern: "*", action: "ask" }] }, { permission: [{ permission: "bash", pattern: "*", action: "allow" }] }],
|
||||
["revert", { revert: { messageID: "m", partID: "a", snapshot: "s", diff: "d" } }, { revert: { messageID: "m", partID: "b", snapshot: "s", diff: "d" } }],
|
||||
["summary diffs", { summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] } }, { summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 2, deletions: 0 }] } }],
|
||||
["time", { time: { created: 1, updated: 1, compacting: 2, archived: 3 } }, { time: { created: 1, updated: 2, compacting: 2, archived: 3 } }],
|
||||
]
|
||||
|
||||
for (const [field, current, incoming] of changes) {
|
||||
test(`replaces only target when ${field} changes`, () => {
|
||||
const first = session("a")
|
||||
const target = session("b", current)
|
||||
const last = session("c")
|
||||
const list = [first, target, last]
|
||||
const result = upsertSessionRecord(list, session("b", incoming))
|
||||
expect(result).not.toBe(list)
|
||||
expect(result[0]).toBe(first)
|
||||
expect(result[1]).not.toBe(target)
|
||||
expect(result[2]).toBe(last)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { Binary } from "./binary"
|
||||
|
||||
function areSessionsEqual(left: Session, right: Session): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
|
||||
export function upsertSessionRecord(current: Session[], incoming: Session): Session[] {
|
||||
const result = Binary.search(current, incoming.id, (session) => session.id)
|
||||
if (!result.found) return [...current.slice(0, result.index), incoming, ...current.slice(result.index)]
|
||||
// Equivalent authoritative detail must retain sidebar session-list references.
|
||||
if (areSessionsEqual(current[result.index], incoming)) return current
|
||||
const next = [...current]
|
||||
next[result.index] = incoming
|
||||
return next
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import { setActionRefs, setOptimisticRefs } from './session-actions';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
|
||||
/**
|
||||
* Unit tests for session worktree routing through the authoritative store.
|
||||
@@ -197,6 +200,47 @@ describe('session-worktree-store worktree routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('draft materialization transition identity', () => {
|
||||
beforeEach(() => {
|
||||
useSessionUIStore.setState({
|
||||
currentSessionId: null,
|
||||
currentSessionDirectory: null,
|
||||
materializedDraftSessionId: null,
|
||||
newSessionDraft: { open: true, target: 'project', directoryOverride: '/projects/alpha' },
|
||||
});
|
||||
});
|
||||
|
||||
test('marks and consumes only the submitted draft session', () => {
|
||||
useSessionUIStore.getState().setCurrentSession(
|
||||
'session-created',
|
||||
'/projects/alpha',
|
||||
'submitted-draft',
|
||||
);
|
||||
|
||||
expect(useSessionUIStore.getState().materializedDraftSessionId).toBe('session-created');
|
||||
|
||||
useSessionUIStore.getState().clearMaterializedDraftSession('another-session');
|
||||
expect(useSessionUIStore.getState().materializedDraftSessionId).toBe('session-created');
|
||||
|
||||
useSessionUIStore.getState().clearMaterializedDraftSession('session-created');
|
||||
expect(useSessionUIStore.getState().materializedDraftSessionId).toBeNull();
|
||||
});
|
||||
|
||||
test('clears the marker when navigating from a draft to an existing session', () => {
|
||||
useSessionUIStore.getState().setCurrentSession(
|
||||
'session-created',
|
||||
'/projects/alpha',
|
||||
'submitted-draft',
|
||||
);
|
||||
useSessionUIStore.setState({
|
||||
newSessionDraft: { open: true, target: 'project', directoryOverride: '/projects/alpha' },
|
||||
});
|
||||
useSessionUIStore.getState().setCurrentSession('session-existing', '/projects/alpha');
|
||||
|
||||
expect(useSessionUIStore.getState().materializedDraftSessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeMessage directory scoping', () => {
|
||||
test('runs sends in the provided session directory', async () => {
|
||||
// The session directory travels as an explicit request param (not via
|
||||
@@ -228,6 +272,85 @@ describe('routeMessage directory scoping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessage captured target', () => {
|
||||
let originalSendMessage;
|
||||
const calls = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
const childStore = {
|
||||
getState: () => ({ session: [], message: {}, part: {}, session_status: {} }),
|
||||
setState: () => {},
|
||||
};
|
||||
const childStores = {
|
||||
children: new Map(),
|
||||
ensureChild: () => childStore,
|
||||
getChild: () => childStore,
|
||||
};
|
||||
setActionRefs(opencodeClient, childStores, () => '/current/project');
|
||||
setOptimisticRefs(() => {}, () => {});
|
||||
useConfigStore.setState({ isConnected: true });
|
||||
useSessionUIStore.setState({
|
||||
currentSessionId: 'session-current',
|
||||
currentSessionDirectory: '/current/project',
|
||||
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
|
||||
});
|
||||
|
||||
originalSendMessage = opencodeClient.sendMessage;
|
||||
opencodeClient.sendMessage = async (params) => {
|
||||
calls.push(params);
|
||||
return 'msg';
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
opencodeClient.sendMessage = originalSendMessage;
|
||||
});
|
||||
|
||||
const sendToTarget = (target) => useSessionUIStore.getState().sendMessage(
|
||||
'queued message',
|
||||
'provider-a',
|
||||
'model-a',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'normal',
|
||||
{ target },
|
||||
);
|
||||
|
||||
test('uses the target captured before the active session changes', async () => {
|
||||
await sendToTarget({
|
||||
runtimeKey: getRuntimeKey(),
|
||||
sessionId: 'session-captured',
|
||||
directory: '/captured/project',
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].runtimeKey).toBe(getRuntimeKey());
|
||||
expect(calls[0].id).toBe('session-captured');
|
||||
expect(calls[0].directory).toBe('/captured/project');
|
||||
});
|
||||
|
||||
test('does not send a captured target through a different runtime', async () => {
|
||||
let error = null;
|
||||
try {
|
||||
await sendToTarget({
|
||||
runtimeKey: `${getRuntimeKey()}-stale`,
|
||||
sessionId: 'session-captured',
|
||||
directory: '/captured/project',
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toContain('runtime changed');
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slash-command goal objectives', () => {
|
||||
test('expands every $ARGUMENTS reference from the authoritative command template', () => {
|
||||
expect(expandSlashCommandGoalObjective('/issue--to-pr LIN-123 --draft', [{
|
||||
@@ -289,16 +412,17 @@ describe('openNewSessionDraft project binding', () => {
|
||||
useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false });
|
||||
});
|
||||
|
||||
test('keeps implicit draft on current directory when active project differs', () => {
|
||||
test('defaults an implicit draft to Chat when active project differs', () => {
|
||||
useSessionUIStore.getState().openNewSessionDraft();
|
||||
const draft = useSessionUIStore.getState().newSessionDraft;
|
||||
|
||||
expect(draft.open).toBe(true);
|
||||
expect(draft.selectedProjectId).toBe(projectB.id);
|
||||
expect(draft.directoryOverride).toBe(projectB.path);
|
||||
expect(draft.target).toBe('chat');
|
||||
expect(draft.selectedProjectId).toBeNull();
|
||||
expect(draft.directoryOverride).toBeNull();
|
||||
});
|
||||
|
||||
test('does not attach active project when current directory is unmatched', () => {
|
||||
test('defaults an implicit draft to Chat when current directory is unmatched', () => {
|
||||
useDirectoryStore.getState().setDirectory('/external/worktree', { showOverlay: false });
|
||||
|
||||
useSessionUIStore.getState().openNewSessionDraft();
|
||||
@@ -306,7 +430,8 @@ describe('openNewSessionDraft project binding', () => {
|
||||
|
||||
expect(draft.open).toBe(true);
|
||||
expect(draft.selectedProjectId).toBeNull();
|
||||
expect(draft.directoryOverride).toBe('/external/worktree');
|
||||
expect(draft.target).toBe('chat');
|
||||
expect(draft.directoryOverride).toBeNull();
|
||||
});
|
||||
|
||||
test('respects explicit directoryOverride over active project', () => {
|
||||
@@ -328,9 +453,21 @@ describe('openNewSessionDraft project binding', () => {
|
||||
|
||||
describe('createSession draft lifecycle', () => {
|
||||
let originalCreateSession;
|
||||
let originalGetDirectoryAvailability;
|
||||
let originalProjects;
|
||||
let originalActiveProjectId;
|
||||
let originalDirectoryState;
|
||||
let originalClientDirectory;
|
||||
let originalLastDirectory;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCreateSession = opencodeClient.createSession;
|
||||
originalGetDirectoryAvailability = opencodeClient.getDirectoryAvailability;
|
||||
originalProjects = useProjectsStore.getState().projects;
|
||||
originalActiveProjectId = useProjectsStore.getState().activeProjectId;
|
||||
originalDirectoryState = useDirectoryStore.getState();
|
||||
originalClientDirectory = opencodeClient.getDirectory();
|
||||
originalLastDirectory = getDeferredSafeStorage().getItem('lastDirectory');
|
||||
useSessionUIStore.setState({
|
||||
currentSessionId: null,
|
||||
currentSessionDirectory: null,
|
||||
@@ -340,6 +477,15 @@ describe('createSession draft lifecycle', () => {
|
||||
|
||||
afterEach(() => {
|
||||
opencodeClient.createSession = originalCreateSession;
|
||||
opencodeClient.getDirectoryAvailability = originalGetDirectoryAvailability;
|
||||
useProjectsStore.setState({ projects: originalProjects, activeProjectId: originalActiveProjectId });
|
||||
useDirectoryStore.setState(originalDirectoryState, true);
|
||||
opencodeClient.setDirectory(originalClientDirectory ?? undefined);
|
||||
if (originalLastDirectory === null) {
|
||||
getDeferredSafeStorage().removeItem('lastDirectory');
|
||||
} else {
|
||||
getDeferredSafeStorage().setItem('lastDirectory', originalLastDirectory);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the draft open when session creation fails', async () => {
|
||||
@@ -353,6 +499,285 @@ describe('createSession draft lifecycle', () => {
|
||||
expect(useSessionUIStore.getState().newSessionDraft.open).toBe(true);
|
||||
expect(useSessionUIStore.getState().newSessionDraft.title).toBe('Draft title');
|
||||
});
|
||||
|
||||
test('rewrites an implicit new-chat draft to the active project before the session is created', async () => {
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
||||
activeProjectId: 'project-main',
|
||||
});
|
||||
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
|
||||
opencodeClient.getDirectoryAvailability = async () => 'missing';
|
||||
|
||||
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
|
||||
await Bun.sleep(0);
|
||||
|
||||
expect(useSessionUIStore.getState().newSessionDraft.directoryOverride).toBe('/projects/main');
|
||||
expect(useSessionUIStore.getState().newSessionDraft.selectedProjectId).toBe('project-main');
|
||||
expect(getDeferredSafeStorage().getItem('lastDirectory')).toBe('/private/deleted-worktree');
|
||||
});
|
||||
|
||||
test('falls back to the current active project when a regular new-chat directory is missing', async () => {
|
||||
const createSessionCalls = [];
|
||||
useProjectsStore.setState({
|
||||
projects: [
|
||||
{ id: 'project-draft', path: '/projects/draft', label: 'Draft' },
|
||||
{ id: 'project-active', path: '/projects/active', label: 'Active' },
|
||||
],
|
||||
activeProjectId: 'project-active',
|
||||
});
|
||||
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
|
||||
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
|
||||
opencodeClient.getDirectoryAvailability = async () => 'missing';
|
||||
opencodeClient.createSession = async (_params, directory) => {
|
||||
createSessionCalls.push(directory);
|
||||
return { id: 'session-fallback', directory };
|
||||
};
|
||||
|
||||
await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
|
||||
|
||||
expect(createSessionCalls).toEqual(['/projects/active']);
|
||||
expect(useDirectoryStore.getState().currentDirectory).toBe('/projects/active');
|
||||
expect(getDeferredSafeStorage().getItem('lastDirectory')).toBe('/projects/active');
|
||||
});
|
||||
|
||||
test('keeps an explicitly pinned worktree directory unchanged', async () => {
|
||||
const createSessionCalls = [];
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
||||
activeProjectId: 'project-main',
|
||||
});
|
||||
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree', preserveDirectoryOverride: true });
|
||||
expect(useSessionUIStore.getState().newSessionDraft.preserveDirectoryOverride).toBe(true);
|
||||
opencodeClient.getDirectoryAvailability = async () => 'missing';
|
||||
opencodeClient.createSession = async (_params, directory) => {
|
||||
createSessionCalls.push(directory);
|
||||
return { id: 'session-pinned', directory };
|
||||
};
|
||||
|
||||
await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
|
||||
|
||||
expect(createSessionCalls).toEqual(['/private/deleted-worktree']);
|
||||
});
|
||||
|
||||
test('keeps a ChatInput-style current-directory draft recoverable when that path is missing', async () => {
|
||||
const createSessionCalls = [];
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
||||
activeProjectId: 'project-main',
|
||||
});
|
||||
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
|
||||
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
|
||||
expect(useSessionUIStore.getState().newSessionDraft.preserveDirectoryOverride).not.toBe(true);
|
||||
opencodeClient.getDirectoryAvailability = async () => 'missing';
|
||||
opencodeClient.createSession = async (_params, directory) => {
|
||||
createSessionCalls.push(directory);
|
||||
return { id: 'session-chat-input', directory };
|
||||
};
|
||||
|
||||
await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
|
||||
|
||||
expect(createSessionCalls).toEqual(['/projects/main']);
|
||||
});
|
||||
|
||||
test('keeps the stale directory when its availability cannot be confirmed', async () => {
|
||||
const createSessionCalls = [];
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
||||
activeProjectId: 'project-main',
|
||||
});
|
||||
useDirectoryStore.getState().setDirectory('/private/unavailable-worktree', { showOverlay: false });
|
||||
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/unavailable-worktree' });
|
||||
opencodeClient.getDirectoryAvailability = async () => 'unknown';
|
||||
opencodeClient.createSession = async (_params, directory) => {
|
||||
createSessionCalls.push(directory);
|
||||
return { id: 'session-unavailable', directory };
|
||||
};
|
||||
|
||||
await useSessionUIStore.getState().createSession('Draft title', '/private/unavailable-worktree');
|
||||
|
||||
expect(createSessionCalls).toEqual(['/private/unavailable-worktree']);
|
||||
expect(useDirectoryStore.getState().currentDirectory).toBe('/private/unavailable-worktree');
|
||||
});
|
||||
|
||||
test('still creates against the active project when the draft is rewritten during the create probe', async () => {
|
||||
const createSessionCalls = [];
|
||||
const availabilityResolvers = [];
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
||||
activeProjectId: 'project-main',
|
||||
});
|
||||
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
|
||||
opencodeClient.getDirectoryAvailability = () => new Promise((resolve) => {
|
||||
availabilityResolvers.push(resolve);
|
||||
});
|
||||
opencodeClient.createSession = async (_params, directory) => {
|
||||
createSessionCalls.push(directory);
|
||||
return { id: 'session-race', directory };
|
||||
};
|
||||
|
||||
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
|
||||
const createPromise = useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
|
||||
expect(availabilityResolvers.length).toBe(2);
|
||||
|
||||
availabilityResolvers[0]('missing');
|
||||
await Bun.sleep(0);
|
||||
expect(useSessionUIStore.getState().newSessionDraft.directoryOverride).toBe('/projects/main');
|
||||
|
||||
availabilityResolvers[1]('missing');
|
||||
const session = await createPromise;
|
||||
|
||||
expect(session).not.toBeNull();
|
||||
expect(createSessionCalls).toEqual(['/projects/main']);
|
||||
});
|
||||
|
||||
test('does not persist a fallback when session creation fails', async () => {
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
||||
activeProjectId: 'project-main',
|
||||
});
|
||||
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
|
||||
useSessionUIStore.getState().openNewSessionDraft();
|
||||
opencodeClient.getDirectoryAvailability = async () => 'missing';
|
||||
opencodeClient.createSession = async () => {
|
||||
throw new Error('offline');
|
||||
};
|
||||
|
||||
const session = await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
|
||||
|
||||
expect(session).toBeNull();
|
||||
expect(useDirectoryStore.getState().currentDirectory).toBe('/private/deleted-worktree');
|
||||
expect(getDeferredSafeStorage().getItem('lastDirectory')).toBe('/private/deleted-worktree');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issues #2222 and #2315 — send target must be snapshotted at submit time so a
|
||||
// later sidebar/project selection cannot reroute a pending draft or session
|
||||
// send to whichever session happens to be current when the async work resumes.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('sendMessage draft snapshot (issues #2222 / #2315)', () => {
|
||||
const sendMessageCalls = [];
|
||||
const createSessionCalls = [];
|
||||
let originalSendMessage;
|
||||
let originalCreateSession;
|
||||
|
||||
beforeEach(() => {
|
||||
sendMessageCalls.length = 0;
|
||||
createSessionCalls.length = 0;
|
||||
|
||||
const childStore = {
|
||||
getState: () => ({ session: [], message: {}, part: {}, session_status: {} }),
|
||||
setState: () => {},
|
||||
};
|
||||
const childStores = {
|
||||
children: new Map(),
|
||||
ensureChild: () => childStore,
|
||||
getChild: () => childStore,
|
||||
};
|
||||
setActionRefs(opencodeClient, childStores, () => '/projects/alpha');
|
||||
setOptimisticRefs(() => {}, () => {});
|
||||
useConfigStore.setState({ isConnected: true });
|
||||
|
||||
originalSendMessage = opencodeClient.sendMessage;
|
||||
originalCreateSession = opencodeClient.createSession;
|
||||
opencodeClient.sendMessage = async (params) => {
|
||||
sendMessageCalls.push(params);
|
||||
return 'msg';
|
||||
};
|
||||
opencodeClient.createSession = async (_params, directory) => {
|
||||
createSessionCalls.push(directory);
|
||||
return { id: 'session-materialized', directory: directory ?? '/projects/alpha' };
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
opencodeClient.sendMessage = originalSendMessage;
|
||||
opencodeClient.createSession = originalCreateSession;
|
||||
useSessionUIStore.setState({
|
||||
currentSessionId: null,
|
||||
currentSessionDirectory: null,
|
||||
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
|
||||
});
|
||||
useProjectsStore.setState({ projects: [], activeProjectId: null });
|
||||
useSessionDisplayStore.setState({ singleProjectId: null });
|
||||
});
|
||||
|
||||
test('draft send snapshots the draft; switching to another project mid-flight still targets the materialized session', async () => {
|
||||
useProjectsStore.setState({
|
||||
projects: [
|
||||
{ id: 'project-alpha', path: '/projects/alpha', label: 'Alpha' },
|
||||
{ id: 'project-beta', path: '/projects/beta', label: 'Beta' },
|
||||
],
|
||||
activeProjectId: 'project-alpha',
|
||||
});
|
||||
useSessionDisplayStore.setState({ singleProjectId: 'project-alpha' });
|
||||
const draftSnapshot = {
|
||||
open: true,
|
||||
directoryOverride: '/projects/alpha',
|
||||
parentID: null,
|
||||
title: 'Project A draft',
|
||||
};
|
||||
useSessionUIStore.setState({
|
||||
currentSessionId: null,
|
||||
currentSessionDirectory: null,
|
||||
newSessionDraft: draftSnapshot,
|
||||
});
|
||||
|
||||
const sendPromise = useSessionUIStore.getState().sendMessage(
|
||||
'message for project A',
|
||||
'provider-a',
|
||||
'model-a',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'normal',
|
||||
{ draftSnapshot },
|
||||
);
|
||||
|
||||
// A sidebar switch while the send is still in flight must not reroute it.
|
||||
useSessionUIStore.getState().setCurrentSession('session-project-b', '/projects/beta');
|
||||
expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-beta');
|
||||
|
||||
await sendPromise;
|
||||
|
||||
expect(createSessionCalls).toHaveLength(1);
|
||||
expect(createSessionCalls[0]).toBe('/projects/alpha');
|
||||
expect(sendMessageCalls).toHaveLength(1);
|
||||
expect(sendMessageCalls[0].id).toBe('session-materialized');
|
||||
expect(sendMessageCalls[0].directory).toBe('/projects/alpha');
|
||||
expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-alpha');
|
||||
});
|
||||
|
||||
test('existing-session send keeps the submit-time target even when selection changes', async () => {
|
||||
useSessionUIStore.setState({
|
||||
currentSessionId: 'session-project-a',
|
||||
currentSessionDirectory: '/projects/alpha',
|
||||
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
|
||||
});
|
||||
|
||||
const sendPromise = useSessionUIStore.getState().sendMessage(
|
||||
'message for project A',
|
||||
'provider-a',
|
||||
'model-a',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'normal',
|
||||
{ target: { runtimeKey: getRuntimeKey(), sessionId: 'session-project-a', directory: '/projects/alpha' } },
|
||||
);
|
||||
|
||||
useSessionUIStore.getState().setCurrentSession('session-project-b', '/projects/beta');
|
||||
|
||||
await sendPromise;
|
||||
|
||||
expect(sendMessageCalls).toHaveLength(1);
|
||||
expect(sendMessageCalls[0].id).toBe('session-project-a');
|
||||
expect(sendMessageCalls[0].directory).toBe('/projects/alpha');
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeMessage skill invocation', () => {
|
||||
@@ -370,7 +795,12 @@ describe('routeMessage skill invocation', () => {
|
||||
|
||||
// Minimal optimistic + connection machinery so routeMessage can dispatch.
|
||||
const childStore = {
|
||||
getState: () => ({ session_status: {} }),
|
||||
getState: () => ({
|
||||
session: [],
|
||||
message: {},
|
||||
part: {},
|
||||
session_status: {},
|
||||
}),
|
||||
setState: () => {},
|
||||
};
|
||||
const childStores = {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* SDK-calling actions that need domain data read it from sync-refs.
|
||||
*/
|
||||
|
||||
import type { ContextPartMetadata } from "@/lib/messages/contextParts"
|
||||
import { create } from "zustand"
|
||||
import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
|
||||
@@ -20,6 +21,8 @@ import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch"
|
||||
import { useConfigStore } from "@/stores/useConfigStore"
|
||||
import { useProjectsStore } from "@/stores/useProjectsStore"
|
||||
import { useSessionDisplayStore } from "@/stores/useSessionDisplayStore"
|
||||
import { fetchSessionKnowledge, reportSessionKnowledgeDelivered } from "@/lib/sessionKnowledgeApi"
|
||||
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from "@/stores/useGlobalSessionsStore"
|
||||
import { useDirectoryStore } from "@/stores/useDirectoryStore"
|
||||
import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore"
|
||||
@@ -28,6 +31,8 @@ import { useSkillsStore } from "@/stores/useSkillsStore"
|
||||
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
|
||||
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
|
||||
import { normalizePath } from "@/lib/pathNormalization"
|
||||
import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, warmChatsRootDirectory } from "@/lib/chatDirectories"
|
||||
import { isVSCodeRuntime } from "@/lib/desktop"
|
||||
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
|
||||
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
|
||||
import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice"
|
||||
@@ -86,6 +91,7 @@ import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache"
|
||||
import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache"
|
||||
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
|
||||
import { contextTokensFromBreakdown } from "@/stores/utils/tokenUtils"
|
||||
|
||||
export type { AttachedFile }
|
||||
|
||||
@@ -122,6 +128,7 @@ export function expandSlashCommandGoalObjective(content: string, commands: GoalC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function routeMessage(params: {
|
||||
runtimeKey?: string
|
||||
sessionId: string
|
||||
directory?: string | null
|
||||
content: string
|
||||
@@ -132,12 +139,13 @@ export function routeMessage(params: {
|
||||
variant?: string
|
||||
inputMode?: "normal" | "shell"
|
||||
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
|
||||
additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }>
|
||||
additionalParts?: Array<{ text: string; synthetic?: boolean; metadata?: ContextPartMetadata; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }>
|
||||
delivery?: 'steer'
|
||||
}): Promise<void> {
|
||||
const requestDirectory = params.directory ?? undefined
|
||||
if (params.inputMode === "shell") {
|
||||
return opencodeClient.shellSession({
|
||||
runtimeKey: params.runtimeKey,
|
||||
sessionId: params.sessionId,
|
||||
directory: requestDirectory,
|
||||
agent: params.agent ?? "",
|
||||
@@ -166,6 +174,7 @@ export function routeMessage(params: {
|
||||
|
||||
if (isCommand) {
|
||||
return optimisticSend({
|
||||
runtimeKey: params.runtimeKey,
|
||||
sessionId: params.sessionId,
|
||||
content: params.content,
|
||||
providerID: params.providerID,
|
||||
@@ -174,6 +183,7 @@ export function routeMessage(params: {
|
||||
directory: requestDirectory,
|
||||
files: params.files,
|
||||
send: (messageID) => opencodeClient.sendCommand({
|
||||
runtimeKey: params.runtimeKey,
|
||||
id: params.sessionId,
|
||||
providerID: params.providerID,
|
||||
modelID: params.modelID,
|
||||
@@ -191,6 +201,7 @@ export function routeMessage(params: {
|
||||
|
||||
// Normal prompt — optimistic insert so message appears instantly
|
||||
return optimisticSend({
|
||||
runtimeKey: params.runtimeKey,
|
||||
sessionId: params.sessionId,
|
||||
content: params.content,
|
||||
providerID: params.providerID,
|
||||
@@ -199,6 +210,7 @@ export function routeMessage(params: {
|
||||
directory: requestDirectory,
|
||||
files: params.files,
|
||||
send: (messageID) => opencodeClient.sendMessage({
|
||||
runtimeKey: params.runtimeKey,
|
||||
id: params.sessionId,
|
||||
providerID: params.providerID,
|
||||
modelID: params.modelID,
|
||||
@@ -215,9 +227,18 @@ export function routeMessage(params: {
|
||||
})
|
||||
}
|
||||
|
||||
type CapturedSendTarget = {
|
||||
runtimeKey: string
|
||||
sessionId: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
type SendMessageOptions = {
|
||||
target?: CapturedSendTarget
|
||||
sessionId?: string
|
||||
directory?: string
|
||||
/** Immutable copy of the new-session draft at submit time; used instead of the live draft. */
|
||||
draftSnapshot?: NewSessionDraftState
|
||||
delivery?: 'steer'
|
||||
}
|
||||
|
||||
@@ -240,10 +261,8 @@ function notifyMessageSent(sessionId: string): void {
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type { SyntheticContextPart } from "./input-store"
|
||||
export type { SessionMemoryState } from "./viewport-store"
|
||||
|
||||
export type NewSessionDraftState = {
|
||||
draftId: number
|
||||
open: boolean
|
||||
selectedProjectId?: string | null
|
||||
directoryOverride: string | null
|
||||
@@ -256,6 +275,9 @@ export type NewSessionDraftState = {
|
||||
initialPrompt?: string
|
||||
syntheticParts?: SyntheticContextPart[]
|
||||
targetFolderId?: string
|
||||
projectContextPins?: { notes: string[]; plans: string[] }
|
||||
target: "chat" | "project"
|
||||
preparedChatDirectory?: string | null
|
||||
}
|
||||
|
||||
export type ViewportAnchor = {
|
||||
@@ -275,6 +297,7 @@ export type SessionHistoryMeta = {
|
||||
export type SessionUIState = {
|
||||
currentSessionId: string | null
|
||||
currentSessionDirectory: string | null
|
||||
materializedDraftSessionId: string | null
|
||||
newSessionDraft: NewSessionDraftState
|
||||
abortPromptSessionId: string | null
|
||||
abortPromptExpiresAt: number | null
|
||||
@@ -297,14 +320,21 @@ export type SessionUIState = {
|
||||
dismissPendingChangesBar: (sessionId: string, signature: string | null) => void
|
||||
|
||||
// Actions — UI state management
|
||||
setCurrentSession: (id: string | null, directoryHint?: string | null) => void
|
||||
setCurrentSession: (
|
||||
id: string | null,
|
||||
directoryHint?: string | null,
|
||||
transition?: "submitted-draft",
|
||||
) => void
|
||||
clearMaterializedDraftSession: (sessionId: string) => void
|
||||
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void
|
||||
restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void
|
||||
openNewSessionDraft: (options?: Partial<NewSessionDraftState> & { automatic?: boolean }) => void
|
||||
prepareChatDraftDirectory: () => Promise<string | null>
|
||||
closeNewSessionDraft: () => void
|
||||
setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void
|
||||
setDraftPreserveDirectoryOverride: (value: boolean) => void
|
||||
setDraftPermissionAutoAcceptEnabled: (enabled: boolean) => void
|
||||
setDraftProjectContextPin: (kind: "note" | "plan", id: string, pinned: boolean) => void
|
||||
acknowledgeSessionAbort: (sessionId: string) => void
|
||||
clearAbortPrompt: () => void
|
||||
armAbortPrompt: (durationMs?: number) => number | null
|
||||
@@ -328,13 +358,18 @@ export type SessionUIState = {
|
||||
agent?: string,
|
||||
attachments?: AttachedFile[],
|
||||
agentMentionName?: string,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }>,
|
||||
variant?: string,
|
||||
inputMode?: "normal" | "shell",
|
||||
options?: SendMessageOptions,
|
||||
) => Promise<void>
|
||||
|
||||
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null, metadata?: Record<string, unknown>) => Promise<Session | null>
|
||||
createSession: (
|
||||
title?: string,
|
||||
directoryOverride?: string | null,
|
||||
parentID?: string | null,
|
||||
metadata?: Record<string, unknown>,
|
||||
) => Promise<Session | null>
|
||||
deleteSession: (id: string, options?: DeleteSessionOptions) => Promise<boolean>
|
||||
deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }>
|
||||
archiveSession: (id: string) => Promise<boolean>
|
||||
@@ -532,10 +567,14 @@ const activateConfigForDirectory = async (directory: string | null | undefined):
|
||||
}
|
||||
|
||||
const DEFAULT_DRAFT: NewSessionDraftState = {
|
||||
draftId: 0,
|
||||
open: false,
|
||||
directoryOverride: null,
|
||||
parentID: null,
|
||||
target: "chat",
|
||||
}
|
||||
let nextDraftId = 1
|
||||
const pendingChatDirectoryByDraft = new Map<string, Promise<string | null>>()
|
||||
|
||||
const activeSessionByRuntime = new Map<string, string | null>()
|
||||
type RuntimeSessionMemory = {
|
||||
@@ -591,14 +630,153 @@ const waitForWorktreeBootstrapIfConfigured = async (directory: string | null, pr
|
||||
}
|
||||
}
|
||||
|
||||
const resolveActiveProjectDirectory = (draft: NewSessionDraftState): string | null => {
|
||||
const projectsState = useProjectsStore.getState()
|
||||
return normalizePath(
|
||||
projectsState.getActiveProject()?.path
|
||||
?? (draft.selectedProjectId
|
||||
? projectsState.projects.find((project) => project.id === draft.selectedProjectId)?.path
|
||||
: null)
|
||||
?? null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regular new-chat drafts inherit the persisted current/last directory. If that
|
||||
* path is confirmed missing (deleted worktree), fall back to the active project.
|
||||
* Explicit worktree targets, in-flight worktree creation, and unknown/offline
|
||||
* probes stay unchanged so a temporary outage cannot rewrite the destination.
|
||||
* A concurrent rewrite of the same implicit draft to that fallback is accepted
|
||||
* instead of aborting create.
|
||||
*/
|
||||
const resolveCreatableDraftDirectory = async (
|
||||
draft: NewSessionDraftState,
|
||||
requestedDirectory: string | null | undefined,
|
||||
): Promise<{ status: "ok"; directory: string | null | undefined } | { status: "aborted" }> => {
|
||||
const directory = requestedDirectory ?? opencodeClient.getDirectory() ?? null
|
||||
const isRecoverableDraftDirectory =
|
||||
draft.open
|
||||
&& draft.preserveDirectoryOverride !== true
|
||||
&& !draft.pendingWorktreeRequestId
|
||||
&& !draft.bootstrapPendingDirectory
|
||||
&& normalizePath(draft.directoryOverride) === normalizePath(directory)
|
||||
|
||||
if (!isRecoverableDraftDirectory || !directory) {
|
||||
return { status: "ok", directory }
|
||||
}
|
||||
|
||||
const activeProjectDirectory = resolveActiveProjectDirectory(draft)
|
||||
if (!activeProjectDirectory || normalizePath(directory) === activeProjectDirectory) {
|
||||
return { status: "ok", directory }
|
||||
}
|
||||
|
||||
const runtimeKey = getRuntimeKey()
|
||||
const draftDirectory = draft.directoryOverride
|
||||
const availability = await opencodeClient.getDirectoryAvailability(directory)
|
||||
const currentDraft = useSessionUIStore.getState().newSessionDraft
|
||||
const currentDirectory = normalizePath(currentDraft.directoryOverride)
|
||||
const capturedDirectory = normalizePath(draftDirectory)
|
||||
// openNewSessionDraft may rewrite the same implicit draft to this fallback
|
||||
// while createSession's probe is still in flight. That is the intended
|
||||
// destination, not a user change, so do not abort the create.
|
||||
const recoveredToActiveProject = currentDirectory === activeProjectDirectory
|
||||
&& capturedDirectory !== activeProjectDirectory
|
||||
const draftChanged = !currentDraft.open
|
||||
|| currentDraft.preserveDirectoryOverride !== draft.preserveDirectoryOverride
|
||||
|| currentDraft.pendingWorktreeRequestId !== draft.pendingWorktreeRequestId
|
||||
|| (currentDirectory !== capturedDirectory && !recoveredToActiveProject)
|
||||
|
||||
if (getRuntimeKey() !== runtimeKey || draftChanged) {
|
||||
return { status: "aborted" }
|
||||
}
|
||||
|
||||
if (recoveredToActiveProject) {
|
||||
return { status: "ok", directory: activeProjectDirectory }
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
directory: availability === "missing" ? activeProjectDirectory : directory,
|
||||
}
|
||||
}
|
||||
|
||||
const recoverStaleDraftDirectory = async (openedDraft: NewSessionDraftState): Promise<void> => {
|
||||
const resolved = await resolveCreatableDraftDirectory(openedDraft, openedDraft.directoryOverride)
|
||||
if (resolved.status !== "ok") return
|
||||
const recovered = normalizePath(resolved.directory ?? null)
|
||||
const original = normalizePath(openedDraft.directoryOverride)
|
||||
if (!recovered || recovered === original) return
|
||||
|
||||
const currentDraft = useSessionUIStore.getState().newSessionDraft
|
||||
if (!currentDraft.open) return
|
||||
if (currentDraft.preserveDirectoryOverride === true) return
|
||||
if (currentDraft.pendingWorktreeRequestId) return
|
||||
if (normalizePath(currentDraft.directoryOverride) !== original) return
|
||||
|
||||
const recoveredProject = useProjectsStore.getState().projects.find((project) => (
|
||||
normalizePath(project.path) === recovered
|
||||
))
|
||||
const nextDraft: NewSessionDraftState = {
|
||||
...currentDraft,
|
||||
selectedProjectId: recoveredProject?.id ?? currentDraft.selectedProjectId,
|
||||
directoryOverride: recovered,
|
||||
}
|
||||
useSessionUIStore.setState({ newSessionDraft: nextDraft })
|
||||
writeRuntimeSessionMemory(runtimeMemoryKey(), { draft: nextDraft })
|
||||
persistDraftTarget({ projectId: nextDraft.selectedProjectId ?? null, directory: recovered })
|
||||
void activateConfigForDirectory(recovered)
|
||||
}
|
||||
|
||||
const createSessionWithDraftLifecycle = async (
|
||||
title?: string,
|
||||
directoryOverride?: string | null,
|
||||
parentID?: string | null,
|
||||
metadata?: Record<string, unknown>,
|
||||
selectionTransition?: "submitted-draft",
|
||||
): Promise<Session | null> => {
|
||||
const store = useSessionUIStore.getState()
|
||||
const draft = store.newSessionDraft
|
||||
const targetFolderId = draft.targetFolderId
|
||||
|
||||
try {
|
||||
const resolved = await resolveCreatableDraftDirectory(draft, directoryOverride)
|
||||
if (resolved.status === "aborted") return null
|
||||
const directory = resolved.directory
|
||||
const session = await createSessionAction(
|
||||
title,
|
||||
directory,
|
||||
parentID ?? null,
|
||||
metadata,
|
||||
selectionTransition,
|
||||
)
|
||||
if (!session) return null
|
||||
|
||||
useSessionUIStore.getState().closeNewSessionDraft()
|
||||
|
||||
if (targetFolderId) {
|
||||
const currentStore = useSessionUIStore.getState()
|
||||
const scopeDirectory = directory || currentStore.lastLoadedDirectory || session.directory
|
||||
const scopeKey = getChatsRootFromDirectory(scopeDirectory) ?? scopeDirectory
|
||||
if (scopeKey) {
|
||||
useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id)
|
||||
}
|
||||
}
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
console.error("[session-ui-store] createSession failed", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function materializeOpenDraftSession(selection: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
agent?: string
|
||||
variant?: string
|
||||
}): Promise<MaterializedDraftSession | null> {
|
||||
}, draftOverride?: NewSessionDraftState): Promise<MaterializedDraftSession | null> {
|
||||
const store = useSessionUIStore.getState()
|
||||
const draft = store.newSessionDraft
|
||||
const draft = draftOverride ?? store.newSessionDraft
|
||||
if (!draft?.open) return null
|
||||
const draftPermissionAutoAcceptEnabled = draft.permissionAutoAcceptEnabled === true
|
||||
|
||||
@@ -613,10 +791,36 @@ export async function materializeOpenDraftSession(selection: {
|
||||
store.resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride)
|
||||
}
|
||||
|
||||
const isChatDraft = draft.target === "chat"
|
||||
if (isChatDraft) {
|
||||
draftDirectoryOverride = await store.prepareChatDraftDirectory()
|
||||
if (!draftDirectoryOverride) throw new Error("Failed to prepare chat directory")
|
||||
const currentDraft = useSessionUIStore.getState().newSessionDraft
|
||||
if (currentDraft.draftId === draft.draftId) {
|
||||
useSessionUIStore.setState({
|
||||
newSessionDraft: { ...currentDraft, preparedChatDirectory: null },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId)
|
||||
|
||||
const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
|
||||
if (!created?.id) throw new Error("Failed to create session")
|
||||
const draftPins = draft.projectContextPins ?? { notes: [], plans: [] }
|
||||
const created = await createSessionWithDraftLifecycle(
|
||||
draft.title,
|
||||
draftDirectoryOverride,
|
||||
draft.parentID ?? null,
|
||||
draftPins.notes.length > 0 || draftPins.plans.length > 0
|
||||
? { openchamber: { project_context_pins: draftPins } }
|
||||
: undefined,
|
||||
"submitted-draft",
|
||||
)
|
||||
if (!created?.id) {
|
||||
if (isChatDraft && draftDirectoryOverride) {
|
||||
await deleteChatDirectory(draftDirectoryOverride).catch(() => undefined)
|
||||
}
|
||||
throw new Error("Failed to create session")
|
||||
}
|
||||
|
||||
// The server response is authoritative. It may canonicalize a requested
|
||||
// worktree path (for example through a symlink or platform path casing).
|
||||
@@ -636,19 +840,22 @@ export async function materializeOpenDraftSession(selection: {
|
||||
})
|
||||
|
||||
const effectiveDraftAgent = trimmedAgent ?? configState.currentAgentName
|
||||
const variantOverride = configState.currentProviderId === selection.providerID
|
||||
&& configState.currentModelId === selection.modelID
|
||||
&& configState.currentAgentName === effectiveDraftAgent
|
||||
? configState.currentVariantSelection.override ?? undefined
|
||||
: selection.variant
|
||||
|
||||
useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID)
|
||||
|
||||
if (effectiveDraftAgent) {
|
||||
useSelectionStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent)
|
||||
useSelectionStore.getState().saveAgentModelForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID)
|
||||
useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, selection.variant)
|
||||
useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, variantOverride)
|
||||
}
|
||||
|
||||
store.initializeNewOpenChamberSession(created.id, configState.agents ?? [])
|
||||
|
||||
store.setCurrentSession(created.id, createdDirectory)
|
||||
|
||||
if (draftPermissionAutoAcceptEnabled) {
|
||||
void import("@/stores/permissionStore")
|
||||
.then(({ usePermissionStore }) => usePermissionStore.getState().setSessionAutoAccept(created.id, true))
|
||||
@@ -689,6 +896,7 @@ const PERSISTED_WORKTREE_MAP = readPersistedWorktreeTopology(runtimeMemoryKey())
|
||||
export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
currentSessionId: null,
|
||||
currentSessionDirectory: null,
|
||||
materializedDraftSessionId: null,
|
||||
newSessionDraft: { ...DEFAULT_DRAFT },
|
||||
abortPromptSessionId: null,
|
||||
abortPromptExpiresAt: null,
|
||||
@@ -707,7 +915,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
// setCurrentSession
|
||||
// ---------------------------------------------------------------------------
|
||||
setCurrentSession: (id, directoryHint?: string | null) => {
|
||||
setCurrentSession: (id, directoryHint?: string | null, transition?: "submitted-draft") => {
|
||||
const materializedDraftSessionId = id && transition === "submitted-draft" ? id : null
|
||||
// Publish the transition identity before closing the draft. Those are two
|
||||
// separate store updates, and ChatContainer must never observe a closed
|
||||
// draft with the previous transition identity.
|
||||
if (get().materializedDraftSessionId !== materializedDraftSessionId) {
|
||||
set({ materializedDraftSessionId })
|
||||
}
|
||||
if (id) {
|
||||
get().closeNewSessionDraft()
|
||||
}
|
||||
@@ -741,7 +956,10 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
|
||||
// Set the directory together with the session id so chat hooks read the
|
||||
// same child store that send/SSE events will update during startup races.
|
||||
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
|
||||
set({
|
||||
currentSessionId: id,
|
||||
currentSessionDirectory: id ? resolvedDir ?? null : null,
|
||||
})
|
||||
guessedSelectionSessionId = isGuessedDir && id ? id : null
|
||||
const rememberedDir = isGuessedDir ? null : resolvedDir ?? null
|
||||
writeRuntimeSessionMemory(key, { sessionId: id, directory: rememberedDir })
|
||||
@@ -766,6 +984,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
if (sessionProject && projectsState.activeProjectId !== sessionProject.id) {
|
||||
projectsState.setActiveProjectIdOnly(sessionProject.id)
|
||||
}
|
||||
if (id && !isGuessedDir && sessionProject) {
|
||||
useSessionDisplayStore.getState().setSingleProjectId(sessionProject.id)
|
||||
}
|
||||
opencodeClient.setDirectory(resolvedDir ?? undefined)
|
||||
} catch (e) {
|
||||
console.warn("Failed to set OpenCode directory for session switch:", e)
|
||||
@@ -775,7 +996,16 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
// skeleton to render and reads messages which can be expensive.
|
||||
if (previousSessionId && previousSessionId !== id) {
|
||||
const prevId = previousSessionId
|
||||
setTimeout(() => {
|
||||
const newId = id
|
||||
// queueMicrotask runs after the current synchronous call stack (and
|
||||
// before the next macrotask / setTimeout(0) / paint), so the previous
|
||||
// session's anchor is saved before the new session's restoreSnapshot
|
||||
// effect fires. This eliminates the race where save and restore
|
||||
// interleave against the same viewport store entry.
|
||||
queueMicrotask(() => {
|
||||
// Bail if the user already switched again — save is now stale.
|
||||
const current = get().currentSessionId
|
||||
if (current !== newId) return
|
||||
const memState = getViewportSessionMemory(prevId)
|
||||
if (!memState?.isStreaming) {
|
||||
const prevMessages = getSyncMessages(prevId)
|
||||
@@ -783,7 +1013,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
useViewportStore.getState().updateViewportAnchor(prevId, prevMessages.length - 1)
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
});
|
||||
}
|
||||
|
||||
// Mark session viewed in notification store + update active session ref
|
||||
@@ -793,6 +1023,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
clearMaterializedDraftSession: (sessionId) => {
|
||||
if (get().materializedDraftSessionId !== sessionId) return
|
||||
set({ materializedDraftSessionId: null })
|
||||
},
|
||||
|
||||
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => {
|
||||
const key = runtimeMemoryKey(apiBaseUrl)
|
||||
const directory = useDirectoryStore.getState().currentDirectory || null
|
||||
@@ -868,7 +1103,16 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
const explicitDirectory = options?.directoryOverride !== undefined
|
||||
? normalizePath(options.directoryOverride)
|
||||
: null
|
||||
const explicitProject = options?.selectedProjectId
|
||||
let target = isVSCodeRuntime() ? "project" : options?.target
|
||||
if (!target) {
|
||||
const hasExplicitProjectTarget = options?.directoryOverride !== undefined
|
||||
|| (options?.selectedProjectId !== undefined && options.selectedProjectId !== CHAT_DRAFT_PROJECT_ID)
|
||||
|| isVSCodeRuntime()
|
||||
target = options?.selectedProjectId === CHAT_DRAFT_PROJECT_ID || !hasExplicitProjectTarget
|
||||
? "chat"
|
||||
: "project"
|
||||
}
|
||||
const explicitProject = target === "project" && options?.selectedProjectId
|
||||
? projects.find((p) => p.id === options.selectedProjectId) ?? null
|
||||
: null
|
||||
|
||||
@@ -885,14 +1129,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null)
|
||||
const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory)
|
||||
|
||||
const selectedProject = (() => {
|
||||
const selectedProject = target === "chat" ? null : (() => {
|
||||
if (explicitProject) return explicitProject
|
||||
if (explicitDirectory !== null) return inferredProjectFromDir
|
||||
if (currentDirectory) return currentDirProject
|
||||
return persistedProjectByDir ?? persistedProjectById ?? fallbackProject
|
||||
})()
|
||||
|
||||
const directory = (() => {
|
||||
const directory = target === "chat" ? null : (() => {
|
||||
if (explicitDirectory !== null) return explicitDirectory
|
||||
if (explicitProject) return normalizePath(explicitProject.path ?? null)
|
||||
if (currentDirectory) return currentDirectory
|
||||
@@ -900,10 +1144,17 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
return normalizePath(selectedProject?.path ?? null)
|
||||
})()
|
||||
|
||||
if (target === "chat") {
|
||||
warmChatsRootDirectory()
|
||||
}
|
||||
|
||||
persistDraftTarget({ projectId: selectedProject?.id ?? null, directory })
|
||||
|
||||
const nextDraft: NewSessionDraftState = {
|
||||
draftId: nextDraftId++,
|
||||
open: true,
|
||||
target,
|
||||
preparedChatDirectory: null,
|
||||
selectedProjectId: selectedProject?.id ?? null,
|
||||
directoryOverride: directory,
|
||||
permissionAutoAcceptEnabled: options?.permissionAutoAcceptEnabled === true,
|
||||
@@ -915,12 +1166,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
initialPrompt: options?.initialPrompt,
|
||||
syntheticParts: options?.syntheticParts,
|
||||
targetFolderId: options?.targetFolderId,
|
||||
projectContextPins: options?.projectContextPins,
|
||||
}
|
||||
|
||||
set({
|
||||
newSessionDraft: {
|
||||
...nextDraft,
|
||||
},
|
||||
newSessionDraft: nextDraft,
|
||||
currentSessionId: null,
|
||||
currentSessionDirectory: null,
|
||||
error: null,
|
||||
@@ -946,12 +1196,45 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
void activateConfigForDirectory(configDirectory).then(() => {
|
||||
useConfigStore.getState().applyDefaultModelAgentSelection({
|
||||
projectDefaultModel: selectedProject?.defaultModel,
|
||||
projectDefaultVariant: selectedProject?.defaultVariant,
|
||||
})
|
||||
})
|
||||
|
||||
if (directory && directory !== useDirectoryStore.getState().currentDirectory) {
|
||||
useDirectoryStore.getState().setDirectory(directory)
|
||||
}
|
||||
|
||||
void recoverStaleDraftDirectory(nextDraft)
|
||||
},
|
||||
|
||||
prepareChatDraftDirectory: async () => {
|
||||
const draft = get().newSessionDraft
|
||||
if (!draft.open || draft.target !== "chat") return null
|
||||
if (draft.preparedChatDirectory) return draft.preparedChatDirectory
|
||||
|
||||
const runtimeKey = getRuntimeKey()
|
||||
const key = `${runtimeKey}:${draft.draftId}`
|
||||
const existing = pendingChatDirectoryByDraft.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const pending = createChatDirectory().then(async (directory) => {
|
||||
const current = get().newSessionDraft
|
||||
if (
|
||||
getRuntimeKey() !== runtimeKey
|
||||
|| !current.open
|
||||
|| current.target !== "chat"
|
||||
|| current.draftId !== draft.draftId
|
||||
) {
|
||||
await deleteChatDirectory(directory).catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
set({ newSessionDraft: { ...current, preparedChatDirectory: directory } })
|
||||
return directory
|
||||
}).finally(() => {
|
||||
pendingChatDirectoryByDraft.delete(key)
|
||||
})
|
||||
pendingChatDirectoryByDraft.set(key, pending)
|
||||
return pending
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -959,6 +1242,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
closeNewSessionDraft: () => {
|
||||
const currentDraft = get().newSessionDraft
|
||||
if (currentDraft.preparedChatDirectory) {
|
||||
void deleteChatDirectory(currentDraft.preparedChatDirectory).catch(() => undefined)
|
||||
}
|
||||
if (
|
||||
!currentDraft.open
|
||||
&& currentDraft.selectedProjectId == null
|
||||
@@ -976,18 +1262,21 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
return
|
||||
}
|
||||
const nextDraft: NewSessionDraftState = {
|
||||
open: false,
|
||||
selectedProjectId: null,
|
||||
directoryOverride: null,
|
||||
pendingWorktreeRequestId: null,
|
||||
bootstrapPendingDirectory: null,
|
||||
preserveDirectoryOverride: false,
|
||||
parentID: null,
|
||||
title: undefined,
|
||||
initialPrompt: undefined,
|
||||
syntheticParts: undefined,
|
||||
targetFolderId: undefined,
|
||||
}
|
||||
draftId: currentDraft.draftId,
|
||||
open: false,
|
||||
target: "chat",
|
||||
preparedChatDirectory: null,
|
||||
selectedProjectId: null,
|
||||
directoryOverride: null,
|
||||
pendingWorktreeRequestId: null,
|
||||
bootstrapPendingDirectory: null,
|
||||
preserveDirectoryOverride: false,
|
||||
parentID: null,
|
||||
title: undefined,
|
||||
initialPrompt: undefined,
|
||||
syntheticParts: undefined,
|
||||
targetFolderId: undefined,
|
||||
}
|
||||
set({
|
||||
newSessionDraft: nextDraft,
|
||||
})
|
||||
@@ -995,14 +1284,21 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
},
|
||||
|
||||
setNewSessionDraftTarget: (target) => {
|
||||
if (isVSCodeRuntime() && target.projectId === CHAT_DRAFT_PROJECT_ID) return
|
||||
const previousDraft = get().newSessionDraft
|
||||
if (previousDraft.preparedChatDirectory && target.projectId !== CHAT_DRAFT_PROJECT_ID) {
|
||||
void deleteChatDirectory(previousDraft.preparedChatDirectory).catch(() => undefined)
|
||||
}
|
||||
let nextDirectory: string | null = null
|
||||
set((s) => {
|
||||
nextDirectory = normalizePath(target.directoryOverride ?? s.newSessionDraft.directoryOverride)
|
||||
return {
|
||||
newSessionDraft: {
|
||||
...s.newSessionDraft,
|
||||
target: target.projectId === CHAT_DRAFT_PROJECT_ID ? "chat" : "project",
|
||||
preparedChatDirectory: target.projectId === CHAT_DRAFT_PROJECT_ID ? s.newSessionDraft.preparedChatDirectory : null,
|
||||
selectedProjectId: target.projectId ?? target.selectedProjectId ?? s.newSessionDraft.selectedProjectId,
|
||||
directoryOverride: target.directoryOverride ?? s.newSessionDraft.directoryOverride,
|
||||
directoryOverride: target.projectId === CHAT_DRAFT_PROJECT_ID ? null : target.directoryOverride ?? s.newSessionDraft.directoryOverride,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1025,6 +1321,22 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
return { newSessionDraft: { ...s.newSessionDraft, permissionAutoAcceptEnabled: enabled } }
|
||||
}),
|
||||
|
||||
setDraftProjectContextPin: (kind, id, pinned) =>
|
||||
set((s) => {
|
||||
if (!s.newSessionDraft?.open) return s
|
||||
const pins = s.newSessionDraft.projectContextPins ?? { notes: [], plans: [] }
|
||||
const key = kind === "note" ? "notes" : "plans"
|
||||
const next = new Set(pins[key])
|
||||
if (pinned) next.add(id)
|
||||
else next.delete(id)
|
||||
return {
|
||||
newSessionDraft: {
|
||||
...s.newSessionDraft,
|
||||
projectContextPins: { ...pins, [key]: [...next] },
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
acknowledgeSessionAbort: (sessionId) =>
|
||||
set((s) => {
|
||||
const flags = new Map(s.sessionAbortFlags)
|
||||
@@ -1062,7 +1374,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
const messages = getSyncMessages(sessionId)
|
||||
if (messages.length === 0) return null
|
||||
|
||||
type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
||||
type AssistantTokens = { total?: number; input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
||||
let lastTokens: AssistantTokens | undefined
|
||||
let lastMessageId: string | undefined
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
@@ -1070,7 +1382,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
if (msg.role !== "assistant") continue
|
||||
const tokens = (msg as { tokens?: AssistantTokens }).tokens
|
||||
if (!tokens) continue
|
||||
const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0)
|
||||
const total = contextTokensFromBreakdown(tokens)
|
||||
if (total > 0) {
|
||||
lastTokens = tokens
|
||||
lastMessageId = msg.id
|
||||
@@ -1080,7 +1392,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
|
||||
if (!lastTokens) return null
|
||||
|
||||
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0)
|
||||
const totalTokens = contextTokensFromBreakdown(lastTokens)
|
||||
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000
|
||||
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0
|
||||
const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined
|
||||
@@ -1193,20 +1505,25 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
agent?: string,
|
||||
attachments?: AttachedFile[],
|
||||
agentMentionName?: string,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }>,
|
||||
variant?: string,
|
||||
inputMode?: "normal" | "shell",
|
||||
options?: SendMessageOptions,
|
||||
) => {
|
||||
const capturedTarget = options?.target
|
||||
if (capturedTarget && capturedTarget.runtimeKey !== getRuntimeKey()) {
|
||||
throw new Error("Message was not sent because the runtime changed.")
|
||||
}
|
||||
|
||||
// Clear non-Git changed-files bar on new user message for current session
|
||||
const sid = options?.sessionId ?? get().currentSessionId;
|
||||
const sid = capturedTarget?.sessionId ?? options?.sessionId ?? get().currentSessionId;
|
||||
if (sid) {
|
||||
const map = new Map(get().pendingChangesBarDismissed);
|
||||
map.delete(sid);
|
||||
set({ pendingChangesBarDismissed: map });
|
||||
}
|
||||
|
||||
const draft = get().newSessionDraft
|
||||
const draft = options?.draftSnapshot ?? get().newSessionDraft
|
||||
const trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined
|
||||
|
||||
const goalArm = inputMode !== "shell" && content.trim().length > 0
|
||||
@@ -1258,18 +1575,31 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
}
|
||||
|
||||
// ---- New session from draft ----
|
||||
if (!options?.sessionId && draft?.open) {
|
||||
if (!capturedTarget && !options?.sessionId && draft?.open) {
|
||||
const createdDraftSession = await materializeOpenDraftSession({
|
||||
providerID,
|
||||
modelID,
|
||||
agent: trimmedAgent,
|
||||
variant,
|
||||
})
|
||||
}, options?.draftSnapshot)
|
||||
if (!createdDraftSession) throw new Error("Failed to create session")
|
||||
|
||||
const mergedAdditionalParts = createdDraftSession.syntheticParts?.length
|
||||
const draftParts = createdDraftSession.syntheticParts?.length
|
||||
? [...(additionalParts || []), ...createdDraftSession.syntheticParts]
|
||||
: additionalParts
|
||||
// The server decides what this session still owes and assembles it; the
|
||||
// client only carries it and reports it delivered.
|
||||
const draftKnowledge = await fetchSessionKnowledge(
|
||||
createdDraftSession.directory,
|
||||
createdDraftSession.sessionId,
|
||||
)
|
||||
const draftPrefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }> =
|
||||
draftKnowledge.text ? [{ text: draftKnowledge.text, synthetic: true }] : []
|
||||
// Left undefined when nothing was added, as before: an empty array is not
|
||||
// the same as no additional parts to everything downstream.
|
||||
const mergedAdditionalParts = draftPrefixParts.length > 0
|
||||
? [...draftPrefixParts, ...(draftParts || [])]
|
||||
: draftParts
|
||||
|
||||
notifyMessageSent(createdDraftSession.sessionId)
|
||||
|
||||
@@ -1298,6 +1628,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
additionalParts: mergedAdditionalParts?.map((p) => ({
|
||||
text: p.text,
|
||||
synthetic: p.synthetic,
|
||||
metadata: p.metadata,
|
||||
files: p.attachments?.map((a: AttachedFile) => ({
|
||||
type: "file" as const,
|
||||
mime: a.mimeType,
|
||||
@@ -1306,11 +1637,20 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
})),
|
||||
})),
|
||||
})
|
||||
// Recorded only after the send resolves: a failed send must carry the
|
||||
// pinned context again rather than assume the agent already saw it.
|
||||
if (draftKnowledge.text) {
|
||||
void reportSessionKnowledgeDelivered(
|
||||
createdDraftSession.directory,
|
||||
createdDraftSession.sessionId,
|
||||
draftKnowledge.signature,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ---- Existing session ----
|
||||
const targetSessionId = options?.sessionId ?? get().currentSessionId
|
||||
const targetSessionId = capturedTarget?.sessionId ?? options?.sessionId ?? get().currentSessionId
|
||||
const sessionAgentSelection = targetSessionId
|
||||
? useSelectionStore.getState().getSessionAgentSelection(targetSessionId)
|
||||
: null
|
||||
@@ -1345,7 +1685,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
}
|
||||
|
||||
const currentSessionDirectory = targetSessionId
|
||||
? normalizePath(options?.directory ?? get().getDirectoryForSession(targetSessionId))
|
||||
? normalizePath(capturedTarget?.directory ?? options?.directory ?? get().getDirectoryForSession(targetSessionId))
|
||||
: null
|
||||
if (targetSessionId) {
|
||||
notifyMessageSent(targetSessionId)
|
||||
@@ -1365,7 +1705,19 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
if (targetSessionId) {
|
||||
await applyArmedGoal(targetSessionId, currentSessionDirectory)
|
||||
}
|
||||
|
||||
// Standing project context — pinned notes and plans, and the memory index.
|
||||
// Prepended so it reads as background before the message it accompanies,
|
||||
// and empty unless the session is actually missing it.
|
||||
const knowledge = await fetchSessionKnowledge(currentSessionDirectory, targetSessionId || "")
|
||||
const prefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }> =
|
||||
knowledge.text ? [{ text: knowledge.text, synthetic: true }] : []
|
||||
const partsWithPinnedContext = prefixParts.length > 0
|
||||
? [...prefixParts, ...(additionalParts || [])]
|
||||
: additionalParts
|
||||
|
||||
await routeMessage({
|
||||
runtimeKey: capturedTarget?.runtimeKey,
|
||||
sessionId: targetSessionId || "",
|
||||
directory: currentSessionDirectory,
|
||||
content,
|
||||
@@ -1377,9 +1729,10 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
inputMode,
|
||||
files,
|
||||
delivery: options?.delivery,
|
||||
additionalParts: additionalParts?.map((p) => ({
|
||||
additionalParts: partsWithPinnedContext?.map((p) => ({
|
||||
text: p.text,
|
||||
synthetic: p.synthetic,
|
||||
metadata: p.metadata,
|
||||
files: p.attachments?.map((a) => ({
|
||||
type: "file" as const,
|
||||
mime: a.mimeType,
|
||||
@@ -1388,42 +1741,27 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
})),
|
||||
})),
|
||||
})
|
||||
if (knowledge.text) {
|
||||
void reportSessionKnowledgeDelivered(currentSessionDirectory, targetSessionId || "", knowledge.signature)
|
||||
}
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createSession
|
||||
// ---------------------------------------------------------------------------
|
||||
createSession: async (title, directoryOverride, parentID, metadata) => {
|
||||
const draft = get().newSessionDraft
|
||||
const targetFolderId = draft.targetFolderId
|
||||
|
||||
try {
|
||||
const dir = directoryOverride ?? opencodeClient.getDirectory()
|
||||
const session = await createSessionAction(title, dir, parentID ?? null, metadata)
|
||||
if (!session) return null
|
||||
|
||||
get().closeNewSessionDraft()
|
||||
|
||||
if (targetFolderId) {
|
||||
const scopeKey = directoryOverride || get().lastLoadedDirectory || session.directory
|
||||
if (scopeKey) {
|
||||
useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id)
|
||||
}
|
||||
}
|
||||
|
||||
return session
|
||||
} catch (e) {
|
||||
console.error("[session-ui-store] createSession failed", e)
|
||||
return null
|
||||
}
|
||||
},
|
||||
createSession: (title, directoryOverride, parentID, metadata) =>
|
||||
createSessionWithDraftLifecycle(title, directoryOverride, parentID, metadata),
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteSession — calls SDK, SSE event updates child store
|
||||
// ---------------------------------------------------------------------------
|
||||
deleteSession: (id, options) => deleteSessionAction(id, options),
|
||||
deleteSession: async (id, options) => deleteSessionAction(id, options),
|
||||
|
||||
deleteSessions: (ids, options) => deleteSessionsAction(ids, options),
|
||||
deleteSessions: async (ids, options) => {
|
||||
const result = await deleteSessionsAction(ids, options)
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
archiveSession: (id) => archiveSessionAction(id),
|
||||
|
||||
@@ -1472,7 +1810,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
const revertToId = currentSession?.revert?.messageID
|
||||
let targetMessage: typeof messages[number] | undefined
|
||||
if (revertToId) {
|
||||
targetMessage = [...userMessages].reverse().find((m) => m.id < revertToId)
|
||||
const revertIndex = userMessages.findIndex((message) => message.id === revertToId)
|
||||
targetMessage = revertIndex > 0 ? userMessages[revertIndex - 1] : undefined
|
||||
} else {
|
||||
targetMessage = userMessages[userMessages.length - 1]
|
||||
}
|
||||
@@ -1519,7 +1858,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
await refetchSessionMessages(sessionId)
|
||||
const messages = getSyncMessages(sessionId)
|
||||
const userMessages = messages.filter((m) => m.role === "user")
|
||||
const targetMessage = userMessages.find((m) => m.id > revertToId)
|
||||
const revertIndex = userMessages.findIndex((message) => message.id === revertToId)
|
||||
const targetMessage = revertIndex >= 0 ? userMessages[revertIndex + 1] : undefined
|
||||
|
||||
if (targetMessage) {
|
||||
await get().revertToMessage(sessionId, targetMessage.id, { skipRedoPush: true })
|
||||
|
||||
@@ -6,30 +6,9 @@ import {
|
||||
formatSessionWorktreeBadge,
|
||||
getSessionWorktreeRepairActions,
|
||||
getMutationBlockingReasons,
|
||||
isWithinWorktreeRoot,
|
||||
buildSessionTargetOptions,
|
||||
} from './session-worktree-contract';
|
||||
|
||||
describe('isWithinWorktreeRoot', () => {
|
||||
test('returns true when candidate equals root', () => {
|
||||
expect(isWithinWorktreeRoot('/repo/worktrees/feat-a', '/repo/worktrees/feat-a')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true when candidate is a subdirectory of root', () => {
|
||||
expect(isWithinWorktreeRoot('/repo/worktrees/feat-a/src', '/repo/worktrees/feat-a')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when candidate is outside root', () => {
|
||||
expect(isWithinWorktreeRoot('/tmp/outside', '/repo/worktrees/feat-a')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when either is null/empty', () => {
|
||||
expect(isWithinWorktreeRoot(null, '/repo')).toBe(false);
|
||||
expect(isWithinWorktreeRoot('/repo', null)).toBe(false);
|
||||
expect(isWithinWorktreeRoot('', '/repo')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttachedSessionDirectory', () => {
|
||||
test('prefers canonical cwd when attachment is healthy', () => {
|
||||
expect(getAttachedSessionDirectory({
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import React, { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2'
|
||||
import { SyncProvider, useSyncDirectory } from './sync-context'
|
||||
import { usePrefetchSessionMessages } from './use-sync'
|
||||
import { installHookTestDom } from '../components/session/sidebar/test-utils/testDom'
|
||||
|
||||
const createSdk = () => createOpencodeClient({
|
||||
baseUrl: 'https://sync.test',
|
||||
fetch: async (request) => {
|
||||
const path = new URL(request instanceof Request ? request.url : request.toString()).pathname
|
||||
if (path.endsWith('/global/event')) {
|
||||
return new Response(new ReadableStream(), { headers: { 'content-type': 'text/event-stream' } })
|
||||
}
|
||||
const body = path.endsWith('/path')
|
||||
? { state: '', config: '', worktree: '/workspace', directory: '/workspace', home: '/home' }
|
||||
: path.endsWith('/project') ? []
|
||||
: path.endsWith('/project/current') ? { id: 'project' }
|
||||
: path.endsWith('/session/status') ? {}
|
||||
: []
|
||||
return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } })
|
||||
},
|
||||
})
|
||||
|
||||
describe('SyncProvider selection boundary', () => {
|
||||
test('does not rerender a stable prefetch consumer when only current directory changes', async () => {
|
||||
const dom = installHookTestDom()
|
||||
const root = createRoot(dom.container)
|
||||
let runtimeRenders = 0
|
||||
let directoryRenders = 0
|
||||
let callback: ReturnType<typeof usePrefetchSessionMessages> | undefined
|
||||
const RuntimeConsumer = React.memo(() => {
|
||||
callback = usePrefetchSessionMessages()
|
||||
runtimeRenders += 1
|
||||
return null
|
||||
})
|
||||
const DirectoryConsumer = () => {
|
||||
useSyncDirectory()
|
||||
directoryRenders += 1
|
||||
return null
|
||||
}
|
||||
const sdk = createSdk()
|
||||
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<SyncProvider sdk={sdk} directory="/workspace/a">
|
||||
<RuntimeConsumer />
|
||||
<DirectoryConsumer />
|
||||
</SyncProvider>,
|
||||
))
|
||||
const initialCallback = callback
|
||||
await act(async () => root.render(
|
||||
<SyncProvider sdk={sdk} directory="/workspace/b">
|
||||
<RuntimeConsumer />
|
||||
<DirectoryConsumer />
|
||||
</SyncProvider>,
|
||||
))
|
||||
expect(runtimeRenders).toBe(1)
|
||||
expect(callback).toBe(initialCallback)
|
||||
expect(directoryRenders).toBe(2)
|
||||
} finally {
|
||||
await act(async () => root.unmount())
|
||||
dom.restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ChildStoreManager,
|
||||
markDirectorySessionPartChanged,
|
||||
subscribeDirectoryPermission,
|
||||
subscribeDirectoryQuestions,
|
||||
subscribeDirectorySessionMessages,
|
||||
type DirectoryBootstrapContext,
|
||||
type DirectoryBootstrapReason,
|
||||
@@ -36,9 +37,11 @@ import { setActionRefs } from "./session-actions"
|
||||
import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
|
||||
import { useSessionUIStore } from "./session-ui-store"
|
||||
import { stripSessionDiffSnapshots } from "./sanitize"
|
||||
import { applySessionEventToGlobalSessions } from "./session-event-router"
|
||||
import { upsertSessionRecord } from "./session-records"
|
||||
import { applySessionEventToGlobalSessions, applySessionEventsToGlobalSessions } from "./session-event-router"
|
||||
import { syncDebug } from "./debug"
|
||||
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
|
||||
import { messagesBefore } from "./message-ordering"
|
||||
import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { usePermissionStore } from "@/stores/permissionStore"
|
||||
import {
|
||||
@@ -50,7 +53,12 @@ import { useTodosPersistStore } from "@/stores/useTodosPersistStore"
|
||||
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
|
||||
import { toast } from "@/components/ui"
|
||||
import { appendNotification } from "./notification-store"
|
||||
import { applyGlobalSessionStatusEvent, applyGlobalSessionStatusSnapshot, useGlobalSessionStatusStore } from "./global-session-status"
|
||||
import {
|
||||
applyGlobalSessionStatusEvent,
|
||||
applyGlobalSessionStatusEvents,
|
||||
applyGlobalSessionStatusSnapshot,
|
||||
useGlobalSessionStatusStore,
|
||||
} from "./global-session-status"
|
||||
import type { State } from "./types"
|
||||
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { PermissionRequest } from "@/types/permission"
|
||||
@@ -67,6 +75,8 @@ import { getPermissionToastKey, showPermissionNeededToast } from "./permission-t
|
||||
import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory"
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
|
||||
import { isFilesystemError } from "@/lib/api/files-errors"
|
||||
import { formatMessage, useI18nStore } from "@/lib/i18n"
|
||||
import { listGlobalSessionPages } from "@/stores/globalSessions"
|
||||
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
|
||||
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
|
||||
@@ -82,22 +92,29 @@ import {
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SyncSystem = {
|
||||
type SyncRuntime = {
|
||||
childStores: ChildStoreManager
|
||||
messageLoader: SessionMessageLoader
|
||||
runtimeKey: string
|
||||
sdk: OpencodeClient
|
||||
}
|
||||
|
||||
type SyncSystem = SyncRuntime & {
|
||||
directory: string
|
||||
}
|
||||
|
||||
const SYNC_CONTEXT_GLOBAL_KEY = "__openchamber_sync_context__"
|
||||
const SYNC_RUNTIME_CONTEXT_GLOBAL_KEY = "__openchamber_sync_runtime_context__"
|
||||
type SyncGlobal = typeof globalThis & {
|
||||
[SYNC_CONTEXT_GLOBAL_KEY]?: React.Context<SyncSystem | null>
|
||||
[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY]?: React.Context<SyncRuntime | null>
|
||||
}
|
||||
|
||||
const syncGlobal = globalThis as SyncGlobal
|
||||
const SyncContext = syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] ?? createContext<SyncSystem | null>(null)
|
||||
syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] = SyncContext
|
||||
const SyncRuntimeContext = syncGlobal[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY] ?? createContext<SyncRuntime | null>(null)
|
||||
syncGlobal[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY] = SyncRuntimeContext
|
||||
|
||||
type SdkResult<T> = {
|
||||
data?: T
|
||||
@@ -133,6 +150,12 @@ function useSyncSystem() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useSyncRuntime() {
|
||||
const ctx = useContext(SyncRuntimeContext)
|
||||
if (!ctx) throw new Error("useSyncRuntime must be used within <SyncProvider>")
|
||||
return ctx
|
||||
}
|
||||
|
||||
function getLiveStates(childStores: ChildStoreManager): State[] {
|
||||
return Array.from(childStores.children.values(), (store) => store.getState())
|
||||
}
|
||||
@@ -143,25 +166,42 @@ function useLiveSyncSelector<T>(
|
||||
subscribe?: (childStores: ChildStoreManager, notify: () => void) => () => void,
|
||||
): T {
|
||||
const { childStores } = useSyncSystem()
|
||||
const cacheRef = useRef<T | undefined>(undefined)
|
||||
const initializedRef = useRef(false)
|
||||
const sourceRevisionRef = useRef(0)
|
||||
const cacheRef = useRef<{
|
||||
childStores: ChildStoreManager
|
||||
selector: (states: State[]) => T
|
||||
revision: number
|
||||
value: T
|
||||
} | null>(null)
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
const next = selector(getLiveStates(childStores))
|
||||
if (initializedRef.current && isEqual(cacheRef.current as T, next)) {
|
||||
return cacheRef.current as T
|
||||
const cached = cacheRef.current
|
||||
if (
|
||||
cached
|
||||
&& cached.childStores === childStores
|
||||
&& cached.selector === selector
|
||||
&& cached.revision === sourceRevisionRef.current
|
||||
) {
|
||||
return cached.value
|
||||
}
|
||||
|
||||
cacheRef.current = next
|
||||
initializedRef.current = true
|
||||
return next
|
||||
const next = selector(getLiveStates(childStores))
|
||||
const value = cached && isEqual(cached.value, next) ? cached.value : next
|
||||
cacheRef.current = { childStores, selector, revision: sourceRevisionRef.current, value }
|
||||
return value
|
||||
}, [childStores, isEqual, selector])
|
||||
|
||||
const subscribeToSource = useCallback((notify: () => void) => {
|
||||
const invalidate = () => {
|
||||
sourceRevisionRef.current += 1
|
||||
notify()
|
||||
}
|
||||
// Force the post-subscribe snapshot to close the read-before-subscribe gap.
|
||||
sourceRevisionRef.current += 1
|
||||
return subscribe ? subscribe(childStores, invalidate) : childStores.subscribeAll(invalidate)
|
||||
}, [childStores, subscribe])
|
||||
|
||||
return React.useSyncExternalStore(
|
||||
useCallback(
|
||||
(notify) => subscribe ? subscribe(childStores, notify) : childStores.subscribeAll(notify),
|
||||
[childStores, subscribe],
|
||||
),
|
||||
subscribeToSource,
|
||||
getSnapshot,
|
||||
getSnapshot,
|
||||
)
|
||||
@@ -177,12 +217,16 @@ type DirectoryEventBatch = {
|
||||
states: Map<StoreApi<DirectoryStore>, DirectoryStore>
|
||||
clonedFields: Map<StoreApi<DirectoryStore>, Set<keyof State>>
|
||||
changedStores: Set<StoreApi<DirectoryStore>>
|
||||
globalSessionEvents: Event[]
|
||||
globalStatusEventsByDirectory: Map<string, Event[]>
|
||||
}
|
||||
|
||||
const createDirectoryEventBatch = (): DirectoryEventBatch => ({
|
||||
states: new Map(),
|
||||
clonedFields: new Map(),
|
||||
changedStores: new Set(),
|
||||
globalSessionEvents: [],
|
||||
globalStatusEventsByDirectory: new Map(),
|
||||
})
|
||||
|
||||
const getDirectoryEventState = (
|
||||
@@ -191,6 +235,10 @@ const getDirectoryEventState = (
|
||||
): DirectoryStore => batch?.states.get(store) ?? store.getState()
|
||||
|
||||
const publishDirectoryEventBatch = (batch: DirectoryEventBatch): void => {
|
||||
applySessionEventsToGlobalSessions(batch.globalSessionEvents)
|
||||
for (const [directory, events] of batch.globalStatusEventsByDirectory) {
|
||||
applyGlobalSessionStatusEvents(directory, events)
|
||||
}
|
||||
for (const store of batch.changedStores) {
|
||||
const state = batch.states.get(store)
|
||||
if (!state) continue
|
||||
@@ -223,7 +271,10 @@ export function useAllSessionStatuses(): Record<string, SessionStatus> {
|
||||
|
||||
export function useAllLiveSessions(): Session[] {
|
||||
return useLiveSyncSelector(
|
||||
useCallback((states) => aggregateLiveSessions(states), []),
|
||||
useCallback((states) => {
|
||||
countSyncPerformance("liveSessionAggregateRuns")
|
||||
return aggregateLiveSessions(states)
|
||||
}, []),
|
||||
areSessionListsEquivalent,
|
||||
useCallback(
|
||||
(childStores: ChildStoreManager, notify: () => void) => childStores.subscribeAllSelected(
|
||||
@@ -458,6 +509,32 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
|
||||
}
|
||||
|
||||
const notification = properties as UiNotificationPayload
|
||||
const kind = asOptionalString(notification.kind)
|
||||
const sessionId = asOptionalString(notification.sessionId)
|
||||
const directory = asOptionalString(notification.directory)
|
||||
?? (fallbackDirectory !== "global" ? fallbackDirectory : "")
|
||||
|
||||
if (kind === "opencode-restart-interrupted") {
|
||||
const dictionary = useI18nStore.getState().dictionary
|
||||
const title = formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.title")
|
||||
const options = {
|
||||
id: "opencode-restart-interrupted",
|
||||
description: formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.description"),
|
||||
duration: Infinity,
|
||||
}
|
||||
if (sessionId && directory) {
|
||||
toast.info(title, {
|
||||
...options,
|
||||
action: {
|
||||
label: formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.openSession"),
|
||||
onClick: () => openSessionFromToast(sessionId, directory),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
toast.info(title, options)
|
||||
}
|
||||
}
|
||||
|
||||
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
|
||||
return true
|
||||
}
|
||||
@@ -471,9 +548,9 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
|
||||
title: asOptionalString(notification.title),
|
||||
body: asOptionalString(notification.body),
|
||||
tag: asOptionalString(notification.tag),
|
||||
kind: asOptionalString(notification.kind),
|
||||
sessionId: asOptionalString(notification.sessionId),
|
||||
directory: asOptionalString(notification.directory) ?? (fallbackDirectory && fallbackDirectory !== "global" ? fallbackDirectory : undefined),
|
||||
kind,
|
||||
sessionId,
|
||||
directory: directory || undefined,
|
||||
requireHidden: notification.requireHidden === true,
|
||||
}).catch((error) => {
|
||||
console.warn("[notifications] failed to dispatch UI notification", error)
|
||||
@@ -633,6 +710,28 @@ async function resyncDirectorySessionStatuses(
|
||||
applySessionStatusSnapshot(store, nextStatuses, candidateSessionIds, mode)
|
||||
if (mode === "authoritative") {
|
||||
applyGlobalSessionStatusSnapshot(directory, nextStatuses, candidateSessionIds)
|
||||
// An authoritative snapshot that settles sessions previously observed
|
||||
// busy/retry can leave their trailing assistant message and tool parts
|
||||
// unfinished (managed process died mid-turn, #2577): finalize them now.
|
||||
// The snapshot write above already lowered their status to explicit idle,
|
||||
// which is the gate the helper requires — a session the snapshot reports
|
||||
// busy stays untouched.
|
||||
for (const sessionId of candidateSessionIds) {
|
||||
const interrupted = interruptedTurnToolParts(store.getState(), sessionId)
|
||||
if (interrupted) {
|
||||
if (!interrupted.parts) {
|
||||
store.setState((state) => ({
|
||||
message: { ...state.message, [sessionId]: interrupted.messages },
|
||||
}))
|
||||
continue
|
||||
}
|
||||
const interruptedParts = interrupted.parts
|
||||
store.setState((state) => ({
|
||||
message: { ...state.message, [sessionId]: interrupted.messages },
|
||||
part: { ...state.part, [interrupted.messageID]: interruptedParts },
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nextStatuses
|
||||
}
|
||||
@@ -731,7 +830,7 @@ const dispatchVSCodeRuntimeNotificationEvent = (directory: string, payload: Even
|
||||
}))
|
||||
}
|
||||
|
||||
const createEventRoutingIndex = (): EventRoutingIndex => ({
|
||||
export const createEventRoutingIndex = (): EventRoutingIndex => ({
|
||||
sessionDirectoryById: new Map(),
|
||||
messageSessionById: new Map(),
|
||||
sessionMessageIdsById: new Map(),
|
||||
@@ -1031,8 +1130,12 @@ const childStoreHasMessagePartState = (
|
||||
return Object.prototype.hasOwnProperty.call(getDirectoryEventState(store, batch).part, messageID)
|
||||
}
|
||||
|
||||
const getActiveDirectoryFallback = (childStores: ChildStoreManager): string | null => {
|
||||
const getActiveDirectoryFallback = (
|
||||
childStores: ChildStoreManager,
|
||||
sessionID?: string | null,
|
||||
): string | null => {
|
||||
if (!_activeDirectory || !_activeSession) return null
|
||||
if (sessionID && sessionID !== _activeSession) return null
|
||||
return childStores.getChild(_activeDirectory) ? _activeDirectory : null
|
||||
}
|
||||
|
||||
@@ -1063,6 +1166,14 @@ const resolveDirectoryFromRoutingIndex = (
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
|
||||
// The global stream does not always include a directory. During a session
|
||||
// transition, its routing index can lag the active session briefly; route
|
||||
// a session-addressed event only when that session is the one being viewed.
|
||||
const activeDirectory = getActiveDirectoryFallback(childStores, sessionID)
|
||||
if (activeDirectory) {
|
||||
return activeDirectory
|
||||
}
|
||||
}
|
||||
|
||||
const messageID = getMessageIdFromPayload(payload)
|
||||
@@ -1199,23 +1310,24 @@ const updateRoutingIndexFromEvent = (
|
||||
* recovery paths only; normal session switches rely on primary SSE reducer
|
||||
* state for `question.asked` / `permission.asked` events. When
|
||||
* `candidateSessionIds` is omitted, every session known to the directory store
|
||||
* is treated as a candidate.
|
||||
* is treated as a candidate; when provided, recovery is limited to those IDs.
|
||||
*/
|
||||
export async function resyncBlockingRequestsForDirectory(
|
||||
directory: string,
|
||||
store: StoreApi<DirectoryStore>,
|
||||
candidateSessionIds?: string[],
|
||||
options?: { includePermissions?: boolean },
|
||||
) {
|
||||
const before = store.getState()
|
||||
const knownSessionIds = new Set<string>([
|
||||
const candidateIds = new Set<string>(candidateSessionIds ?? [
|
||||
...before.session.map((session) => session.id),
|
||||
...Object.keys(before.message ?? {}),
|
||||
...Object.keys(before.session_status ?? {}),
|
||||
...Object.keys(before.question ?? {}),
|
||||
...Object.keys(before.permission ?? {}),
|
||||
])
|
||||
const candidates = candidateSessionIds ?? Array.from(knownSessionIds)
|
||||
if (candidates.length === 0) return
|
||||
if (candidateIds.size === 0) return
|
||||
const candidates = Array.from(candidateIds)
|
||||
|
||||
// Re-fetch pending questions that may have been asked during an SSE gap,
|
||||
// reconnect window, or directory materialization gap.
|
||||
@@ -1225,12 +1337,12 @@ export async function resyncBlockingRequestsForDirectory(
|
||||
)
|
||||
const pendingQuestions = await opencodeClient.listPendingQuestions({ directories: [directory] })
|
||||
const grouped: Record<string, QuestionRequest[]> = {}
|
||||
for (const q of pendingQuestions) {
|
||||
if (!q?.id || !q.sessionID) continue
|
||||
if (!knownSessionIds.has(q.sessionID)) continue
|
||||
const list = grouped[q.sessionID]
|
||||
if (list) list.push(q)
|
||||
else grouped[q.sessionID] = [q]
|
||||
for (const question of pendingQuestions) {
|
||||
if (!question?.id || !question.sessionID) continue
|
||||
if (!candidateIds.has(question.sessionID)) continue
|
||||
const list = grouped[question.sessionID]
|
||||
if (list) list.push(question)
|
||||
else grouped[question.sessionID] = [question]
|
||||
}
|
||||
for (const sessionId of Object.keys(grouped)) {
|
||||
grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
@@ -1277,6 +1389,8 @@ export async function resyncBlockingRequestsForDirectory(
|
||||
// Non-fatal: question resync best-effort
|
||||
}
|
||||
|
||||
if (options?.includePermissions === false) return
|
||||
|
||||
// Re-fetch pending permissions — same rationale as questions.
|
||||
try {
|
||||
const beforeSignatures = new Map(
|
||||
@@ -1286,7 +1400,7 @@ export async function resyncBlockingRequestsForDirectory(
|
||||
const grouped: Record<string, PermissionRequest[]> = {}
|
||||
for (const permission of pendingPermissions) {
|
||||
if (!permission?.id || !permission.sessionID) continue
|
||||
if (!knownSessionIds.has(permission.sessionID)) continue
|
||||
if (!candidateIds.has(permission.sessionID)) continue
|
||||
const list = grouped[permission.sessionID]
|
||||
if (list) list.push(permission)
|
||||
else grouped[permission.sessionID] = [permission]
|
||||
@@ -1351,6 +1465,15 @@ export async function resyncBlockingRequestsForDirectory(
|
||||
}
|
||||
}
|
||||
|
||||
export async function resyncBlockingRequestsForActiveDirectory(
|
||||
directory: string,
|
||||
childStores: ChildStoreManager,
|
||||
) {
|
||||
const store = childStores.getChild(directory)
|
||||
if (!store) return
|
||||
await resyncBlockingRequestsForDirectory(directory, store)
|
||||
}
|
||||
|
||||
async function resyncDirectoryAfterReconnect(
|
||||
directory: string,
|
||||
store: StoreApi<DirectoryStore>,
|
||||
@@ -1380,28 +1503,13 @@ async function resyncDirectoryAfterReconnect(
|
||||
|
||||
const nextSession = stripSessionDiffSnapshots(session)
|
||||
store.setState((state: DirectoryStore) => {
|
||||
const sessionIndex = state.session.findIndex((item) => item.id === nextSession.id)
|
||||
let sessions = state.session
|
||||
let sessionChanged = false
|
||||
const sessions = upsertSessionRecord(state.session, nextSession)
|
||||
let sessionTotal = state.sessionTotal
|
||||
|
||||
if (sessionIndex >= 0) {
|
||||
if (!haveEquivalentSyncSnapshots(sessions[sessionIndex], nextSession)) {
|
||||
sessions = [...state.session]
|
||||
sessions[sessionIndex] = nextSession
|
||||
sessionChanged = true
|
||||
}
|
||||
} else {
|
||||
sessions = [...state.session]
|
||||
sessions.push(nextSession)
|
||||
sessions.sort((a, b) => cmp(a.id, b.id))
|
||||
if (!nextSession.parentID) sessionTotal += 1
|
||||
sessionChanged = true
|
||||
}
|
||||
|
||||
if (!sessionChanged) {
|
||||
if (sessions === state.session) {
|
||||
return state
|
||||
}
|
||||
if (!state.session.some((item) => item.id === nextSession.id) && !nextSession.parentID) sessionTotal += 1
|
||||
|
||||
return {
|
||||
session: sessions,
|
||||
@@ -1418,7 +1526,7 @@ async function resyncDirectoryAfterReconnect(
|
||||
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||
}
|
||||
|
||||
function handleEvent(
|
||||
export function handleEvent(
|
||||
rawDirectory: string,
|
||||
payload: Event,
|
||||
childStores: ChildStoreManager,
|
||||
@@ -1427,6 +1535,7 @@ function handleEvent(
|
||||
skipVSCodeAutoAccept = false,
|
||||
streamingDirectory?: string,
|
||||
batch?: DirectoryEventBatch,
|
||||
globalEffectsAlreadyApplied = false,
|
||||
) {
|
||||
if ((payload as { type?: unknown }).type === "openchamber:permission-auto-accept.updated") {
|
||||
const properties = (payload as unknown as { properties?: unknown }).properties
|
||||
@@ -1455,12 +1564,19 @@ function handleEvent(
|
||||
return
|
||||
}
|
||||
|
||||
applySessionEventToGlobalSessions(payload)
|
||||
// Keep the cross-project status map current for ALL directories (mirrors the
|
||||
// global-session handling above). Child stores remain the primary source for
|
||||
// synced directories; this map covers sessions a child store doesn't list
|
||||
// (unopened directories, or list/status races for just-created sessions).
|
||||
applyGlobalSessionStatusEvent(directory, payload)
|
||||
if (!globalEffectsAlreadyApplied) {
|
||||
if (batch) {
|
||||
batch.globalSessionEvents.push(payload)
|
||||
const statusEvents = batch.globalStatusEventsByDirectory.get(directory)
|
||||
if (statusEvents) statusEvents.push(payload)
|
||||
else batch.globalStatusEventsByDirectory.set(directory, [payload])
|
||||
} else {
|
||||
applySessionEventToGlobalSessions(payload)
|
||||
// Child stores remain the primary source for synced directories; this
|
||||
// index covers unopened directories and list/status races.
|
||||
applyGlobalSessionStatusEvent(directory, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// Global events
|
||||
if (directory === "global" || !directory) {
|
||||
@@ -1543,7 +1659,17 @@ function handleEvent(
|
||||
if (eventKey && pendingVSCodePermissionEvents.get(eventKey) !== eventToken) return
|
||||
if (eventKey) pendingVSCodePermissionEvents.delete(eventKey)
|
||||
if (expectedRuntimeKey !== getRuntimeKey()) return
|
||||
if (!accepted) handleEvent(rawDirectory, payload, childStores, routingIndex, expectedRuntimeKey, true, streamingDirectory)
|
||||
if (!accepted) handleEvent(
|
||||
rawDirectory,
|
||||
payload,
|
||||
childStores,
|
||||
routingIndex,
|
||||
expectedRuntimeKey,
|
||||
true,
|
||||
streamingDirectory,
|
||||
undefined,
|
||||
true,
|
||||
)
|
||||
}
|
||||
void processVSCodePermissionAutoAccept(permission, resolvedDirectory).then(
|
||||
completePermissionCheck,
|
||||
@@ -1668,6 +1794,12 @@ function handleEvent(
|
||||
case "session.deleted":
|
||||
cloneField("session", (value) => [...value])
|
||||
cloneField("permission", (value) => ({ ...value }))
|
||||
if (
|
||||
payload.type === "session.deleted"
|
||||
|| (payload.type === "session.updated" && Boolean((payload.properties as { info?: Session }).info?.time.archived))
|
||||
) {
|
||||
cloneField("question", (value) => ({ ...value }))
|
||||
}
|
||||
cloneField("todo", (value) => ({ ...value }))
|
||||
cloneField("part", (value) => ({ ...value }))
|
||||
cloneField("sessionEventRevision", (value) => ({ ...(value ?? {}) }))
|
||||
@@ -1812,11 +1944,139 @@ function handleEvent(
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
// The reducer already wrote the idle/error status into `draft`; finalize
|
||||
// the interrupted message and orphaned tools through the same batch.
|
||||
if (sessionID) {
|
||||
const interrupted = interruptedTurnToolParts(state, sessionID)
|
||||
if (interrupted) {
|
||||
cloneField("message", (value) => ({ ...value }))
|
||||
draft.message[sessionID] = interrupted.messages
|
||||
if (interrupted.parts) {
|
||||
cloneField("part", (value) => ({ ...(value ?? {}) }))
|
||||
draft.part[interrupted.messageID] = interrupted.parts
|
||||
}
|
||||
if (batch) {
|
||||
batch.states.set(store, draft as DirectoryStore)
|
||||
batch.changedStores.add(store)
|
||||
} else {
|
||||
const currentState = store.getState()
|
||||
if (interrupted.parts) {
|
||||
store.setState({
|
||||
message: { ...currentState.message, [sessionID]: interrupted.messages },
|
||||
part: { ...currentState.part, [interrupted.messageID]: interrupted.parts },
|
||||
})
|
||||
} else {
|
||||
store.setState({
|
||||
message: { ...currentState.message, [sessionID]: interrupted.messages },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interrupted-turn reconciliation
|
||||
//
|
||||
// A managed OpenCode process can die mid-turn (crash, health-check restart).
|
||||
// The persisted turn then never settles: the trailing assistant message has
|
||||
// no `time.completed`, and any tool parts can stay `pending`/`running`
|
||||
// forever — the server never finalizes them (anomalyco/opencode#19023). The
|
||||
// settle-triggered tail refresh above refetches the same stale records, so
|
||||
// the UI would keep the assistant message unfinished and any tool timers and
|
||||
// "working" styling active indefinitely (#2577).
|
||||
//
|
||||
// OpenCode keeps a turn's session busy while it is genuinely alive —
|
||||
// including while waiting for a question/permission reply — so once a
|
||||
// session is AUTHORITATIVELY settled (a `session.idle`/`session.error`
|
||||
// event, or an authoritative status snapshot that lowers a previously busy
|
||||
// session) and the trailing assistant message is still unfinished with
|
||||
// no pending question/permission, the turn is definitively interrupted.
|
||||
// Complete the assistant message locally with MessageAbortedError and finalize
|
||||
// any orphaned parts as `error`/`Interrupted` with an end time — the same shape
|
||||
// OpenCode itself writes for cancelled tools. A later terminal event can
|
||||
// supersede the mark; a stale refresh cannot regress the locally final state.
|
||||
type AssistantMessage = Extract<Message, { role: "assistant" }>
|
||||
type SdkMessageAbortedError = Extract<NonNullable<AssistantMessage["error"]>, { name: "MessageAbortedError" }>
|
||||
type LocalMessageAbortedError = SdkMessageAbortedError & { message: string }
|
||||
|
||||
export function interruptedTurnToolParts(
|
||||
state: DirectoryStore,
|
||||
sessionID: string,
|
||||
now = Date.now(),
|
||||
): { messageID: string; messages: Message[]; parts?: Part[] } | null {
|
||||
if ((state.question?.[sessionID] ?? []).length > 0) return null
|
||||
if ((state.permission?.[sessionID] ?? []).length > 0) return null
|
||||
|
||||
const status = state.session_status?.[sessionID]
|
||||
if (!status || status.type !== "idle") {
|
||||
// Absent status is "unknown", not settled (the reducer maps both
|
||||
// session.idle and session.error to {type:"idle"}): never judge an
|
||||
// interrupted turn without an authoritative settle signal.
|
||||
return null
|
||||
}
|
||||
|
||||
const messages = state.message[sessionID] ?? []
|
||||
let messageIndex = -1
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = messages[index]
|
||||
if (candidate.role === "user") return null
|
||||
if (candidate.role !== "assistant") continue
|
||||
messageIndex = index
|
||||
break
|
||||
}
|
||||
if (messageIndex < 0) return null
|
||||
|
||||
const message = messages[messageIndex]
|
||||
if (message.role !== "assistant") return null
|
||||
if (message.time.completed !== undefined) {
|
||||
// The turn finished; a missed terminal tool event is the tail refresh's
|
||||
// job, not an interruption.
|
||||
return null
|
||||
}
|
||||
|
||||
const messageID = message.id
|
||||
const nextMessages = [...messages]
|
||||
const error = {
|
||||
name: "MessageAbortedError",
|
||||
data: { message: "aborted" },
|
||||
message: "aborted",
|
||||
} satisfies LocalMessageAbortedError
|
||||
nextMessages[messageIndex] = {
|
||||
...message,
|
||||
time: { ...message.time, completed: now },
|
||||
error,
|
||||
}
|
||||
|
||||
let partsChanged = false
|
||||
const currentParts = state.part[messageID]
|
||||
const nextParts = currentParts?.map((part) => {
|
||||
if (part.type !== "tool") return part
|
||||
if (part.state.status !== "pending" && part.state.status !== "running") return part
|
||||
partsChanged = true
|
||||
const partTime = "time" in part.state ? part.state.time : undefined
|
||||
const start = typeof partTime?.start === "number" ? partTime.start : now
|
||||
return {
|
||||
...part,
|
||||
state: {
|
||||
...part.state,
|
||||
status: "error" as const,
|
||||
error: "Interrupted",
|
||||
time: { start, end: now },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
messageID,
|
||||
messages: nextMessages,
|
||||
parts: partsChanged ? nextParts : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1862,20 +2122,19 @@ export function SyncProvider(props: {
|
||||
const lastFullResyncAtByDirectoryRef = useRef(new Map<string, number>())
|
||||
const lastChildDiscoveryAtByDirectoryRef = useRef(new Map<string, number>())
|
||||
const resyncingDirectoriesRef = useRef(new Set<string>())
|
||||
const blockingRequestResyncingDirectoriesRef = useRef(new Set<string>())
|
||||
const statusPollingDirectoriesRef = useRef(new Set<string>())
|
||||
const pipelineReconnectRef = useRef<((reason?: string) => void) | null>(null)
|
||||
const pipelineHasConnectedRef = useRef(false)
|
||||
const pipelineDisconnectedBeforeFirstConnectRef = useRef(false)
|
||||
|
||||
const runtime = useMemo<SyncRuntime>(
|
||||
() => ({ childStores, messageLoader, runtimeKey, sdk: props.sdk }),
|
||||
[childStores, messageLoader, props.sdk, runtimeKey],
|
||||
)
|
||||
const system = useMemo<SyncSystem>(
|
||||
() => ({
|
||||
childStores,
|
||||
messageLoader,
|
||||
runtimeKey,
|
||||
sdk: props.sdk,
|
||||
directory: props.directory,
|
||||
}),
|
||||
[childStores, messageLoader, props.sdk, props.directory, runtimeKey],
|
||||
() => ({ ...runtime, directory: props.directory }),
|
||||
[props.directory, runtime],
|
||||
)
|
||||
|
||||
const triggerDirectoryResync = useCallback((directory: string, reason: SessionMaterializationReason) => {
|
||||
@@ -1895,6 +2154,24 @@ export function SyncProvider(props: {
|
||||
})
|
||||
}, [childStores, routingIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const onSystemResume = () => {
|
||||
const directory = currentDirectoryRef.current
|
||||
if (!directory || !childStores.getChild(directory)) return
|
||||
|
||||
const resyncing = blockingRequestResyncingDirectoriesRef.current
|
||||
if (resyncing.has(directory)) return
|
||||
resyncing.add(directory)
|
||||
void resyncBlockingRequestsForActiveDirectory(directory, childStores)
|
||||
.finally(() => resyncing.delete(directory))
|
||||
}
|
||||
|
||||
window.addEventListener("openchamber:system-resume", onSystemResume)
|
||||
return () => window.removeEventListener("openchamber:system-resume", onSystemResume)
|
||||
}, [childStores])
|
||||
|
||||
// Configure child store manager
|
||||
useEffect(() => {
|
||||
void usePermissionStore.getState().hydrate().catch(() => undefined)
|
||||
@@ -2003,7 +2280,21 @@ export function SyncProvider(props: {
|
||||
}
|
||||
|
||||
const result = await runBootstrap(0)
|
||||
if (result === "failed") throw new Error(`Directory bootstrap failed for ${directory}`)
|
||||
if (result === "failed") {
|
||||
// OpenCode can mask the underlying errno while initializing an
|
||||
// inaccessible workspace. Probe the exact directory through the
|
||||
// owning runtime filesystem API so only an authoritative local
|
||||
// EPERM/EACCES becomes an actionable grant-access failure.
|
||||
const files = getRegisteredRuntimeAPIs()?.files
|
||||
if (files) {
|
||||
try {
|
||||
await files.listDirectory(directory)
|
||||
} catch (error) {
|
||||
if (isFilesystemError(error) && error.reason === "os-permission") throw error
|
||||
}
|
||||
}
|
||||
throw new Error(`Directory bootstrap failed for ${directory}`)
|
||||
}
|
||||
|
||||
// Selecting a session whose directory this client had not indexed yet
|
||||
// routes it through the active directory as a documented guess. This is
|
||||
@@ -2314,6 +2605,12 @@ export function SyncProvider(props: {
|
||||
props.sdk,
|
||||
childStores,
|
||||
() => opencodeClient.getDirectory() || props.directory,
|
||||
(directory, sessionID, messageID) => {
|
||||
enqueueSessionMaterialization(directory, sessionID, childStores, {
|
||||
reason: "settled-running-tool",
|
||||
messageID,
|
||||
})
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
if (getImperativeSessionMessageLoader() === messageLoader) {
|
||||
@@ -2352,7 +2649,14 @@ export function SyncProvider(props: {
|
||||
return unsubscribe
|
||||
}, [props.directory, childStores])
|
||||
|
||||
return <SyncContext.Provider value={system}>{props.children}</SyncContext.Provider>
|
||||
// Directory navigation must not republish stable runtime dependencies.
|
||||
return (
|
||||
<SyncContext.Provider value={system}>
|
||||
<SyncRuntimeContext.Provider value={runtime}>
|
||||
{props.children}
|
||||
</SyncRuntimeContext.Provider>
|
||||
</SyncContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2452,6 +2756,48 @@ export function useSessionParts(messageID: string, directory?: string) {
|
||||
)
|
||||
}
|
||||
|
||||
const EMPTY_PARTS_BY_MESSAGE: Record<string, Part[]> = {}
|
||||
|
||||
/**
|
||||
* Get parts for several messages at once, keyed by message id. The snapshot
|
||||
* keeps its identity until one of the requested part arrays changes, so a
|
||||
* streaming turn can overlay every one of its step messages — not only the
|
||||
* currently streaming one — without tearing between them when the stream
|
||||
* moves to the next message.
|
||||
*/
|
||||
export function useSessionPartsForMessages(messageIDs: readonly string[], directory?: string): Record<string, Part[]> {
|
||||
const store = useDirectoryStore(directory)
|
||||
const cacheRef = React.useRef<{ ids: readonly string[]; parts: Record<string, Part[]> } | null>(null)
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (messageIDs.length === 0) return EMPTY_PARTS_BY_MESSAGE
|
||||
const state = store.getState()
|
||||
const cached = cacheRef.current
|
||||
if (
|
||||
cached
|
||||
&& cached.ids === messageIDs
|
||||
&& messageIDs.every((id) => (state.part[id] ?? EMPTY_PARTS) === (cached.parts[id] ?? EMPTY_PARTS))
|
||||
) {
|
||||
return cached.parts
|
||||
}
|
||||
const parts: Record<string, Part[]> = {}
|
||||
for (const id of messageIDs) parts[id] = state.part[id] ?? EMPTY_PARTS
|
||||
cacheRef.current = { ids: messageIDs, parts }
|
||||
return parts
|
||||
}, [messageIDs, store])
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (messageIDs.length === 0) return () => undefined
|
||||
return store.subscribe((state, previous) => {
|
||||
for (const id of messageIDs) {
|
||||
if (state.part[id] !== previous.part[id]) {
|
||||
notify()
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [messageIDs, store])
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
/** Get status for a specific session */
|
||||
export function useSessionStatus(sessionID: string, directory?: string) {
|
||||
const store = useDirectoryStore(directory)
|
||||
@@ -2490,6 +2836,46 @@ export function useSessionQuestions(sessionID: string, directory?: string) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Total number of pending questions across the given session scopes. Each
|
||||
* scope names a directory store plus the session IDs to count inside it, so
|
||||
* collapsed subtree rows can roll up pending questions of hidden descendants
|
||||
* from their owning directory stores without bootstrapping them.
|
||||
*
|
||||
* Subscribes through the per-session question sidecar channel, so unrelated
|
||||
* streaming or session activity does not re-render rows.
|
||||
*/
|
||||
export function useSessionQuestionCount(scopes: readonly { directory: string; sessionIDs: readonly string[] }[]) {
|
||||
const { childStores } = useSyncSystem()
|
||||
const scopedStores = React.useMemo(() => scopes.map((scope) => ({
|
||||
sessionIDs: scope.sessionIDs,
|
||||
store: childStores.ensureChild(scope.directory, { bootstrap: false }),
|
||||
})), [childStores, scopes])
|
||||
React.useEffect(() => {
|
||||
for (const scope of scopes) childStores.pin(scope.directory)
|
||||
return () => {
|
||||
for (const scope of scopes) childStores.unpin(scope.directory)
|
||||
}
|
||||
}, [childStores, scopes])
|
||||
const getSnapshot = React.useCallback(() => {
|
||||
let count = 0
|
||||
for (const { sessionIDs, store } of scopedStores) {
|
||||
const questions = store.getState().question
|
||||
for (const sessionID of sessionIDs) count += questions[sessionID]?.length ?? 0
|
||||
}
|
||||
return count
|
||||
}, [scopedStores])
|
||||
const subscribe = React.useCallback((notify: () => void) => {
|
||||
const unsubscribers = scopedStores.map(({ sessionIDs, store }) => (
|
||||
subscribeDirectoryQuestions(store, sessionIDs, notify)
|
||||
))
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribers) unsubscribe()
|
||||
}
|
||||
}, [scopedStores])
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
/** Get sessions list for a directory */
|
||||
export function useSessions(directory?: string) {
|
||||
return useDirectorySync(
|
||||
@@ -2555,13 +2941,25 @@ export function useScopedBlockingQuestions(sessionID: string | null, directory?:
|
||||
return useScopedBlockingRequests(sessionID, directory, selectQuestionRequestsBySession, EMPTY_QUESTION_REQUESTS)
|
||||
}
|
||||
|
||||
const sessionsByIdCache = new WeakMap<State["session"], Map<string, Session>>()
|
||||
|
||||
const getSessionById = (sessions: State["session"], sessionID?: string | null): Session | undefined => {
|
||||
if (!sessionID) return undefined
|
||||
let sessionsById = sessionsByIdCache.get(sessions)
|
||||
if (!sessionsById) {
|
||||
sessionsById = new Map(sessions.map((session) => [session.id, session]))
|
||||
sessionsByIdCache.set(sessions, sessionsById)
|
||||
}
|
||||
return sessionsById.get(sessionID)
|
||||
}
|
||||
|
||||
export function useParentSession(sessionID: string | null, directory?: string): Session | null {
|
||||
return useDirectorySync(
|
||||
useCallback((state: State) => {
|
||||
if (!sessionID) return null
|
||||
const current = state.session.find((s) => s.id === sessionID)
|
||||
const current = getSessionById(state.session, sessionID)
|
||||
if (!current?.parentID) return null
|
||||
return state.session.find((s) => s.id === current.parentID)
|
||||
return getSessionById(state.session, current.parentID)
|
||||
?? getAllSyncSessions().find((s) => s.id === current.parentID)
|
||||
?? null
|
||||
}, [sessionID]),
|
||||
@@ -2574,7 +2972,8 @@ export function useSession(sessionID?: string | null, directory?: string) {
|
||||
const { childStores } = useSyncSystem()
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (directory) {
|
||||
return childStores.getChild(directory)?.getState().session.find((session) => session.id === sessionID)
|
||||
const sessions = childStores.getChild(directory)?.getState().session
|
||||
return sessions ? getSessionById(sessions, sessionID) : undefined
|
||||
}
|
||||
return findLiveSession(getLiveStates(childStores), sessionID)
|
||||
}, [childStores, directory, sessionID])
|
||||
@@ -2612,26 +3011,6 @@ export function useChildStoreManager() {
|
||||
return useSyncSystem().childStores
|
||||
}
|
||||
|
||||
export type SessionTextMessage = {
|
||||
id: string
|
||||
role: string | null
|
||||
text: string
|
||||
}
|
||||
|
||||
const getPartText = (part: Part): string => {
|
||||
if (part?.type !== "text") return ""
|
||||
const text = (part as { text?: unknown }).text
|
||||
return typeof text === "string" ? text : ""
|
||||
}
|
||||
|
||||
const getConcatenatedTextFromParts = (parts: Part[]): string => {
|
||||
let text = ""
|
||||
for (const part of parts) {
|
||||
text += getPartText(part)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
type SessionMessageRecord = { info: Message; parts: Part[] }
|
||||
const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = []
|
||||
|
||||
@@ -2858,7 +3237,7 @@ function getVisibleMessagesForSession(state: State, sessionID: string, previous?
|
||||
|
||||
return {
|
||||
sourceMessages,
|
||||
visibleMessages: revertMessageID ? sourceMessages.filter((message) => message.id < revertMessageID) : sourceMessages,
|
||||
visibleMessages: messagesBefore(sourceMessages, revertMessageID),
|
||||
revertMessageID,
|
||||
}
|
||||
}
|
||||
@@ -2957,19 +3336,6 @@ export function useSessionRenderable(sessionID: string, directory?: string): boo
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
export function useSessionTextMessages(sessionID: string, directory?: string): SessionTextMessage[] {
|
||||
const records = useSessionMessageRecords(sessionID, directory)
|
||||
|
||||
return useMemo(
|
||||
() => records.map((record) => ({
|
||||
id: record.info.id,
|
||||
role: typeof record.info.role === "string" ? record.info.role : null,
|
||||
text: getConcatenatedTextFromParts(record.parts),
|
||||
})),
|
||||
[records],
|
||||
)
|
||||
}
|
||||
|
||||
export function useUserMessageHistory(sessionID: string, directory?: string): string[] {
|
||||
const store = useDirectoryStore(directory)
|
||||
const snapshotRef = useRef<UserMessageHistorySnapshot>(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT)
|
||||
@@ -3129,14 +3495,20 @@ export function useSessionMessageRecords(
|
||||
// (e.g. multiple ToolParts) request the same session's messages.
|
||||
const _ensureMessagesLoading = new Set<string>()
|
||||
|
||||
export function useEnsureSessionMessages(sessionID: string, directory?: string) {
|
||||
/**
|
||||
* @param enabled Gate for callers that only need a session materialised under
|
||||
* a specific condition — a panel resolving pinned message text, say. Loading a
|
||||
* whole session is not free, so "something is missing" is not on its own a
|
||||
* reason to fetch it.
|
||||
*/
|
||||
export function useEnsureSessionMessages(sessionID: string, directory?: string, enabled = true) {
|
||||
const syncDirectory = useSyncDirectory()
|
||||
const resolvedDirectory = directory ?? syncDirectory
|
||||
const store = useDirectoryStore(resolvedDirectory)
|
||||
const requestGenerationRef = React.useRef(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionID) return
|
||||
if (!sessionID || !enabled) return
|
||||
|
||||
const state = store.getState()
|
||||
// Already loaded into a renderable message/part snapshot — nothing to do.
|
||||
@@ -3162,7 +3534,7 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
|
||||
_ensureMessagesLoading.delete(loadingKey)
|
||||
}
|
||||
})()
|
||||
}, [sessionID, store, resolvedDirectory])
|
||||
}, [enabled, sessionID, store, resolvedDirectory])
|
||||
}
|
||||
const EMPTY_MESSAGES: Message[] = []
|
||||
const EMPTY_PARTS: Part[] = []
|
||||
|
||||
@@ -40,11 +40,11 @@ describe('shouldFetchSessionForRenderableSync', () => {
|
||||
// already-committed messages (no re-render churn, no reference breaks).
|
||||
// 3. mergeOptimisticPage + clearOptimistic is idempotent across commits.
|
||||
|
||||
function assistantMessage(id: string): Message {
|
||||
return { id, sessionID: 'ses_1', role: 'assistant', time: { created: 1 } } as Message
|
||||
function assistantMessage(id: string, created = 1): Message {
|
||||
return { id, sessionID: 'ses_1', role: 'assistant', time: { created } } as Message
|
||||
}
|
||||
function userMessage(id: string): Message {
|
||||
return { id, sessionID: 'ses_1', role: 'user', time: { created: 1 } } as Message
|
||||
function userMessage(id: string, created = 1): Message {
|
||||
return { id, sessionID: 'ses_1', role: 'user', time: { created } } as Message
|
||||
}
|
||||
function assistantMessageWithClientRole(id: string): Message {
|
||||
// OpenCode sets clientRole on the wire; role may be absent.
|
||||
@@ -79,11 +79,26 @@ describe('hasUserMessage', () => {
|
||||
describe('incremental materialization of superset pages (#2084)', () => {
|
||||
const SKIP_PARTS = new Set(['patch', 'step-start', 'step-finish'])
|
||||
|
||||
test('preserves authoritative part order across the part ID rollover', () => {
|
||||
const msg = assistantMessage('msg_1')
|
||||
const legacy = textPart('prt_ffffffffffffLegacy', msg.id)
|
||||
const current = textPart('prt_000000000000Current', msg.id)
|
||||
|
||||
const result = materializeSessionSnapshots(
|
||||
{ message: {}, part: {} },
|
||||
'ses_1',
|
||||
[{ info: msg, parts: [legacy, current] }],
|
||||
{ skipPartTypes: SKIP_PARTS },
|
||||
)
|
||||
|
||||
expect(result.part[msg.id]).toEqual([legacy, current])
|
||||
})
|
||||
|
||||
test('preserves message references for already-committed messages', () => {
|
||||
// Simulate expansion: commit 50 (assistant-only), then 100 (with user), then 150.
|
||||
// Pages are supersets: the 100-page includes all 50 from the first page,
|
||||
// the 150-page includes all 100 from the second.
|
||||
// Messages are sorted by id in the store (mergeMessages uses cmp by id),
|
||||
// Messages are chronological in the store,
|
||||
// so look them up by id rather than positional index.
|
||||
const a1 = assistantMessage('a_1')
|
||||
const a2 = assistantMessage('a_2')
|
||||
@@ -168,6 +183,16 @@ describe('incremental materialization of superset pages (#2084)', () => {
|
||||
})
|
||||
|
||||
describe('mergeOptimisticPage idempotency across commits (#2084)', () => {
|
||||
test('places a post-rollover optimistic message at the chronological tail', () => {
|
||||
const legacy = userMessage('msg_ffffffffffffLegacy', 100)
|
||||
const current = userMessage('msg_000000000000Current', 200)
|
||||
const page = { session: [legacy], part: [], cursor: undefined, complete: true }
|
||||
|
||||
const merged = mergeOptimisticPage(page, [{ message: current, parts: [] }])
|
||||
|
||||
expect(merged.session).toEqual([legacy, current])
|
||||
})
|
||||
|
||||
test('second call after clearOptimistic returns the page unchanged', () => {
|
||||
// Simulate: first commit confirmed an optimistic item, clearOptimistic
|
||||
// removed it; second commit (expansion) finds no optimistic items.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useMemo } from "react"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { Binary } from "./binary"
|
||||
import { upsertSessionRecord } from "./session-records"
|
||||
import { retry } from "./retry"
|
||||
import { SESSION_CACHE_LIMIT, type State } from "./types"
|
||||
import { pickSessionCacheEvictions } from "./session-cache"
|
||||
import { dropSessionCaches, getProtectedSessionCacheIds, pickSessionCacheEvictions } from "./session-cache"
|
||||
import {
|
||||
dropCachedSessionMessageRecordsSnapshots,
|
||||
useChildStoreManager,
|
||||
@@ -11,8 +12,10 @@ import {
|
||||
useSessionMessageLoader,
|
||||
useSyncDirectory,
|
||||
useSyncSDK,
|
||||
useSyncRuntime,
|
||||
resyncBlockingRequestsForDirectory,
|
||||
buildSessionMessageRecordsSnapshot,
|
||||
} from "./sync-context"
|
||||
import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
|
||||
import { stripSessionDiffSnapshots } from "./sanitize"
|
||||
import { isVSCodeRuntime } from "@/lib/desktop"
|
||||
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
|
||||
@@ -45,7 +48,6 @@ const syncSessionInflightByKey = new Map<string, Promise<void>>()
|
||||
// to the store. This prevents rapid session switches (e.g. 1→2→3 in the
|
||||
// sidebar) from having each completed fetch fight for focus.
|
||||
const syncSessionGenerationByKey = new Map<string, number>()
|
||||
|
||||
type SdkResult<T> = {
|
||||
data?: T
|
||||
error?: unknown
|
||||
@@ -110,35 +112,16 @@ export function shouldFetchSessionForRenderableSync(input: {
|
||||
return Boolean(input.force) || !input.hasSession || input.shouldLoadMessages
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useSync — message loading, pagination, optimistic updates
|
||||
// Message loading, pagination, optimistic updates
|
||||
// ---------------------------------------------------------------------------
|
||||
function useSessionCacheTouch() {
|
||||
const { childStores, messageLoader, runtimeKey } = useSyncRuntime()
|
||||
|
||||
export function useSync() {
|
||||
const sdk = useSyncSDK()
|
||||
const directory = useSyncDirectory()
|
||||
const store = useDirectoryStore()
|
||||
const childStores = useChildStoreManager()
|
||||
const messageLoader = useSessionMessageLoader()
|
||||
const runtimeKey = getRuntimeKey()
|
||||
|
||||
const keyFor = useCallback(
|
||||
(sessionID: string, directoryOverride = directory) => `${runtimeKey}\n${directoryOverride}\n${sessionID}`,
|
||||
[directory, runtimeKey],
|
||||
)
|
||||
|
||||
// Session cache eviction — two levels of LRU:
|
||||
// (1) across directories (max 30), (2) within a directory (SESSION_CACHE_LIMIT).
|
||||
|
||||
// Evict all cached session data for given IDs from a directory's store
|
||||
const evict = useCallback(
|
||||
(dir: string, sessionIDs: string[]) => {
|
||||
(directory: string, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0 || getRuntimeKey() !== runtimeKey) return
|
||||
const dirStore = childStores.getChild(dir)
|
||||
if (!dirStore) return
|
||||
const store = childStores.getChild(directory)
|
||||
if (!store) return
|
||||
|
||||
const current = dirStore.getState()
|
||||
const current = store.getState()
|
||||
const draft = {
|
||||
message: { ...current.message },
|
||||
part: { ...current.part },
|
||||
@@ -149,84 +132,90 @@ export function useSync() {
|
||||
question: { ...current.question },
|
||||
}
|
||||
dropSessionCaches(draft, sessionIDs)
|
||||
dropCachedSessionMessageRecordsSnapshots(dirStore, sessionIDs)
|
||||
dirStore.setState(draft)
|
||||
|
||||
// Clear meta + optimistic + prefetch cache for evicted sessions
|
||||
for (const id of sessionIDs) {
|
||||
messageLoader.invalidateSession({ directory: dir, sessionID: id })
|
||||
}
|
||||
clearSessionPrefetch(dir, sessionIDs)
|
||||
dropCachedSessionMessageRecordsSnapshots(store, sessionIDs)
|
||||
store.setState(draft)
|
||||
for (const sessionID of sessionIDs) messageLoader.invalidateSession({ directory, sessionID })
|
||||
clearSessionPrefetch(directory, sessionIDs)
|
||||
},
|
||||
[childStores, messageLoader, runtimeKey],
|
||||
)
|
||||
|
||||
// Get or create the seen-set for a directory. LRU reorder on access.
|
||||
// When seen directories exceed MAX_SEEN_DIRS, evict the oldest directory's caches.
|
||||
// LRU reorder on access. Evicts oldest directory when exceeding MAX_SEEN_DIRS.
|
||||
const seenFor = useCallback((targetDirectory: string) => {
|
||||
const cacheKey = `${runtimeKey}\n${targetDirectory}`
|
||||
const seenFor = useCallback((directory: string) => {
|
||||
const cacheKey = `${runtimeKey}\n${directory}`
|
||||
const existing = seenByDirectory.get(cacheKey)
|
||||
if (existing) {
|
||||
// LRU reorder: delete + re-insert moves to end (most recent)
|
||||
seenByDirectory.delete(cacheKey)
|
||||
seenByDirectory.set(cacheKey, existing)
|
||||
return existing.sessions
|
||||
}
|
||||
const created: SeenDirectoryEntry = { runtimeKey, directory: targetDirectory, sessions: new Set() }
|
||||
const created: SeenDirectoryEntry = { runtimeKey, directory, sessions: new Set() }
|
||||
seenByDirectory.set(cacheKey, created)
|
||||
|
||||
// Evict oldest directories if over limit
|
||||
while (seenByDirectory.size > MAX_SEEN_DIRS) {
|
||||
const first = seenByDirectory.keys().next().value
|
||||
if (!first) break
|
||||
const stale = seenByDirectory.get(first)
|
||||
seenByDirectory.delete(first)
|
||||
if (stale?.runtimeKey === runtimeKey) evict(stale.directory, [...stale.sessions])
|
||||
const oldestKey = seenByDirectory.keys().next().value
|
||||
if (!oldestKey) break
|
||||
const oldest = seenByDirectory.get(oldestKey)
|
||||
seenByDirectory.delete(oldestKey)
|
||||
if (oldest?.runtimeKey === runtimeKey) evict(oldest.directory, [...oldest.sessions])
|
||||
}
|
||||
|
||||
return created.sessions
|
||||
}, [evict, runtimeKey])
|
||||
|
||||
// Touch a session — triggers both directory-level and session-level eviction
|
||||
const touch = useCallback(
|
||||
(sessionID: string, targetDirectory = directory) => {
|
||||
if (getRuntimeKey() !== runtimeKey) return
|
||||
const s = seenFor(targetDirectory)
|
||||
const targetStore = targetDirectory === directory
|
||||
? store
|
||||
: childStores.ensureChild(targetDirectory, { bootstrap: false })
|
||||
const protectedIds = getProtectedSessionCacheIds(targetStore.getState())
|
||||
const cacheLimit = getEffectiveSessionCacheLimit()
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen: s,
|
||||
keep: sessionID,
|
||||
limit: cacheLimit,
|
||||
preserve: protectedIds,
|
||||
return useCallback((sessionID: string, directory: string) => {
|
||||
if (getRuntimeKey() !== runtimeKey) return
|
||||
const seen = seenFor(directory)
|
||||
const store = childStores.ensureChild(directory, { bootstrap: false })
|
||||
const protectedIds = getProtectedSessionCacheIds(store.getState())
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen,
|
||||
keep: sessionID,
|
||||
limit: getEffectiveSessionCacheLimit(),
|
||||
preserve: protectedIds,
|
||||
})
|
||||
evict(directory, stale)
|
||||
|
||||
if (!isConstrainedSessionRuntime()) return
|
||||
const state = store.getState()
|
||||
const keep = new Set([sessionID, ...seen, ...protectedIds])
|
||||
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
|
||||
evict(directory, prefetched)
|
||||
const afterPrefetchEviction = prefetched.length > 0 ? store.getState() : state
|
||||
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => (
|
||||
id !== sessionID && !protectedIds.has(id) && isHeavyConstrainedSessionCache(afterPrefetchEviction, id)
|
||||
))
|
||||
for (const id of heavyInactive) seen.delete(id)
|
||||
evict(directory, heavyInactive)
|
||||
}, [childStores, evict, runtimeKey, seenFor])
|
||||
}
|
||||
|
||||
export function useSync() {
|
||||
const sdk = useSyncSDK()
|
||||
const directory = useSyncDirectory()
|
||||
const store = useDirectoryStore()
|
||||
const childStores = useChildStoreManager()
|
||||
const messageLoader = useSessionMessageLoader()
|
||||
const runtimeKey = getRuntimeKey()
|
||||
const touch = useSessionCacheTouch()
|
||||
|
||||
const recoverPendingQuestions = useCallback(
|
||||
async (sessionID: string, directoryOverride?: string): Promise<boolean> => {
|
||||
const targetDirectory = directoryOverride || directory
|
||||
if (!sessionID || !targetDirectory || getRuntimeKey() !== runtimeKey) return false
|
||||
const targetStore = childStores.ensureChild(targetDirectory, {
|
||||
priority: "selected",
|
||||
reason: "selected-session",
|
||||
})
|
||||
evict(targetDirectory, stale)
|
||||
|
||||
if (isConstrainedSessionRuntime()) {
|
||||
const state = targetStore.getState()
|
||||
const keep = new Set([sessionID, ...s, ...protectedIds])
|
||||
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
|
||||
evict(targetDirectory, prefetched)
|
||||
|
||||
// One very large inactive session can create memory/GC pressure that
|
||||
// makes later small-session switches feel slow. Keep it while active,
|
||||
// but do not retain it as a warm cache in constrained shells.
|
||||
const afterPrefetchEviction = prefetched.length > 0 ? targetStore.getState() : state
|
||||
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => {
|
||||
if (id === sessionID || protectedIds.has(id)) return false
|
||||
return isHeavyConstrainedSessionCache(afterPrefetchEviction, id)
|
||||
})
|
||||
if (heavyInactive.length > 0) {
|
||||
for (const id of heavyInactive) s.delete(id)
|
||||
evict(targetDirectory, heavyInactive)
|
||||
}
|
||||
}
|
||||
await resyncBlockingRequestsForDirectory(targetDirectory, targetStore, [sessionID], {
|
||||
includePermissions: false,
|
||||
})
|
||||
if (getRuntimeKey() !== runtimeKey) return false
|
||||
return (targetStore.getState().question[sessionID]?.length ?? 0) > 0
|
||||
},
|
||||
[childStores, directory, seenFor, evict, runtimeKey, store],
|
||||
[childStores, directory, runtimeKey],
|
||||
)
|
||||
|
||||
const keyFor = useCallback(
|
||||
(sessionID: string, directoryOverride = directory) => `${runtimeKey}\n${directoryOverride}\n${sessionID}`,
|
||||
[directory, runtimeKey],
|
||||
)
|
||||
|
||||
// Sync a session (load if not cached)
|
||||
@@ -271,14 +260,8 @@ export function useSync() {
|
||||
if (result.data && !isStale()) {
|
||||
const nextSession = stripSessionDiffSnapshots(result.data)
|
||||
const s = targetStore.getState()
|
||||
const sessions = [...s.session]
|
||||
const idx = Binary.search(sessions, sessionID, (s) => s.id)
|
||||
if (idx.found) {
|
||||
sessions[idx.index] = nextSession
|
||||
} else {
|
||||
sessions.splice(idx.index, 0, nextSession)
|
||||
}
|
||||
if (!isStale()) {
|
||||
const sessions = upsertSessionRecord(s.session, nextSession)
|
||||
if (sessions !== s.session && !isStale()) {
|
||||
targetStore.setState({ session: sessions })
|
||||
}
|
||||
}
|
||||
@@ -408,12 +391,39 @@ export function useSync() {
|
||||
hasMore,
|
||||
isLoading,
|
||||
isComplete,
|
||||
recoverPendingQuestions,
|
||||
optimistic: {
|
||||
add: optimisticAdd,
|
||||
remove: optimisticRemove,
|
||||
confirm: optimisticConfirm,
|
||||
},
|
||||
}),
|
||||
[syncSession, prefetchSession, loadMore, loadCompleteHistory, hasMore, isLoading, isComplete, optimisticAdd, optimisticRemove, optimisticConfirm],
|
||||
[syncSession, prefetchSession, loadMore, loadCompleteHistory, hasMore, isLoading, isComplete, recoverPendingQuestions, optimisticAdd, optimisticRemove, optimisticConfirm],
|
||||
)
|
||||
}
|
||||
|
||||
export function usePrefetchSessionMessages() {
|
||||
const { messageLoader, runtimeKey } = useSyncRuntime()
|
||||
const touch = useSessionCacheTouch()
|
||||
|
||||
return useCallback(async ({ directory, sessionID }: { directory: string; sessionID: string }) => {
|
||||
if (getRuntimeKey() !== runtimeKey) return
|
||||
await messageLoader.prefetch({ directory, sessionID })
|
||||
if (messageLoader.getSnapshot({ directory, sessionID }).status !== "ready") return
|
||||
touch(sessionID, directory)
|
||||
}, [messageLoader, runtimeKey, touch])
|
||||
}
|
||||
|
||||
export function useSessionMessageRecordsForExport() {
|
||||
const { childStores, messageLoader, runtimeKey } = useSyncRuntime()
|
||||
const touch = useSessionCacheTouch()
|
||||
|
||||
return useCallback(async ({ directory, sessionID }: { directory: string; sessionID: string }) => {
|
||||
if (getRuntimeKey() !== runtimeKey) return null
|
||||
const store = childStores.ensureChild(directory, { bootstrap: false })
|
||||
touch(sessionID, directory)
|
||||
await messageLoader.loadComplete({ directory, sessionID })
|
||||
if (getRuntimeKey() !== runtimeKey) return null
|
||||
return buildSessionMessageRecordsSnapshot(store.getState(), sessionID).list
|
||||
}, [childStores, messageLoader, runtimeKey, touch])
|
||||
}
|
||||
|
||||
@@ -78,16 +78,16 @@ describe('buildUserMessageHistorySnapshot', () => {
|
||||
});
|
||||
|
||||
test('excludes user messages hidden by session revert state', () => {
|
||||
const beforeRevert = message('user_1', 'user');
|
||||
const reverted = message('user_2', 'user');
|
||||
const beforeRevert = message('msg_ffffffffffffBefore', 'user');
|
||||
const reverted = message('msg_000000000000Reverted', 'user');
|
||||
|
||||
const snapshot = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
session: [{ id: 'ses_1', revert: { messageID: 'user_2' } } as State['session'][number]],
|
||||
session: [{ id: 'ses_1', revert: { messageID: reverted.id } } as State['session'][number]],
|
||||
message: { ses_1: [beforeRevert, reverted] },
|
||||
part: {
|
||||
user_1: [textPart('part_user_1', 'kept')],
|
||||
user_2: [textPart('part_user_2', 'reverted')],
|
||||
[beforeRevert.id]: [textPart('part_user_1', 'kept')],
|
||||
[reverted.id]: [textPart('part_user_2', 'reverted')],
|
||||
},
|
||||
}),
|
||||
'ses_1',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
import type { State } from './types';
|
||||
import { messagesBefore } from './message-ordering';
|
||||
|
||||
type UserMessageHistoryRecord = {
|
||||
message: Message;
|
||||
@@ -62,14 +63,12 @@ export const buildUserMessageHistorySnapshot = (
|
||||
const session = state.session.find((candidate) => candidate.id === sessionID);
|
||||
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
|
||||
const records: UserMessageHistoryRecord[] = [];
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const visibleMessages = messagesBefore(messages, revertMessageID);
|
||||
for (let index = visibleMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = visibleMessages[index];
|
||||
if (message.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
if (revertMessageID && message.id >= revertMessageID) {
|
||||
continue;
|
||||
}
|
||||
records.push({
|
||||
message,
|
||||
parts: state.part[message.id] ?? EMPTY_PARTS,
|
||||
|
||||
Reference in New Issue
Block a user