mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Compare commits
92 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc4d82c26 | ||
|
|
811a4b7158 | ||
|
|
05ea87643a | ||
|
|
bb673b2a75 | ||
|
|
01a20dcf70 | ||
|
|
73be2beea7 | ||
|
|
14adac592d | ||
|
|
f4616cf945 | ||
|
|
67289765c6 | ||
|
|
ab0a9047f4 | ||
|
|
488344a74d | ||
|
|
6bdd9e5972 | ||
|
|
5b6fce1a4d | ||
|
|
05f9740e8e | ||
|
|
6b2a20cf0d | ||
|
|
d0965d19b1 | ||
|
|
381e2a5467 | ||
|
|
03305a47ef | ||
|
|
d74747fbf0 | ||
|
|
f707da0274 | ||
|
|
2fb7d29841 | ||
|
|
8a798a5bca | ||
|
|
689368fe8d | ||
|
|
5cd106973c | ||
|
|
f3d987ee03 | ||
|
|
bbe311c4de | ||
|
|
4f3ebcaa31 | ||
|
|
96b88c1715 | ||
|
|
193b680f06 | ||
|
|
2d9f98f434 | ||
|
|
ffcc814ba5 | ||
|
|
1f5aa50862 | ||
|
|
a2c896d0ae | ||
|
|
205893cc1f | ||
|
|
b00f6292c4 | ||
|
|
8d952fcfbf | ||
|
|
54dcbd8759 | ||
|
|
381646ebfb | ||
|
|
aaa04507e0 | ||
|
|
a92f3b031d | ||
|
|
567c744624 | ||
|
|
11c233b8fe | ||
|
|
aadcf179bc | ||
|
|
52c350093a | ||
|
|
a35c9f4263 | ||
|
|
dc467695f4 | ||
|
|
1ef344b02a | ||
|
|
f93570848f | ||
|
|
cf0548a0d0 | ||
|
|
ea665af455 | ||
|
|
7ec4acf2b1 | ||
|
|
44128caf2c | ||
|
|
e7efd773b3 | ||
|
|
9c71b0eb88 | ||
|
|
d3b426d500 | ||
|
|
d114c3e473 | ||
|
|
eecf73dc77 | ||
|
|
358cdd7b7c | ||
|
|
5cd898fabe | ||
|
|
8351156589 | ||
|
|
ef5d1d803d | ||
|
|
760421613d | ||
|
|
46ecf3addd | ||
|
|
93c4ba50a1 | ||
|
|
d568f9abb1 | ||
|
|
d1337a6c85 | ||
|
|
eb6d4a3c2d | ||
|
|
4820fd5821 | ||
|
|
f588719440 | ||
|
|
6524409313 | ||
|
|
d8f4f8a899 | ||
|
|
9b2585b2bd | ||
|
|
4581ee4c5f | ||
|
|
56a7662b2e | ||
|
|
93d0021391 | ||
|
|
dfeeb0e4b2 | ||
|
|
ffb11490ce | ||
|
|
51994c6777 | ||
|
|
5e2007c899 | ||
|
|
4ddb17a27b | ||
|
|
48bd261f64 | ||
|
|
58f8805328 | ||
|
|
6800bcc339 | ||
|
|
562513099f | ||
|
|
e056fee378 | ||
|
|
2de242819e | ||
|
|
9ad8ade044 | ||
|
|
1eb074b361 | ||
|
|
716d6a2db5 | ||
|
|
4b0218169c | ||
|
|
ec3bcd0312 | ||
|
|
7dfc023360 |
414 changed files with 24315 additions and 9361 deletions
3
.github/release-notes/5.0.4.md
vendored
Normal file
3
.github/release-notes/5.0.4.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Restored availability of downloadable install assets while upstream attestation verification catches up with GitHub's API changes.
|
||||||
6
.github/release-notes/5.1.0.md
vendored
Normal file
6
.github/release-notes/5.1.0.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added search to Codex Panel settings.
|
||||||
|
- Large web, thread, selection, and vault references are now bounded predictably, report truncation when needed, and keep their context details after reopening a thread.
|
||||||
|
- Paste and drop attachments now preserve their intended insertion point while files are being saved, with clearer composer editing state.
|
||||||
|
- Improved reliability when switching threads, leaving running subagents, or changing runtime and app-server settings, preventing stale results and lost updates.
|
||||||
6
.github/release-notes/5.1.1.md
vendored
Normal file
6
.github/release-notes/5.1.1.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Thread history now stays in sync across the chat toolbar, thread picker, and Threads view while loading older threads or refreshing.
|
||||||
|
- Thread search now uses fuzzy title matching across the complete active history instead of matching thread IDs.
|
||||||
|
- Running subagent summaries now show each agent's latest activity instead of only its initial status.
|
||||||
|
- Opening the thread picker again now closes the previous picker, and accepted MCP links are preserved in conversation history.
|
||||||
6
.github/release-notes/5.1.2.md
vendored
Normal file
6
.github/release-notes/5.1.2.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Thread rename and auto-name controls now prevent conflicting edits while work is in progress, and cancelling auto-name returns to the existing draft.
|
||||||
|
- Actions with known unmet prerequisites are now disabled before execution, including unavailable auto-name and selection rewrite actions.
|
||||||
|
- Thread operation errors no longer appear as entries in navigation lists.
|
||||||
|
- Resumed conversations now hide Codex Panel's internal context metadata while preserving their context attachments.
|
||||||
4
.github/release-notes/5.2.0.md
vendored
Normal file
4
.github/release-notes/5.2.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Thread references created with `/refer` now remain as readable, clickable links in conversation history and open the referenced thread in an available Codex Panel.
|
||||||
|
- Opening an existing thread in a new panel is now faster because resume no longer waits for background metadata and thread-list refreshes.
|
||||||
5
.github/release-notes/5.2.1.md
vendored
Normal file
5
.github/release-notes/5.2.1.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Updated compatibility for Codex CLI 0.145.0.
|
||||||
|
- Subagent conversations that support replies can now be continued directly from their panel.
|
||||||
|
- Fixed malformed links in exported thread archives.
|
||||||
17
.github/workflows/release.yml
vendored
17
.github/workflows/release.yml
vendored
|
|
@ -7,8 +7,6 @@ on:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
|
@ -56,21 +54,6 @@ jobs:
|
||||||
- name: Run checks and build
|
- name: Run checks and build
|
||||||
run: npm run check
|
run: npm run check
|
||||||
|
|
||||||
- name: Generate main.js artifact attestation
|
|
||||||
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4
|
|
||||||
with:
|
|
||||||
subject-path: main.js
|
|
||||||
|
|
||||||
- name: Generate manifest.json artifact attestation
|
|
||||||
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4
|
|
||||||
with:
|
|
||||||
subject-path: manifest.json
|
|
||||||
|
|
||||||
- name: Generate styles.css artifact attestation
|
|
||||||
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4
|
|
||||||
with:
|
|
||||||
subject-path: styles.css
|
|
||||||
|
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,4 +1,6 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
.stryker-tmp/
|
||||||
.jj/
|
.jj/
|
||||||
.codex/
|
.codex/
|
||||||
main.js
|
main.js
|
||||||
|
|
@ -7,4 +9,6 @@ data.json
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist/
|
dist/
|
||||||
|
coverage/
|
||||||
|
reports/
|
||||||
tmp/
|
tmp/
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ This repository contains the Codex Panel Obsidian plugin.
|
||||||
|
|
||||||
## Working Principles
|
## Working Principles
|
||||||
|
|
||||||
- Start from evidence. Reproduce defects before fixing them; for product changes, reconstruct the current workflow, user need, alternatives, and losses before deciding what should change.
|
- Start from evidence. Reproduce defects before fixing them; for product changes, reconstruct the current workflow, user need, alternatives, and losses before deciding what should change. Treat theoretical failure modes and reviewer agreement as hypotheses, not evidence of meaningful user or operational harm.
|
||||||
- Use design documents as context, not as a substitute for current behavior and user expectations. Prefer coherent user-facing behavior and clear ownership, and remove needless abstraction or obsolete compatibility instead of preserving it by default.
|
- Use design documents as context, not as a substitute for current behavior and user expectations. Prefer coherent user-facing behavior and clear ownership, and remove needless abstraction or obsolete compatibility instead of preserving it by default.
|
||||||
- Treat implementation, tests, documentation, lint policy, and final history as one deliverable: inspect each for drift, but update only the durable contracts that changed. Documentation should record user-facing behavior, long-term design, or reusable workflow; do not add it merely to narrate an implementation change or duplicate tests and tooling.
|
- Treat implementation, tests, documentation, lint policy, and final history as one deliverable: inspect each for drift, but update only the durable contracts that changed. Documentation should record user-facing behavior, long-term design, or reusable workflow; do not add it merely to narrate an implementation change or duplicate tests and tooling.
|
||||||
- Parallelize only substantial concerns that can be implemented and tested independently with little shared-file contention. Keep small, tightly coupled, sequential, or coordination-heavy work together.
|
- Parallelize only substantial concerns that can be implemented and tested independently with little shared-file contention. Keep small, tightly coupled, sequential, or coordination-heavy work together.
|
||||||
|
|
|
||||||
48
README.md
48
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Codex Panel brings Codex into an Obsidian sidebar. It keeps Codex threads beside your notes, helps you add vault context to prompts, and lets you handle approvals and file changes without switching windows.
|
Codex Panel brings Codex into an Obsidian sidebar. It keeps Codex threads beside your notes, helps you add vault context to prompts, and lets you handle approvals and file changes without switching windows.
|
||||||
|
|
||||||
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup. The plugin handles the Obsidian side of the workflow, while models, credentials, sandboxing, tools, hooks, and thread state stay with Codex.
|
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -35,36 +35,46 @@ You can also open the plugin page directly: <https://community.obsidian.md/plugi
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
1. Open the command palette and run **Codex Panel: Open panel**, or select the ribbon icon.
|
1. Open the note or project you want to work on.
|
||||||
2. If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executable** to an absolute path such as `/opt/homebrew/bin/codex`.
|
2. Run **Codex Panel: Open panel** from the command palette, or select the ribbon icon.
|
||||||
3. Ask Codex to work on the vault or project from the composer.
|
3. In the composer, reference what matters and describe the outcome you want. For example: `@active Summarize this note and suggest the next three actions.`
|
||||||
4. Return to earlier work from **Codex Panel: Open thread...** or **Codex Panel: Open threads view**.
|
4. Follow the turn in the panel, respond to requests when needed, and review any file changes before continuing.
|
||||||
|
|
||||||
## Using Codex Panel
|
If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executable** to an absolute path such as `/opt/homebrew/bin/codex`.
|
||||||
|
|
||||||
Each panel can keep a separate conversation, so related work can stay open side by side, including temporary read-only side chats. Start fresh from the panel, reopen recent threads from the picker or panel history, or use the Threads view to see active work across the vault.
|
## Working with Codex Panel
|
||||||
|
|
||||||
The composer treats Obsidian content as prompt context. It suggests vault files and recent notes for wikilinks, keeps links readable while attaching resolved files, and opens file links from Codex replies back in Obsidian. Use `@active` or `@selection` to include the active file or current Markdown selection without pasting it manually, or enable **Reference active file on send** to reference the current active file with each composer send. When Obsidian Daily Notes or the daily section of Periodic Notes is enabled, `@today`, `@tomorrow`, and `@yesterday` insert wikilinks using that daily-note folder and date format. Paste or drop files to save them in the attachment folder and reference them from the same prompt.
|
### Keep work separate or pick it up later
|
||||||
|
|
||||||
For context outside the current composer, use `/refer <thread> <message>` to send a message with recent turns from another Codex thread, or `/web <url> [message]` to fetch readable web content and attach it to the next turn without saving a note.
|
A panel is an independent working surface with its own active thread and draft. Open multiple panels when different tasks should stay visible and separate. Regular Codex threads remain available after a panel closes; return to them from **Codex Panel: Open thread...**, panel history, or the Threads view.
|
||||||
|
|
||||||
Completions cover slash commands, `$skill-name` skills, Obsidian tags, recent threads, models, reasoning levels, and permission profiles. Use `/help` for the current command list.
|
For a one-off question alongside the current thread, use `/btw` to open a temporary side chat. To carry context between regular threads without merging their histories, use `/refer <thread> <message>`. The submitted message keeps a normal link to the referenced thread; clicking it opens the thread in an available Codex Panel, creating one when needed.
|
||||||
|
|
||||||
While a turn is running, the panel streams the conversation, reasoning, tool activity, file changes, plans, and agent activity. You can answer Codex questions, approve commands, respond to requests, steer or interrupt the turn, and manage active goals above the conversation.
|
### Bring in the right context
|
||||||
|
|
||||||
When Codex changes files, open an Obsidian diff view to review the changes and copy the patch. For a focused note edit, select Markdown text and run **Codex Panel: Rewrite selection** to ask Codex for a replacement and review a selection-scoped diff before applying it.
|
The composer lets you point Codex to relevant material without pasting it into the prompt. Type wikilinks to reference vault files while keeping their names readable. Use `@active` for the active file or `@selection` for the current Markdown selection.
|
||||||
|
|
||||||
Use the toolbar, composer status row, or slash commands to inspect usage, adjust turn settings, and check Codex connection details.
|
When Obsidian Daily Notes or the daily section of Periodic Notes is enabled, `@today`, `@tomorrow`, and `@yesterday` resolve through its folder and date format. Paste or drop files to save them in the configured attachment folder and reference them from the same prompt. For material outside the vault, `/web <url> [message]` fetches readable page content and attaches it to the next turn without creating a note.
|
||||||
|
|
||||||
Threads can be archived as Markdown notes with a configurable folder, filename template, and tags. You can also archive without saving, then restore or permanently delete archived threads from settings.
|
### Guide a running turn
|
||||||
|
|
||||||
|
As a turn runs, the panel keeps the conversation together with requests that need your attention. You can answer questions, approve or reject actions, steer the turn with another message, or interrupt it. Plans, goals, tool activity, and agent work remain available when you need to inspect how the task is progressing.
|
||||||
|
|
||||||
|
### Review and keep the result
|
||||||
|
|
||||||
|
When Codex changes files, open the Obsidian diff view to review the changes and copy the patch. For a focused edit, select Markdown text and run **Codex Panel: Rewrite selection** to request a replacement and review a selection-scoped diff before applying it.
|
||||||
|
|
||||||
|
Archive a finished thread with or without saving it as a Markdown note. Saved notes use the configured folder, filename template, and tags; archived threads can later be restored or permanently deleted from settings.
|
||||||
|
|
||||||
|
Use `/help` for the current slash command list.
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
| Key | Version | Policy |
|
| Key | Version | Policy |
|
||||||
| ------------------------ | --------- | --------------------------------------------------------------------------------------------------- |
|
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------- |
|
||||||
| `manifest.minAppVersion` | `1.12.0` | Minimum Obsidian desktop version declared for plugin loading. |
|
| `manifest.minAppVersion` | `1.12.0` | Minimum Obsidian desktop version declared for plugin loading. |
|
||||||
| `obsidian` API types | `1.12.3` | TypeScript API package used for compile-time checks; kept in the same minor as `manifest` baseline. |
|
| `obsidian` API types | `1.12.3` | TypeScript API package used for compile-time checks; kept in the same minor as `manifest` baseline. |
|
||||||
| `codex.testedCliVersion` | `0.144.4` | Exact CLI patch used to generate and verify bindings; compatibility is tracked by minor version. |
|
| `codexAppServer.testedCliVersion` | `0.145.0` | Exact CLI patch used to generate and verify bindings; compatibility is tracked by minor version. |
|
||||||
|
|
||||||
Codex Panel depends on the experimental `codex app-server` API.
|
Codex Panel depends on the experimental `codex app-server` API.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,14 @@ Keep the panel thin. Codex Panel owns the Obsidian experience around Codex: pane
|
||||||
|
|
||||||
Codex owns runtime behavior and thread state: models, credentials, sandboxing, approval policy, MCP servers, hooks, providers, agent-initiated network access, thread history, archived state, goals, and runtime settings. The panel should read and update that state through `codex app-server`, not redefine it.
|
Codex owns runtime behavior and thread state: models, credentials, sandboxing, approval policy, MCP servers, hooks, providers, agent-initiated network access, thread history, archived state, goals, and runtime settings. The panel should read and update that state through `codex app-server`, not redefine it.
|
||||||
|
|
||||||
|
The panel may provide a narrow management surface when direct configuration of Codex-owned state is impractical and app-server still owns the semantics. Hook controls serve this role: help users recognize and manage hooks they added or changed without presenting the panel as a code-audit surface.
|
||||||
|
|
||||||
The panel may acquire prompt context through an explicit user action. `/web` fetches the requested page through Obsidian and attaches the extracted content as untrusted turn context; it is not a search surface or agent network policy.
|
The panel may acquire prompt context through an explicit user action. `/web` fetches the requested page through Obsidian and attaches the extracted content as untrusted turn context; it is not a search surface or agent network policy.
|
||||||
|
|
||||||
|
Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context without adding Panel-owned metadata envelopes to Codex history. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer. Keep read-only compatibility for envelopes written by older Panel versions isolated at history projection boundaries.
|
||||||
|
|
||||||
|
Vault file references are prompt handoff data, not durable thread metadata. Keep their display state local; ordinary wikilinks remain represented by the user-authored message text. Thread references persist as ordinary, human-readable `codex://threads/` Markdown links, while their bounded transcript payload remains ephemeral turn context.
|
||||||
|
|
||||||
Panel settings should store only panel-specific preferences. Do not mirror Codex configuration in Obsidian settings just to display or inspect it.
|
Panel settings should store only panel-specific preferences. Do not mirror Codex configuration in Obsidian settings just to display or inspect it.
|
||||||
|
|
||||||
## Sources of Truth
|
## Sources of Truth
|
||||||
|
|
@ -24,6 +30,8 @@ Runtime controls should express visible user intent for the active thread rather
|
||||||
|
|
||||||
An app-server context is the pair of Codex executable and Vault root. Replacing it must invalidate context-bound connections and work before publishing the new context, keep events attributed to their source context, and discard old runtime metadata. Preserve last-known-good state only across transient failures within the same context.
|
An app-server context is the pair of Codex executable and Vault root. Replacing it must invalidate context-bound connections and work before publishing the new context, keep events attributed to their source context, and discard old runtime metadata. Preserve last-known-good state only across transient failures within the same context.
|
||||||
|
|
||||||
|
Vault root is the Panel-owned workspace root for that context. Panel operations always use it as the thread working directory and do not project mutable protocol thread cwd values into panel state.
|
||||||
|
|
||||||
## Code Boundaries
|
## Code Boundaries
|
||||||
|
|
||||||
Raw app-server protocol belongs at the app-server boundary. Boundary code should adapt protocol payloads into panel-owned domain models or small projections before those values reach features, workspace coordination, settings, or UI.
|
Raw app-server protocol belongs at the app-server boundary. Boundary code should adapt protocol payloads into panel-owned domain models or small projections before those values reach features, workspace coordination, settings, or UI.
|
||||||
|
|
@ -44,19 +52,31 @@ Runtime UI composition is Preact-owned. Preact components should render the pane
|
||||||
|
|
||||||
Obsidian and app-server boundaries stay outside Preact components. External lifecycles, app-server connections, editor/workspace APIs, and rendering bridges belong in boundary modules.
|
Obsidian and app-server boundaries stay outside Preact components. External lifecycles, app-server connections, editor/workspace APIs, and rendering bridges belong in boundary modules.
|
||||||
|
|
||||||
Chat-visible state belongs in the chat state store and named reducer actions. Components should project that state through narrow selector-backed snapshots rather than mirror it into another reactive store.
|
Chat-visible state should have one authoritative owner. Components should consume narrow projections of that state rather than mirror it into another reactive store.
|
||||||
|
|
||||||
Each shared app-server resource should have one authoritative query record. Derived metadata may report status but must not duplicate its snapshot.
|
TanStack Query is the single panel-side owner of cached app-server resources. Features may project authoritative event results into that state, but should not introduce parallel cache or synchronization mechanisms. Partial read models are reconciled at explicit lifecycle boundaries rather than kept globally and continuously consistent.
|
||||||
|
|
||||||
Each top-level panel region should subscribe to a narrow selector-backed store projection. Region selectors should retain stable snapshots across unrelated state changes so streaming updates do not rerender the toolbar, goal, or composer unnecessarily.
|
Thread lifecycle changes should be projected from completed operation facts into the shared read model. When one user action changes multiple visible projections, publish them coherently without delaying live operational state or coupling independent panels.
|
||||||
|
|
||||||
Imperative DOM bridges are allowed when an external API, host lifecycle, hit-test, focus/selection operation, or measurement problem requires an `HTMLElement`. They should remain narrow boundary adapters, not a second UI composition system inside Preact-owned surfaces.
|
Reads with different completeness requirements have separate lifecycles. Complete operation-local reads must not replace bounded shared history, and transient activity should come from its owning live state rather than forcing history refreshes.
|
||||||
|
|
||||||
|
Imperative UI bridges are allowed only where the host platform requires them. They should remain narrow boundary adapters, not a second UI composition system inside Preact-owned surfaces.
|
||||||
|
|
||||||
## Interaction Principles
|
## Interaction Principles
|
||||||
|
|
||||||
Multiple panels are separate Obsidian leaves. Treat each panel as its own Codex working surface with independent connection, thread, turn state, composer, and pending requests.
|
Multiple panels are separate Obsidian leaves. Treat each panel as its own Codex working surface with independent connection, thread, turn state, composer, and pending requests.
|
||||||
|
|
||||||
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Treat their panels as read-only conversation surfaces while preserving their parent and agent provenance for future specialized behavior.
|
Long-running actions must preserve the user intent that started them. Panel-local results may affect a panel only while that intent still owns its target; Codex facts completed in the current app-server context remain shared truth even if the initiating panel has moved on.
|
||||||
|
|
||||||
|
Coordinate conflicting work at the narrowest shared semantic owner. Independent panels should not block one another, and stale reads or writes must not overwrite newer shared state. Coalesce work only when replacement is part of the operation's meaning.
|
||||||
|
|
||||||
|
Foreground reveal and focus are the narrow workspace-wide exception: the latest user intent wins without serializing unrelated panel or app-server work. Cleanup obligations created by a committed state transition must survive the UI action that initiated them.
|
||||||
|
|
||||||
|
Do not use explanatory copy as the default remedy for an unclear or incoherent interface. Before adding instructions, warnings, status text, validation messages, confirmations, or diagnostics, determine why the interface does not communicate the necessary behavior through its structure and state. Prefer improving information hierarchy, labels, defaults, available choices, affordances, validation timing, progress indication, and feedback. Add text only when irreducible information remains and it materially helps the user decide, act, or recover; do not expose implementation concepts merely because they explain the current behavior.
|
||||||
|
|
||||||
|
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Use the app-server direct-input capability when available, falling back to read-only subagent panels for older servers, while preserving parent and agent provenance for other specialized behavior.
|
||||||
|
|
||||||
|
Mode-derived restrictions for the active panel thread must remain consistent across actions and UI. Keep connection state, turn busy state, and operations targeting another listed thread in their owning workflows.
|
||||||
|
|
||||||
Thread history and other app-server resources should follow app-server semantics. Panel-side views are read models over app-server snapshots and lifecycle events; stale or partial refreshes must not overwrite newer state. Obsidian integrations such as archive note export are convenience views, not replacements for Codex history.
|
Thread history and other app-server resources should follow app-server semantics. Panel-side views are read models over app-server snapshots and lifecycle events; stale or partial refreshes must not overwrite newer state. Obsidian integrations such as archive note export are convenience views, not replacements for Codex history.
|
||||||
|
|
||||||
|
|
@ -76,6 +96,6 @@ Tests should protect user expectations, app-server/panel responsibility boundari
|
||||||
|
|
||||||
Prefer tests for visible behavior and state-transition invariants across panels, threads, requests, streams, and display fallbacks.
|
Prefer tests for visible behavior and state-transition invariants across panels, threads, requests, streams, and display fallbacks.
|
||||||
|
|
||||||
Avoid tests that freeze incidental implementation details such as exact DOM nesting, render counts, node reuse, helper decomposition, or no-op array updates unless those details directly protect a user-visible invariant.
|
Avoid tests that freeze incidental implementation details unless those details directly protect a user-visible invariant.
|
||||||
|
|
||||||
Panel tests may define how received structured values are displayed, retained, or normalized. They should not redefine Codex-owned runtime policy, model lists, sandbox behavior, approval policy, or thread history semantics.
|
Panel tests may define how received structured values are displayed, retained, or normalized. They should not redefine Codex-owned runtime policy, model lists, sandbox behavior, approval policy, or thread history semantics.
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,18 @@ Use this document for day-to-day implementation mechanics: commands, generated f
|
||||||
npm ci
|
npm ci
|
||||||
npm run fix
|
npm run fix
|
||||||
npm run check
|
npm run check
|
||||||
|
npm run test:coverage
|
||||||
|
npm run test:mutation
|
||||||
```
|
```
|
||||||
|
|
||||||
Use the Node.js version in `.node-version`.
|
Use the Node.js version in `.node-version`.
|
||||||
|
|
||||||
Use `npm run fix` for mechanical cleanup, review its diff, and run `npm run check` before handoff.
|
Use `npm run fix` for mechanical cleanup, review its diff, and run `npm run check` before handoff.
|
||||||
|
|
||||||
|
Use `npm run test:coverage` to identify source modules and branches that lack exercised behavior. It reports every authored TypeScript source file, including files not imported by tests, while excluding generated app-server bindings. Open `coverage/index.html` to inspect line-level gaps. Coverage is diagnostic and has no pass/fail threshold; prioritize user-visible behavior and state-transition invariants rather than raising the aggregate percentage.
|
||||||
|
|
||||||
|
Use `npm run test:mutation` for an exploratory mutation test of domain-owned logic, app-server protocol normalization, thread-stream event mapping, and the chat root reducer. Review surviving mutants individually instead of treating the aggregate score as a quality gate: add tests for meaningful behavior gaps, simplify equivalent or redundant code, and leave mutants alone when neither change improves the durable contract. The run ignores expensive module-initialization mutants, is intentionally manual, and writes its ignored HTML report to `reports/mutation/mutation.html`.
|
||||||
|
|
||||||
## Commit Messages
|
## Commit Messages
|
||||||
|
|
||||||
Use [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) for new commits:
|
Use [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) for new commits:
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ jj git push --remote origin --bookmark main
|
||||||
git push origin X.Y.Z
|
git push origin X.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
`release:prepare` updates version files and drafts `## Changes` from selected Conventional Commits since the previous version. Review the draft against the full diff for complete, user-facing notes; consolidate and reorder entries as needed, and replace an empty placeholder.
|
`release:prepare` updates version files and drafts `## Changes` from selected Conventional Commits since the previous version. Use those commits to locate candidate changes, then compare the resulting user-visible behavior with the previous release before choosing the version. Review the draft against the full diff for complete, user-facing notes; consolidate and reorder entries as needed, and replace an empty placeholder.
|
||||||
|
|
||||||
Run `release:preflight` after the release commit is on `main`; it validates the commit range and release readiness.
|
Run `release:preflight` after the release commit is on `main`; it validates the commit range and release readiness.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,6 @@ export default defineConfig([
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"@typescript-eslint/require-await": "off",
|
"@typescript-eslint/require-await": "off",
|
||||||
// Declarative setting definitions require Obsidian 1.13; keep display() while the supported API baseline is 1.12.
|
|
||||||
"obsidianmd/settings-tab/prefer-setting-definitions": "off",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "codex-panel",
|
"id": "codex-panel",
|
||||||
"name": "Codex Panel",
|
"name": "Codex Panel",
|
||||||
"version": "5.0.3",
|
"version": "5.2.1",
|
||||||
"minAppVersion": "1.12.0",
|
"minAppVersion": "1.12.0",
|
||||||
"description": "Codex in your sidebar.",
|
"description": "Codex in your sidebar.",
|
||||||
"author": "murashit",
|
"author": "murashit",
|
||||||
|
|
|
||||||
2317
package-lock.json
generated
2317
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "codex-panel",
|
"name": "codex-panel",
|
||||||
"version": "5.0.3",
|
"version": "5.2.1",
|
||||||
"description": "Codex in your Obsidian sidebar.",
|
"description": "Codex in your Obsidian sidebar.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"author": "murashit",
|
"author": "murashit",
|
||||||
|
|
@ -36,14 +36,20 @@
|
||||||
"release:preflight": "node scripts/release/preflight.mjs",
|
"release:preflight": "node scripts/release/preflight.mjs",
|
||||||
"release:prepare": "node scripts/release/prepare.mjs",
|
"release:prepare": "node scripts/release/prepare.mjs",
|
||||||
"test": "vitest run --pool threads --maxWorkers 6",
|
"test": "vitest run --pool threads --maxWorkers 6",
|
||||||
|
"test:coverage": "vitest run --pool threads --maxWorkers 6 --coverage",
|
||||||
|
"test:mutation": "stryker run",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit --incremental --tsBuildInfoFile node_modules/.cache/typescript/tsconfig.tsbuildinfo"
|
"typecheck": "tsc -p tsconfig.json --noEmit --incremental --tsBuildInfoFile node_modules/.cache/typescript/tsconfig.tsbuildinfo"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.3",
|
"@biomejs/biome": "^2.5.3",
|
||||||
"@commitlint/cli": "^21.2.1",
|
"@commitlint/cli": "^21.2.1",
|
||||||
"@commitlint/config-conventional": "^21.2.0",
|
"@commitlint/config-conventional": "^21.2.0",
|
||||||
|
"@stryker-mutator/core": "^9.6.1",
|
||||||
|
"@stryker-mutator/vitest-runner": "^9.6.1",
|
||||||
|
"@types/mdast": "^4.0.4",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"concurrently": "^10.0.3",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
|
"concurrently": "^9.2.4",
|
||||||
"conventional-changelog-conventionalcommits": "^10.2.1",
|
"conventional-changelog-conventionalcommits": "^10.2.1",
|
||||||
"conventional-commits-parser": "^7.1.0",
|
"conventional-commits-parser": "^7.1.0",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
|
|
@ -60,8 +66,12 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "^5.101.2",
|
"@tanstack/query-core": "^5.101.2",
|
||||||
"defuddle": "^0.19.1",
|
"defuddle": "^0.19.1",
|
||||||
|
"diff": "^9.0.0",
|
||||||
|
"mdast-util-from-markdown": "^2.0.3",
|
||||||
|
"mdast-util-to-markdown": "^2.1.2",
|
||||||
"micromark": "^4.0.2",
|
"micromark": "^4.0.2",
|
||||||
"obsidian-daily-notes-interface": "^0.9.5",
|
"obsidian-daily-notes-interface": "^0.9.5",
|
||||||
"preact": "^10.29.7"
|
"preact": "^10.29.7",
|
||||||
|
"unist-util-visit": "^5.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export async function createApiBaselineReport(options = {}) {
|
||||||
const initializeRequestAttestationDisabled = initializeCapabilities.requestAttestation === false;
|
const initializeRequestAttestationDisabled = initializeCapabilities.requestAttestation === false;
|
||||||
|
|
||||||
if (!codexReadmeSemver) {
|
if (!codexReadmeSemver) {
|
||||||
fail("README.md Compatibility table must define `codex.testedCliVersion` as X.Y.Z.");
|
fail("README.md Compatibility table must define `codexAppServer.testedCliVersion` as X.Y.Z.");
|
||||||
}
|
}
|
||||||
if (!codexRecordedSemver || codexRecordedSemver.version !== codexRecordedVersion) {
|
if (!codexRecordedSemver || codexRecordedSemver.version !== codexRecordedVersion) {
|
||||||
fail("src/app-server/connection/compatibility.json must declare codexAppServer.testedCliVersion as X.Y.Z.");
|
fail("src/app-server/connection/compatibility.json must declare codexAppServer.testedCliVersion as X.Y.Z.");
|
||||||
|
|
@ -180,7 +180,7 @@ function readCompatibilityBaselines(readme) {
|
||||||
const section = markdownSection(readme, "Compatibility");
|
const section = markdownSection(readme, "Compatibility");
|
||||||
const table = readMarkdownTableValues(section);
|
const table = readMarkdownTableValues(section);
|
||||||
return {
|
return {
|
||||||
codexTestedCliVersion: table.get("codex.testedCliVersion") ?? null,
|
codexTestedCliVersion: table.get("codexAppServer.testedCliVersion") ?? null,
|
||||||
obsidianApiTypesVersion: table.get("obsidian") ?? null,
|
obsidianApiTypesVersion: table.get("obsidian") ?? null,
|
||||||
obsidianMinAppVersion: table.get("manifest.minAppVersion") ?? null,
|
obsidianMinAppVersion: table.get("manifest.minAppVersion") ?? null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export async function buildStyles() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderStyles() {
|
export async function renderStyles() {
|
||||||
|
const { version } = JSON.parse(await readFile("package.json", "utf8"));
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
const sourceFiles = await readStyleOrder();
|
const sourceFiles = await readStyleOrder();
|
||||||
|
|
@ -29,7 +30,7 @@ export async function renderStyles() {
|
||||||
parts.push(content.trimEnd());
|
parts.push(content.trimEnd());
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${parts.join("\n\n")}\n`;
|
return `/* Codex Panel v${version} */\n\n${parts.join("\n\n")}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readStyleOrder() {
|
async function readStyleOrder() {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import type { ThreadItemsListResponse } from "../../generated/app-server/v2/Thre
|
||||||
import type { ThreadListResponse } from "../../generated/app-server/v2/ThreadListResponse";
|
import type { ThreadListResponse } from "../../generated/app-server/v2/ThreadListResponse";
|
||||||
import type { ThreadReadResponse } from "../../generated/app-server/v2/ThreadReadResponse";
|
import type { ThreadReadResponse } from "../../generated/app-server/v2/ThreadReadResponse";
|
||||||
import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadResumeResponse";
|
import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadResumeResponse";
|
||||||
import type { ThreadRollbackResponse } from "../../generated/app-server/v2/ThreadRollbackResponse";
|
|
||||||
import type { ThreadSetNameResponse } from "../../generated/app-server/v2/ThreadSetNameResponse";
|
import type { ThreadSetNameResponse } from "../../generated/app-server/v2/ThreadSetNameResponse";
|
||||||
import type { ThreadSettingsUpdateResponse } from "../../generated/app-server/v2/ThreadSettingsUpdateResponse";
|
import type { ThreadSettingsUpdateResponse } from "../../generated/app-server/v2/ThreadSettingsUpdateResponse";
|
||||||
import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse";
|
import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse";
|
||||||
|
|
@ -87,7 +86,6 @@ export interface ClientResponseByMethod {
|
||||||
"thread/delete": ThreadDeleteResponse;
|
"thread/delete": ThreadDeleteResponse;
|
||||||
"thread/unsubscribe": ThreadUnsubscribeResponse;
|
"thread/unsubscribe": ThreadUnsubscribeResponse;
|
||||||
"thread/unarchive": ThreadUnarchiveResponse;
|
"thread/unarchive": ThreadUnarchiveResponse;
|
||||||
"thread/rollback": ThreadRollbackResponse;
|
|
||||||
"thread/name/set": ThreadSetNameResponse;
|
"thread/name/set": ThreadSetNameResponse;
|
||||||
"thread/settings/update": ThreadSettingsUpdateResponse;
|
"thread/settings/update": ThreadSettingsUpdateResponse;
|
||||||
"thread/turns/list": ThreadTurnsListResponse;
|
"thread/turns/list": ThreadTurnsListResponse;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"codexAppServer": {
|
"codexAppServer": {
|
||||||
"testedCliVersion": "0.144.4",
|
"testedCliVersion": "0.145.0",
|
||||||
"typeGeneration": {
|
"typeGeneration": {
|
||||||
"arguments": ["app-server", "generate-ts", "--experimental"]
|
"arguments": ["app-server", "generate-ts", "--experimental"]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,32 +5,23 @@ import type { ServerRequest } from "../../generated/app-server/ServerRequest";
|
||||||
import { AppServerClient, type AppServerClientHandlers, type AppServerServerRequestResponder } from "./client";
|
import { AppServerClient, type AppServerClientHandlers, type AppServerServerRequestResponder } from "./client";
|
||||||
|
|
||||||
export interface ConnectionManagerHandlers {
|
export interface ConnectionManagerHandlers {
|
||||||
onNotification: (notification: ServerNotification, context: AppServerConnectionContext) => void;
|
onNotification: (notification: ServerNotification) => void;
|
||||||
onServerRequest: (request: ServerRequest, responder: AppServerServerRequestResponder, context: AppServerConnectionContext) => void;
|
onServerRequest: (request: ServerRequest, responder: AppServerServerRequestResponder) => void;
|
||||||
onLog: (message: string, context: AppServerConnectionContext) => void;
|
onLog: (message: string) => void;
|
||||||
onExit: (context: AppServerConnectionContext) => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient;
|
type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient;
|
||||||
|
|
||||||
export interface AppServerConnectionContext {
|
|
||||||
readonly codexPath: string;
|
|
||||||
readonly cwd: string;
|
|
||||||
readonly generation: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConnectionLifecycleState =
|
type ConnectionLifecycleState =
|
||||||
| { kind: "idle"; generation: number }
|
| { kind: "idle"; generation: number }
|
||||||
| {
|
| {
|
||||||
kind: "connecting";
|
kind: "connecting";
|
||||||
generation: number;
|
generation: number;
|
||||||
contextGeneration: number;
|
|
||||||
codexPath: string;
|
|
||||||
cwd: string;
|
|
||||||
client: AppServerClient;
|
client: AppServerClient;
|
||||||
promise: Promise<ServerInitialization>;
|
promise: Promise<ServerInitialization>;
|
||||||
}
|
}
|
||||||
| { kind: "connected"; generation: number; contextGeneration: number; codexPath: string; cwd: string; client: AppServerClient }
|
| { kind: "connected"; generation: number; client: AppServerClient }
|
||||||
| { kind: "disconnected"; generation: number };
|
| { kind: "disconnected"; generation: number };
|
||||||
|
|
||||||
export class StaleConnectionError extends Error {
|
export class StaleConnectionError extends Error {
|
||||||
|
|
@ -44,25 +35,15 @@ export class ConnectionManager {
|
||||||
private state: ConnectionLifecycleState = { kind: "idle", generation: 0 };
|
private state: ConnectionLifecycleState = { kind: "idle", generation: 0 };
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly codexPath: () => string,
|
private readonly codexPath: string,
|
||||||
private readonly cwd: string,
|
private readonly cwd: string,
|
||||||
initializeParams: InitializeParams,
|
initializeParams: InitializeParams,
|
||||||
private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) =>
|
private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) =>
|
||||||
new AppServerClient({ codexPath, cwd, handlers, initializeParams }),
|
new AppServerClient({ codexPath, cwd, handlers, initializeParams }),
|
||||||
private readonly contextGeneration: () => number = () => 0,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
currentClient(): AppServerClient | null {
|
currentClient(): AppServerClient | null {
|
||||||
return this.state.kind === "connected" && this.state.client.isConnected() && this.stateMatchesCurrentContext(this.state)
|
return this.state.kind === "connected" && this.state.client.isConnected() ? this.state.client : null;
|
||||||
? this.state.client
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentConnectionContext(): AppServerConnectionContext | null {
|
|
||||||
if (this.state.kind !== "connected" || !this.state.client.isConnected() || !this.stateMatchesCurrentContext(this.state)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { codexPath: this.state.codexPath, cwd: this.state.cwd, generation: this.state.contextGeneration };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
|
|
@ -74,31 +55,29 @@ export class ConnectionManager {
|
||||||
if (currentClient) {
|
if (currentClient) {
|
||||||
return serverInitializationFromResponse(currentClient.initializeResponse);
|
return serverInitializationFromResponse(currentClient.initializeResponse);
|
||||||
}
|
}
|
||||||
if (this.state.kind === "connecting" && this.stateMatchesCurrentContext(this.state)) return this.state.promise;
|
if (this.state.kind === "connecting") return this.state.promise;
|
||||||
if (this.state.kind === "connecting" || this.state.kind === "connected") this.disconnect();
|
if (this.state.kind === "connected") this.disconnect();
|
||||||
|
|
||||||
const generation = this.state.generation + 1;
|
const generation = this.state.generation + 1;
|
||||||
const contextGeneration = this.contextGeneration();
|
const codexPath = this.codexPath;
|
||||||
const codexPath = this.codexPath();
|
|
||||||
const cwd = this.cwd;
|
const cwd = this.cwd;
|
||||||
const context = { codexPath, cwd, generation: contextGeneration };
|
|
||||||
const client = this.clientFactory(codexPath, cwd, {
|
const client = this.clientFactory(codexPath, cwd, {
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
if (this.isStale(generation)) return;
|
if (this.isStale(generation)) return;
|
||||||
handlers.onNotification(notification, context);
|
handlers.onNotification(notification);
|
||||||
},
|
},
|
||||||
onServerRequest: (request, responder) => {
|
onServerRequest: (request, responder) => {
|
||||||
if (this.isStale(generation)) return;
|
if (this.isStale(generation)) return;
|
||||||
handlers.onServerRequest(request, responder, context);
|
handlers.onServerRequest(request, responder);
|
||||||
},
|
},
|
||||||
onLog: (message) => {
|
onLog: (message) => {
|
||||||
if (this.isStale(generation)) return;
|
if (this.isStale(generation)) return;
|
||||||
handlers.onLog(message, context);
|
handlers.onLog(message);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
if (this.isStale(generation)) return;
|
if (this.isStale(generation)) return;
|
||||||
this.state = { kind: "disconnected", generation };
|
this.state = { kind: "disconnected", generation };
|
||||||
handlers.onExit(context);
|
handlers.onExit();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const promise = client
|
const promise = client
|
||||||
|
|
@ -108,7 +87,7 @@ export class ConnectionManager {
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
throw new StaleConnectionError();
|
throw new StaleConnectionError();
|
||||||
}
|
}
|
||||||
this.state = { kind: "connected", generation, contextGeneration, codexPath, cwd, client };
|
this.state = { kind: "connected", generation, client };
|
||||||
return serverInitializationFromResponse(response);
|
return serverInitializationFromResponse(response);
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
|
|
@ -122,14 +101,10 @@ export class ConnectionManager {
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.state = { kind: "connecting", generation, contextGeneration, codexPath, cwd, client, promise };
|
this.state = { kind: "connecting", generation, client, promise };
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetConnection(): void {
|
|
||||||
this.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
const previous = this.state;
|
const previous = this.state;
|
||||||
this.state = { kind: "disconnected", generation: previous.generation + 1 };
|
this.state = { kind: "disconnected", generation: previous.generation + 1 };
|
||||||
|
|
@ -141,10 +116,6 @@ export class ConnectionManager {
|
||||||
private isStale(generation: number): boolean {
|
private isStale(generation: number): boolean {
|
||||||
return generation !== this.state.generation;
|
return generation !== this.state.generation;
|
||||||
}
|
}
|
||||||
|
|
||||||
private stateMatchesCurrentContext(state: Extract<ConnectionLifecycleState, { kind: "connecting" | "connected" }>): boolean {
|
|
||||||
return state.codexPath === this.codexPath() && state.cwd === this.cwd && state.contextGeneration === this.contextGeneration();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function serverInitializationFromResponse(response: ServerInitialization): ServerInitialization {
|
function serverInitializationFromResponse(response: ServerInitialization): ServerInitialization {
|
||||||
|
|
|
||||||
4
src/app-server/connection/execution-context.ts
Normal file
4
src/app-server/connection/execution-context.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface AppServerExecutionContext {
|
||||||
|
readonly codexPath: string;
|
||||||
|
readonly vaultPath: string;
|
||||||
|
}
|
||||||
|
|
@ -2,11 +2,17 @@ import { AppServerClient } from "./client";
|
||||||
import type { AppServerClientAccessOptions } from "./client-access";
|
import type { AppServerClientAccessOptions } from "./client-access";
|
||||||
import { codexPanelAppServerInitializeParams } from "./client-profile";
|
import { codexPanelAppServerInitializeParams } from "./client-profile";
|
||||||
|
|
||||||
|
export interface ShortLivedAppServerClientLifetime {
|
||||||
|
created(client: AppServerClient): void;
|
||||||
|
disposed(client: AppServerClient): void;
|
||||||
|
}
|
||||||
|
|
||||||
export async function withShortLivedAppServerClient<T>(
|
export async function withShortLivedAppServerClient<T>(
|
||||||
codexPath: string,
|
codexPath: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
operation: (client: AppServerClient) => Promise<T>,
|
operation: (client: AppServerClient) => Promise<T>,
|
||||||
options: AppServerClientAccessOptions = {},
|
options: AppServerClientAccessOptions = {},
|
||||||
|
lifetime?: ShortLivedAppServerClientLifetime,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const client = new AppServerClient({
|
const client = new AppServerClient({
|
||||||
codexPath,
|
codexPath,
|
||||||
|
|
@ -27,11 +33,13 @@ export async function withShortLivedAppServerClient<T>(
|
||||||
onExit: () => undefined,
|
onExit: () => undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
lifetime?.created(client);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
return await operation(client);
|
return await operation(client);
|
||||||
} finally {
|
} finally {
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
|
lifetime?.disposed(client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,7 @@ export interface AppServerTransportHandlers {
|
||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAppServerSpawnSpec(
|
function createAppServerSpawnSpec(codexPath: string, options: { platform?: NodeJS.Platform; comSpec?: string } = {}): AppServerSpawnSpec {
|
||||||
codexPath: string,
|
|
||||||
options: { platform?: NodeJS.Platform; comSpec?: string } = {},
|
|
||||||
): AppServerSpawnSpec {
|
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
if (platform !== "win32" || !isWindowsCommandScript(codexPath)) {
|
if (platform !== "win32" || !isWindowsCommandScript(codexPath)) {
|
||||||
return { command: codexPath, args: ["app-server"], killProcessTreeOnStop: false };
|
return { command: codexPath, args: ["app-server"], killProcessTreeOnStop: false };
|
||||||
|
|
|
||||||
148
src/app-server/protocol/legacy-panel-user-message.ts
Normal file
148
src/app-server/protocol/legacy-panel-user-message.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import type { VaultFileReference } from "../../domain/chat/input";
|
||||||
|
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Compatibility boundary for user messages persisted by old Codex Panel
|
||||||
|
* versions. New request construction must not depend on this module. Once old
|
||||||
|
* thread history no longer needs recovery, delete this file and its explicit
|
||||||
|
* fallbacks in turn.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function legacyPanelReferencedThreadFromPrompt(text: string): { text: string; reference: ReferencedThreadMetadata } | null {
|
||||||
|
const envelope = referencedThreadEnvelopeFromPrompt(text);
|
||||||
|
return envelope ? { text: envelope.visibleText, reference: envelope.reference } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegacyPanelUserMessageContentItem {
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegacyPanelUserMessageInput {
|
||||||
|
content: readonly LegacyPanelUserMessageContentItem[];
|
||||||
|
visibleText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyPanelUserMessageProjection {
|
||||||
|
text: string;
|
||||||
|
referencedThread: ReferencedThreadMetadata | null;
|
||||||
|
fileReferences: VaultFileReference[];
|
||||||
|
mentionTextByContentIndex: ReadonlyMap<number, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacyPanelUserMessageProjection(input: LegacyPanelUserMessageInput): LegacyPanelUserMessageProjection {
|
||||||
|
const referencedThread = legacyPanelReferencedThreadFromPrompt(input.visibleText);
|
||||||
|
const fileReferences: VaultFileReference[] = [];
|
||||||
|
const mentionTextByContentIndex = new Map<number, string>();
|
||||||
|
for (const [index, item] of input.content.entries()) {
|
||||||
|
if (!isMention(item)) continue;
|
||||||
|
const reference = legacyPanelFileReference(item);
|
||||||
|
if (reference) fileReferences.push(reference);
|
||||||
|
if (!input.visibleText) {
|
||||||
|
mentionTextByContentIndex.set(index, reference ? `[file] ${reference.path}` : `[@${item.name}] ${item.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: referencedThread?.text ?? input.visibleText,
|
||||||
|
referencedThread: referencedThread?.reference ?? null,
|
||||||
|
fileReferences,
|
||||||
|
mentionTextByContentIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMention(
|
||||||
|
input: LegacyPanelUserMessageInput["content"][number],
|
||||||
|
): input is LegacyPanelUserMessageContentItem & { type: "mention"; name: string; path: string } {
|
||||||
|
return input.type === "mention" && typeof input.name === "string" && typeof input.path === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyPanelFileReference(input: { name: string; path: string }): VaultFileReference | null {
|
||||||
|
if (!input.path || /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(input.path)) return null;
|
||||||
|
return { name: input.name, path: input.path };
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFERENCED_THREAD_ENVELOPE_START = "[Codex Panel referenced thread v1]";
|
||||||
|
const REFERENCED_THREAD_ENVELOPE_END = "[/Codex Panel referenced thread]";
|
||||||
|
|
||||||
|
interface ReferencedThreadEnvelope {
|
||||||
|
version: 1;
|
||||||
|
reference: ReferencedThreadMetadata;
|
||||||
|
visibleText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReferencedThreadEnvelopeMetadata {
|
||||||
|
version: 1;
|
||||||
|
threadId: string;
|
||||||
|
title: string;
|
||||||
|
includedTurns: number;
|
||||||
|
turnLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function referencedThreadEnvelopeFromPrompt(text: string): ReferencedThreadEnvelope | null {
|
||||||
|
const headerStart = text.indexOf(REFERENCED_THREAD_ENVELOPE_START);
|
||||||
|
const requestBoundary = `\n${REFERENCED_THREAD_ENVELOPE_END}\n\nCurrent user request:\n`;
|
||||||
|
const boundaryStart = text.lastIndexOf(requestBoundary);
|
||||||
|
if (headerStart !== 0 || boundaryStart === -1) return null;
|
||||||
|
|
||||||
|
const metadataText = firstNonEmptyLine(text.slice(REFERENCED_THREAD_ENVELOPE_START.length, boundaryStart));
|
||||||
|
const metadata = referencedThreadEnvelopeMetadataFromJson(metadataText);
|
||||||
|
const visibleText = text.slice(boundaryStart + requestBoundary.length).trim();
|
||||||
|
if (!metadata || !visibleText) return null;
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
visibleText,
|
||||||
|
reference: {
|
||||||
|
threadId: metadata.threadId,
|
||||||
|
title: metadata.title,
|
||||||
|
includedTurns: metadata.includedTurns,
|
||||||
|
turnLimit: metadata.turnLimit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonEmptyLine(text: string): string | null {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function referencedThreadEnvelopeMetadataFromJson(text: string | null): ReferencedThreadEnvelopeMetadata | null {
|
||||||
|
if (!text) return null;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
const value = parsed as Record<string, unknown>;
|
||||||
|
if (value["version"] !== 1) return null;
|
||||||
|
const threadId = stringValue(value["threadId"]);
|
||||||
|
const title = stringValue(value["title"]);
|
||||||
|
const includedTurns = finiteNonNegativeInteger(value["includedTurns"]);
|
||||||
|
const turnLimit = finitePositiveInteger(value["turnLimit"]);
|
||||||
|
if (!threadId || !title || includedTurns === null || turnLimit === null) return null;
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
threadId,
|
||||||
|
title,
|
||||||
|
includedTurns,
|
||||||
|
turnLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNonNegativeInteger(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finitePositiveInteger(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
||||||
|
}
|
||||||
222
src/app-server/protocol/legacy-turn-context-manifest.ts
Normal file
222
src/app-server/protocol/legacy-turn-context-manifest.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
import type { VaultFileReference } from "../../domain/chat/input";
|
||||||
|
import { isPanelSubmissionClientId, turnContextSubmissionId } from "../../domain/chat/submission-id";
|
||||||
|
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Read-only compatibility for v2 metadata envelopes written by Codex Panel
|
||||||
|
* versions that predate durable, human-readable references in message text.
|
||||||
|
* Request construction must not import this module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TURN_CONTEXT_MANIFEST_PREFIX = "[Codex Panel context v2]";
|
||||||
|
const TURN_CONTEXT_MANIFEST_MAX_BYTES = 2_800;
|
||||||
|
const TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT = 64;
|
||||||
|
const TURN_CONTEXT_FILE_REFERENCE_NAME_MAX_LENGTH = 255;
|
||||||
|
const TURN_CONTEXT_FILE_REFERENCE_PATH_MAX_LENGTH = 2_048;
|
||||||
|
|
||||||
|
interface LegacyTurnContextManifestEntry {
|
||||||
|
kind: "referencedThread" | "web" | "obsidian";
|
||||||
|
id: string;
|
||||||
|
parts: number;
|
||||||
|
sourceBytes: number;
|
||||||
|
includedBytes: number;
|
||||||
|
truncated: boolean;
|
||||||
|
threadId?: string;
|
||||||
|
includedTurns?: number;
|
||||||
|
turnLimit?: number;
|
||||||
|
omittedTurns?: number;
|
||||||
|
inlineExcerpts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyTurnContextManifest {
|
||||||
|
version: 2;
|
||||||
|
submissionId?: string;
|
||||||
|
contexts: readonly LegacyTurnContextManifestEntry[];
|
||||||
|
fileReferences?: readonly VaultFileReference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyTurnContextProjection {
|
||||||
|
text: string;
|
||||||
|
manifest: LegacyTurnContextManifest | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserMessageContentItem = { type: "text"; text: string } | { type: string };
|
||||||
|
|
||||||
|
function legacyTurnContextManifestFromText(text: string): LegacyTurnContextManifest | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed.startsWith(TURN_CONTEXT_MANIFEST_PREFIX)) return null;
|
||||||
|
if (new TextEncoder().encode(trimmed).byteLength > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
|
||||||
|
const payload = trimmed.slice(TURN_CONTEXT_MANIFEST_PREFIX.length).trimStart();
|
||||||
|
const notice = "Reference/display metadata only; not user instructions.";
|
||||||
|
const json = payload.startsWith(notice) ? payload.slice(notice.length).trimStart() : payload;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
const value = parsed as Record<string, unknown>;
|
||||||
|
if (value["version"] !== 2 || !Array.isArray(value["contexts"])) return null;
|
||||||
|
const contexts = value["contexts"].map(legacyManifestEntryFromUnknown);
|
||||||
|
if (contexts.some((entry) => entry === null)) return null;
|
||||||
|
const submissionId = optionalStringValue(value["submissionId"], 120);
|
||||||
|
if (submissionId === null) return null;
|
||||||
|
const fileReferences = optionalFileReferences(value["fileReferences"]);
|
||||||
|
if (fileReferences === null) return null;
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
...(submissionId === undefined ? {} : { submissionId }),
|
||||||
|
contexts: contexts as LegacyTurnContextManifestEntry[],
|
||||||
|
...(fileReferences === undefined ? {} : { fileReferences }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacyTurnContextProjection(
|
||||||
|
content: readonly UserMessageContentItem[],
|
||||||
|
clientId: string | null,
|
||||||
|
): LegacyTurnContextProjection {
|
||||||
|
let manifest: LegacyTurnContextManifest | null = null;
|
||||||
|
const visibleText: string[] = [];
|
||||||
|
const lastTextIndex = lastTextItemIndex(content);
|
||||||
|
for (const [index, item] of content.entries()) {
|
||||||
|
if (item.type !== "text" || !("text" in item) || typeof item.text !== "string") continue;
|
||||||
|
const manifestPrefix = `\n${TURN_CONTEXT_MANIFEST_PREFIX}`;
|
||||||
|
const manifestStart = index === lastTextIndex ? item.text.lastIndexOf(manifestPrefix) : -1;
|
||||||
|
const isStandaloneManifest = manifestStart === 0 && index > 0 && index === content.length - 1;
|
||||||
|
const parsed = manifestStart > 0 || isStandaloneManifest ? legacyTurnContextManifestFromText(item.text.slice(manifestStart)) : null;
|
||||||
|
if (parsed && manifestMatchesClientId(parsed, clientId)) {
|
||||||
|
manifest = parsed;
|
||||||
|
if (manifestStart > 0) visibleText.push(item.text.slice(0, manifestStart));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visibleText.push(item.text);
|
||||||
|
}
|
||||||
|
return { text: visibleText.join("\n"), manifest };
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastTextItemIndex(content: readonly UserMessageContentItem[]): number {
|
||||||
|
for (let index = content.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (content[index]?.type === "text") return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestMatchesClientId(manifest: LegacyTurnContextManifest, clientId: string | null): boolean {
|
||||||
|
if (!isPanelSubmissionClientId(clientId)) return false;
|
||||||
|
const submissionId = turnContextSubmissionId(clientId);
|
||||||
|
if (manifest.submissionId !== undefined && manifest.submissionId !== submissionId) return false;
|
||||||
|
const contextIds = manifest.contexts.map((context) => context.id);
|
||||||
|
return (
|
||||||
|
(manifest.submissionId === submissionId || contextIds.length > 0) &&
|
||||||
|
new Set(contextIds).size === contextIds.length &&
|
||||||
|
contextIds.every((contextId) => new RegExp(`^${escapeRegExp(submissionId)}\\.\\d{2}$`).test(contextId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function referencedThreadFromLegacyManifest(manifest: LegacyTurnContextManifest | null): ReferencedThreadMetadata | null {
|
||||||
|
const context = manifest?.contexts.find((entry) => entry.kind === "referencedThread");
|
||||||
|
if (!context?.threadId || context.includedTurns === undefined || context.turnLimit === undefined || context.omittedTurns === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
threadId: context.threadId,
|
||||||
|
title: context.threadId.slice(0, 8),
|
||||||
|
includedTurns: context.includedTurns,
|
||||||
|
turnLimit: context.turnLimit,
|
||||||
|
omittedTurns: context.omittedTurns,
|
||||||
|
truncated: context.truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileReferencesFromLegacyManifest(manifest: LegacyTurnContextManifest | null): VaultFileReference[] {
|
||||||
|
return manifest?.fileReferences ? [...manifest.fileReferences] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyManifestEntryFromUnknown(input: unknown): LegacyTurnContextManifestEntry | null {
|
||||||
|
if (!input || typeof input !== "object") return null;
|
||||||
|
const value = input as Record<string, unknown>;
|
||||||
|
const kind = contextKind(value["kind"]);
|
||||||
|
const id = stringValue(value["id"]);
|
||||||
|
const parts = nonNegativeInteger(value["parts"]);
|
||||||
|
const sourceBytes = nonNegativeInteger(value["sourceBytes"]);
|
||||||
|
const includedBytes = nonNegativeInteger(value["includedBytes"]);
|
||||||
|
const truncated = value["truncated"];
|
||||||
|
if (!kind || !id || parts === null || sourceBytes === null || includedBytes === null || typeof truncated !== "boolean") return null;
|
||||||
|
|
||||||
|
if (kind === "obsidian") {
|
||||||
|
const inlineExcerpts = optionalNonNegativeInteger(value["inlineExcerpts"]);
|
||||||
|
if (inlineExcerpts === null) return null;
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
id,
|
||||||
|
parts,
|
||||||
|
sourceBytes,
|
||||||
|
includedBytes,
|
||||||
|
truncated,
|
||||||
|
...(inlineExcerpts === undefined ? {} : { inlineExcerpts }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (kind !== "referencedThread") return { kind, id, parts, sourceBytes, includedBytes, truncated };
|
||||||
|
const threadId = stringValue(value["threadId"]);
|
||||||
|
const includedTurns = nonNegativeInteger(value["includedTurns"]);
|
||||||
|
const turnLimit = positiveInteger(value["turnLimit"]);
|
||||||
|
const omittedTurns = nonNegativeInteger(value["omittedTurns"]);
|
||||||
|
if (!threadId || includedTurns === null || turnLimit === null || omittedTurns === null) return null;
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
id,
|
||||||
|
parts,
|
||||||
|
sourceBytes,
|
||||||
|
includedBytes,
|
||||||
|
truncated,
|
||||||
|
threadId,
|
||||||
|
includedTurns,
|
||||||
|
turnLimit,
|
||||||
|
omittedTurns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextKind(value: unknown): LegacyTurnContextManifestEntry["kind"] | null {
|
||||||
|
return value === "referencedThread" || value === "web" || value === "obsidian" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.length > 0 && value.length <= 160 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalStringValue(value: unknown, maxLength: number): string | null | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
return typeof value === "string" && value.length > 0 && value.length <= maxLength ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalFileReferences(value: unknown): VaultFileReference[] | null | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!Array.isArray(value) || value.length > TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT) return null;
|
||||||
|
const references = value.map(manifestFileReference);
|
||||||
|
return references.some((reference) => reference === null) ? null : (references as VaultFileReference[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestFileReference(input: unknown): VaultFileReference | null {
|
||||||
|
if (!input || typeof input !== "object") return null;
|
||||||
|
const reference = input as Record<string, unknown>;
|
||||||
|
const name = optionalStringValue(reference["name"], TURN_CONTEXT_FILE_REFERENCE_NAME_MAX_LENGTH);
|
||||||
|
const path = optionalStringValue(reference["path"], TURN_CONTEXT_FILE_REFERENCE_PATH_MAX_LENGTH);
|
||||||
|
return name && path ? { name, path } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeInteger(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNonNegativeInteger(value: unknown): number | null | undefined {
|
||||||
|
return value === undefined ? undefined : nonNegativeInteger(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { splitUtf8Context } from "../../domain/chat/context-budget";
|
||||||
import type { CodexInputItem } from "../../domain/chat/input";
|
import type { CodexInputItem } from "../../domain/chat/input";
|
||||||
|
import { turnContextSubmissionId } from "../../domain/chat/submission-id";
|
||||||
|
|
||||||
type AppServerUserInputImageDetail = "auto" | "low" | "high" | "original";
|
type AppServerUserInputImageDetail = "auto" | "low" | "high" | "original";
|
||||||
|
|
||||||
|
|
@ -6,28 +8,75 @@ type AppServerUserInput =
|
||||||
| { type: "text"; text: string; text_elements: [] }
|
| { type: "text"; text: string; text_elements: [] }
|
||||||
| { type: "image"; detail?: AppServerUserInputImageDetail; url: string }
|
| { type: "image"; detail?: AppServerUserInputImageDetail; url: string }
|
||||||
| { type: "localImage"; detail?: AppServerUserInputImageDetail; path: string }
|
| { type: "localImage"; detail?: AppServerUserInputImageDetail; path: string }
|
||||||
| { type: "skill"; name: string; path: string }
|
| { type: "skill"; name: string; path: string };
|
||||||
| { type: "mention"; name: string; path: string };
|
|
||||||
|
|
||||||
interface AppServerAdditionalContextEntry {
|
interface AppServerAdditionalContextEntry {
|
||||||
value: string;
|
value: string;
|
||||||
kind: "untrusted" | "application";
|
kind: "untrusted" | "application";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppServerTurnInput {
|
||||||
|
input: AppServerUserInput[];
|
||||||
|
additionalContext?: Record<string, AppServerAdditionalContextEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES = 2_800;
|
||||||
|
const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
|
||||||
|
|
||||||
export function toAppServerUserInput(input: readonly CodexInputItem[]): AppServerUserInput[] {
|
export function toAppServerUserInput(input: readonly CodexInputItem[]): AppServerUserInput[] {
|
||||||
return input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
|
return input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function additionalContextFromCodexInput(
|
export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[], submissionId: string): AppServerTurnInput {
|
||||||
input: readonly CodexInputItem[],
|
|
||||||
): Record<string, AppServerAdditionalContextEntry> | undefined {
|
|
||||||
const additionalContext: Record<string, AppServerAdditionalContextEntry> = {};
|
const additionalContext: Record<string, AppServerAdditionalContextEntry> = {};
|
||||||
for (const item of input) {
|
const contexts = input.filter(
|
||||||
if (item.type !== "additionalContext") continue;
|
(item): item is Extract<CodexInputItem, { type: "additionalContext" }> =>
|
||||||
if (!item.key || !item.value) continue;
|
item.type === "additionalContext" && Boolean(item.key) && Boolean(item.value),
|
||||||
additionalContext[item.key] = { value: item.value, kind: item.kind };
|
);
|
||||||
|
if (contexts.length > ADDITIONAL_CONTEXT_MAX_PARTS) {
|
||||||
|
throw new Error(`Too many additional context sources (${String(contexts.length)}).`);
|
||||||
}
|
}
|
||||||
return Object.keys(additionalContext).length > 0 ? additionalContext : undefined;
|
const partAllocations = allocatedPartCounts(contexts);
|
||||||
|
for (const [contextIndex, item] of contexts.entries()) {
|
||||||
|
const id = `${turnContextSubmissionId(submissionId)}.${String(contextIndex).padStart(2, "0")}`;
|
||||||
|
const split = splitUtf8Context(item.value, ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES, partAllocations[contextIndex] ?? 1);
|
||||||
|
const partCount = split.parts.length;
|
||||||
|
split.parts.forEach((part, partIndex) => {
|
||||||
|
const key = [
|
||||||
|
"codex_panel",
|
||||||
|
id,
|
||||||
|
safeKeyPart(item.key),
|
||||||
|
`part_${String(partIndex + 1).padStart(2, "0")}_of_${String(partCount).padStart(2, "0")}`,
|
||||||
|
].join(".");
|
||||||
|
additionalContext[key] = {
|
||||||
|
kind: item.kind,
|
||||||
|
value: [
|
||||||
|
`Codex Panel context part ${String(partIndex + 1)}/${String(partCount)}.`,
|
||||||
|
`Source: ${safeKeyPart(item.key)}`,
|
||||||
|
"",
|
||||||
|
part,
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
input: toAppServerUserInput(input),
|
||||||
|
...(Object.keys(additionalContext).length > 0 ? { additionalContext } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function allocatedPartCounts(contexts: readonly Extract<CodexInputItem, { type: "additionalContext" }>[]): number[] {
|
||||||
|
const allocations = contexts.map(() => 1);
|
||||||
|
const desired = contexts.map(
|
||||||
|
(context) => splitUtf8Context(context.value, ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES, ADDITIONAL_CONTEXT_MAX_PARTS).parts.length,
|
||||||
|
);
|
||||||
|
let remaining = ADDITIONAL_CONTEXT_MAX_PARTS - contexts.length;
|
||||||
|
for (let index = 0; index < contexts.length && remaining > 0; index += 1) {
|
||||||
|
const extra = Math.min(Math.max((desired[index] ?? 1) - 1, 0), remaining);
|
||||||
|
allocations[index] = (allocations[index] ?? 1) + extra;
|
||||||
|
remaining -= extra;
|
||||||
|
}
|
||||||
|
return allocations;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServerUserInput[] {
|
function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServerUserInput[] {
|
||||||
|
|
@ -40,13 +89,18 @@ function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServ
|
||||||
return [{ type: "localImage", path: item.path, ...appServerImageDetailProp(item.detail) }];
|
return [{ type: "localImage", path: item.path, ...appServerImageDetailProp(item.detail) }];
|
||||||
case "skill":
|
case "skill":
|
||||||
return [{ type: "skill", name: item.name, path: item.path }];
|
return [{ type: "skill", name: item.name, path: item.path }];
|
||||||
case "mention":
|
case "fileReference":
|
||||||
return [{ type: "mention", name: item.name, path: item.path }];
|
return [];
|
||||||
case "additionalContext":
|
case "additionalContext":
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeKeyPart(value: string): string {
|
||||||
|
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
|
||||||
|
return safe || "context";
|
||||||
|
}
|
||||||
|
|
||||||
function appServerImageDetailProp(detail: Extract<CodexInputItem, { type: "image" | "localImage" }>["detail"]): {
|
function appServerImageDetailProp(detail: Extract<CodexInputItem, { type: "image" | "localImage" }>["detail"]): {
|
||||||
detail?: AppServerUserInputImageDetail;
|
detail?: AppServerUserInputImageDetail;
|
||||||
} {
|
} {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export function threadFromThreadRecord(thread: ThreadRecord, options: { archived
|
||||||
archived: options.archived ?? false,
|
archived: options.archived ?? false,
|
||||||
createdAt: finiteTimestamp(thread.createdAt),
|
createdAt: finiteTimestamp(thread.createdAt),
|
||||||
updatedAt: finiteTimestamp(thread.updatedAt),
|
updatedAt: finiteTimestamp(thread.updatedAt),
|
||||||
|
canAcceptDirectInput: typeof thread.canAcceptDirectInput === "boolean" ? thread.canAcceptDirectInput : null,
|
||||||
provenance: threadProvenance(thread),
|
provenance: threadProvenance(thread),
|
||||||
...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}),
|
...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { VaultFileReference } from "../../domain/chat/input";
|
||||||
|
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
|
||||||
import {
|
import {
|
||||||
nonEmptyTurnTranscriptSummaries,
|
nonEmptyTurnTranscriptSummaries,
|
||||||
type ThreadTranscriptEntry,
|
type ThreadTranscriptEntry,
|
||||||
|
|
@ -6,13 +8,16 @@ import {
|
||||||
} from "../../domain/threads/transcript";
|
} from "../../domain/threads/transcript";
|
||||||
import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem";
|
import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem";
|
||||||
import type { Turn as GeneratedTurn } from "../../generated/app-server/v2/Turn";
|
import type { Turn as GeneratedTurn } from "../../generated/app-server/v2/Turn";
|
||||||
|
import { legacyPanelUserMessageProjection } from "./legacy-panel-user-message";
|
||||||
|
import {
|
||||||
|
fileReferencesFromLegacyManifest,
|
||||||
|
legacyTurnContextProjection,
|
||||||
|
referencedThreadFromLegacyManifest,
|
||||||
|
} from "./legacy-turn-context-manifest";
|
||||||
|
|
||||||
export type TurnItem = GeneratedThreadItem;
|
export type TurnItem = GeneratedThreadItem;
|
||||||
export type TurnRecord = GeneratedTurn;
|
export type TurnRecord = GeneratedTurn;
|
||||||
|
|
||||||
type AppServerUserInput = Extract<TurnItem, { type: "userMessage" }>["content"][number];
|
|
||||||
type AppServerTextUserInput = Extract<AppServerUserInput, { type: "text" }>;
|
|
||||||
|
|
||||||
function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] {
|
function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] {
|
||||||
return turn.items.flatMap((item) => transcriptEntriesFromTurnItem(item, turn));
|
return turn.items.flatMap((item) => transcriptEntriesFromTurnItem(item, turn));
|
||||||
}
|
}
|
||||||
|
|
@ -48,8 +53,42 @@ export function chronologicalTurnTranscriptSummariesFromTurnRecords(turns: reado
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function turnUserItemText(item: Extract<TurnItem, { type: "userMessage" }>): string {
|
export interface TurnUserItemProjection {
|
||||||
return userInputText(item.content);
|
text: string;
|
||||||
|
referencedThread: ReferencedThreadMetadata | null;
|
||||||
|
fileReferences: VaultFileReference[];
|
||||||
|
contexts: TurnUserContextMetadata[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurnUserContextMetadata {
|
||||||
|
kind: "web" | "obsidian";
|
||||||
|
truncated: boolean;
|
||||||
|
inlineExcerpts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMessage" }>): TurnUserItemProjection {
|
||||||
|
const projected = legacyTurnContextProjection(item.content, item.clientId);
|
||||||
|
if (!projected.manifest) {
|
||||||
|
const legacy = legacyPanelUserMessageProjection({
|
||||||
|
content: item.content,
|
||||||
|
visibleText: projected.text,
|
||||||
|
});
|
||||||
|
const supplementalText = nonTextUserInputText(item.content, projected.text, legacy.mentionTextByContentIndex);
|
||||||
|
return {
|
||||||
|
text: [legacy.text, supplementalText].filter(Boolean).join("\n"),
|
||||||
|
referencedThread: legacy.referencedThread,
|
||||||
|
fileReferences: legacy.fileReferences,
|
||||||
|
contexts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const supplementalText = nonTextUserInputText(item.content, projected.text);
|
||||||
|
const text = [projected.text, supplementalText].filter(Boolean).join("\n");
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
referencedThread: referencedThreadFromLegacyManifest(projected.manifest),
|
||||||
|
fileReferences: fileReferencesFromLegacyManifest(projected.manifest),
|
||||||
|
contexts: legacyContextMetadata(projected.manifest.contexts),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | null {
|
export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | null {
|
||||||
|
|
@ -65,8 +104,20 @@ export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | n
|
||||||
|
|
||||||
function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): ThreadTranscriptEntry[] {
|
function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): ThreadTranscriptEntry[] {
|
||||||
if (item.type === "userMessage") {
|
if (item.type === "userMessage") {
|
||||||
const text = turnUserItemText(item).trim();
|
const projection = turnUserItemProjection(item);
|
||||||
return text ? [{ kind: "user", text, timestamp: turn.startedAt }] : [];
|
const text = projection.text.trim();
|
||||||
|
const contexts = projection.contexts.map((context) => ({ kind: context.kind, truncated: context.truncated }));
|
||||||
|
return text
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
kind: "user",
|
||||||
|
text,
|
||||||
|
timestamp: turn.startedAt,
|
||||||
|
...(projection.referencedThread ? { referencedThread: projection.referencedThread } : {}),
|
||||||
|
...(contexts.length > 0 ? { contexts } : {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
if (item.type === "agentMessage") {
|
if (item.type === "agentMessage") {
|
||||||
const text = item.text.trim();
|
const text = item.text.trim();
|
||||||
|
|
@ -79,22 +130,36 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function userInputText(content: readonly AppServerUserInput[]): string {
|
function legacyContextMetadata(
|
||||||
const textItems = content.filter(isTextUserInput);
|
contexts: readonly { kind: "referencedThread" | "web" | "obsidian"; truncated: boolean; inlineExcerpts?: number }[],
|
||||||
const hasText = textItems.some((item) => item.text.length > 0);
|
): TurnUserContextMetadata[] {
|
||||||
const textIncludes = (value: string) => value.length > 0 && textItems.some((item) => item.text.includes(value));
|
return contexts.flatMap((context) => {
|
||||||
|
if (context.kind === "referencedThread") return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: context.kind,
|
||||||
|
truncated: context.truncated,
|
||||||
|
...(context.inlineExcerpts === undefined ? {} : { inlineExcerpts: context.inlineExcerpts }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonTextUserInputText(
|
||||||
|
content: Extract<TurnItem, { type: "userMessage" }>["content"],
|
||||||
|
visibleText: string,
|
||||||
|
mentionTextByContentIndex?: ReadonlyMap<number, string>,
|
||||||
|
): string {
|
||||||
|
const hasText = visibleText.length > 0;
|
||||||
|
const textIncludes = (value: string) => value.length > 0 && visibleText.includes(value);
|
||||||
return content
|
return content
|
||||||
.map((item) => {
|
.map((item, index) => {
|
||||||
if (item.type === "text") return item.text;
|
|
||||||
if (item.type === "localImage") return hasText && textIncludes(item.path) ? "" : `[local image] ${item.path}`;
|
if (item.type === "localImage") return hasText && textIncludes(item.path) ? "" : `[local image] ${item.path}`;
|
||||||
if (item.type === "image") return hasText && textIncludes(item.url) ? "" : `[image] ${item.url}`;
|
if (item.type === "image") return hasText && textIncludes(item.url) ? "" : `[image] ${item.url}`;
|
||||||
if (item.type === "mention") return hasText ? "" : `[@${item.name}] ${item.path}`;
|
if (item.type === "mention") return mentionTextByContentIndex?.get(index) ?? "";
|
||||||
return hasText ? "" : `[$${item.name}] ${item.path}`;
|
if (item.type === "skill") return hasText ? "" : `[$${item.name}] ${item.path}`;
|
||||||
|
return "";
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTextUserInput(item: AppServerUserInput): item is AppServerTextUserInput {
|
|
||||||
return item.type === "text";
|
|
||||||
}
|
|
||||||
|
|
|
||||||
65
src/app-server/query/active-thread-inventory.ts
Normal file
65
src/app-server/query/active-thread-inventory.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import type { InfiniteData } from "@tanstack/query-core";
|
||||||
|
import { type Thread, threadRecencyAt } from "../../domain/threads/model";
|
||||||
|
import type { ThreadPage } from "../services/threads";
|
||||||
|
import type { ThreadListMutation } from "./thread-list-mutation";
|
||||||
|
|
||||||
|
export type ActiveThreadCursor = string | null;
|
||||||
|
export type ActiveThreadData = InfiniteData<ThreadPage, ActiveThreadCursor>;
|
||||||
|
|
||||||
|
export function activeThreadsFromData(data: ActiveThreadData | undefined): readonly Thread[] | null {
|
||||||
|
if (!data) return null;
|
||||||
|
return orderedUniqueThreads(data.pages.flatMap((page) => page.threads));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recentActiveThreadsFromData(data: ActiveThreadData | undefined): readonly Thread[] | null {
|
||||||
|
if (!data) return null;
|
||||||
|
const threads = activeThreadsFromData(data) ?? [];
|
||||||
|
const recentWindowCapacity = Math.max(data.pages[0]?.fetchedSize ?? 0, threads.length > 0 ? 1 : 0);
|
||||||
|
return threads.slice(0, recentWindowCapacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeThreadDataHasMore(data: ActiveThreadData | undefined): boolean {
|
||||||
|
return data?.pages.at(-1)?.nextCursor != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyActiveThreadMutation(data: ActiveThreadData | undefined, mutation: ThreadListMutation): ActiveThreadData | undefined {
|
||||||
|
if (!data || mutation.list !== "active" || mutation.kind === "refresh") return data;
|
||||||
|
const pages = data.pages.map((page) => ({ ...page, threads: [...page.threads] }));
|
||||||
|
|
||||||
|
switch (mutation.kind) {
|
||||||
|
case "upsert": {
|
||||||
|
const existing = pages.some((page) => page.threads.some((thread) => thread.id === mutation.thread.id));
|
||||||
|
if (existing) {
|
||||||
|
for (const page of pages) {
|
||||||
|
page.threads = page.threads.map((thread) => (thread.id === mutation.thread.id ? mutation.thread : thread));
|
||||||
|
}
|
||||||
|
} else if (pages[0]) {
|
||||||
|
pages[0].threads = [mutation.thread, ...pages[0].threads];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove":
|
||||||
|
for (const page of pages) page.threads = page.threads.filter((thread) => thread.id !== mutation.threadId);
|
||||||
|
break;
|
||||||
|
case "update":
|
||||||
|
for (const page of pages) {
|
||||||
|
page.threads = page.threads.map((thread) => (thread.id === mutation.threadId ? { ...thread, ...mutation.changes } : thread));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pages, pageParams: [...data.pageParams] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedUniqueThreads(threads: readonly Thread[]): readonly Thread[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return threads
|
||||||
|
.filter((thread) => {
|
||||||
|
if (seen.has(thread.id)) return false;
|
||||||
|
seen.add(thread.id);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((thread, index) => ({ thread, index }))
|
||||||
|
.sort((left, right) => threadRecencyAt(right.thread) - threadRecencyAt(left.thread) || left.index - right.index)
|
||||||
|
.map(({ thread }) => thread);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
import { QueryClient, QueryObserver, type QueryObserverResult } from "@tanstack/query-core";
|
import {
|
||||||
|
CancelledError,
|
||||||
|
InfiniteQueryObserver,
|
||||||
|
type InfiniteQueryObserverOptions,
|
||||||
|
type InfiniteQueryObserverResult,
|
||||||
|
QueryClient,
|
||||||
|
QueryObserver,
|
||||||
|
type QueryObserverResult,
|
||||||
|
} from "@tanstack/query-core";
|
||||||
import type { ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata";
|
import type { ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata";
|
||||||
import { cloneRuntimeConfigSnapshot, type RuntimeConfigSnapshot } from "../../domain/runtime/config";
|
import { cloneRuntimeConfigSnapshot, type RuntimeConfigSnapshot } from "../../domain/runtime/config";
|
||||||
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
|
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
|
||||||
|
|
@ -10,103 +18,88 @@ import {
|
||||||
diagnosticProbeOk,
|
diagnosticProbeOk,
|
||||||
diagnosticsWithProbe,
|
diagnosticsWithProbe,
|
||||||
} from "../../domain/server/diagnostics";
|
} from "../../domain/server/diagnostics";
|
||||||
import type { SharedServerMetadata } from "../../domain/server/metadata";
|
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../domain/server/metadata";
|
||||||
import type { Thread } from "../../domain/threads/model";
|
import type { Thread } from "../../domain/threads/model";
|
||||||
|
import { StaleExecutionRuntimeError } from "../../shared/runtime/execution-runtime-lifetime";
|
||||||
import type { AppServerClient } from "../connection/client";
|
import type { AppServerClient } from "../connection/client";
|
||||||
import type { AppServerClientAccessOptions } from "../connection/client-access";
|
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../connection/client-access";
|
||||||
|
import type { AppServerExecutionContext } from "../connection/execution-context";
|
||||||
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
|
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
|
||||||
import { listModelMetadata } from "../services/catalog";
|
import { listModelMetadata } from "../services/catalog";
|
||||||
import { readEffectiveConfig } from "../services/runtime-metadata";
|
import { readEffectiveConfig } from "../services/runtime-metadata";
|
||||||
import { listThreads, readThreadPage } from "../services/threads";
|
import { listThreads, readThreadPage, type ThreadPage } from "../services/threads";
|
||||||
import {
|
import {
|
||||||
type AppServerQueryContextIdentity as AppServerQueryContext,
|
type ActiveThreadCursor,
|
||||||
activeThreadsQueryKey,
|
type ActiveThreadData,
|
||||||
appServerModelsQueryKey,
|
activeThreadDataHasMore,
|
||||||
appServerPermissionProfilesQueryKey,
|
activeThreadsFromData,
|
||||||
appServerQueryContextIsComplete,
|
applyActiveThreadMutation,
|
||||||
appServerRateLimitsQueryKey,
|
recentActiveThreadsFromData,
|
||||||
appServerRuntimeConfigQueryKey,
|
} from "./active-thread-inventory";
|
||||||
appServerSkillsQueryKey,
|
|
||||||
archivedThreadsQueryKey,
|
|
||||||
cloneAppServerQueryContextIdentity,
|
|
||||||
} from "./keys";
|
|
||||||
import { readPermissionProfileMetadataProbe, readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
|
import { readPermissionProfileMetadataProbe, readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
|
||||||
import type { ObservedResult, ObservedResultListener } from "./observed-result";
|
import type { ObservedPaginatedResult, ObservedPaginatedResultListener, ObservedResult, ObservedResultListener } from "./observed-result";
|
||||||
import { cloneModelMetadata, cloneSharedServerMetadata, cloneThreads } from "./snapshots";
|
import { cloneModelMetadata, cloneSharedServerMetadata, cloneSharedServerMetadataResource, cloneThreads } from "./snapshots";
|
||||||
|
import { applyThreadListMutation, type ThreadListKind, type ThreadListMutation } from "./thread-list-mutation";
|
||||||
|
|
||||||
const THREAD_LIST_STALE_TIME_MS = 10_000;
|
|
||||||
const APP_SERVER_METADATA_STALE_TIME_MS = 10_000;
|
|
||||||
const MODELS_STALE_TIME_MS = 60_000;
|
const MODELS_STALE_TIME_MS = 60_000;
|
||||||
const APP_SERVER_QUERY_GC_TIME_MS = 5 * 60_000;
|
const ACTIVE_THREADS_QUERY_KEY = ["threads", "active"] as const;
|
||||||
const FULL_ACTIVE_THREAD_FETCH_ATTEMPTS = 2;
|
const ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY = ["threads", "active-search-inventory"] as const;
|
||||||
|
const ARCHIVED_THREADS_QUERY_KEY = ["threads", "archived"] as const;
|
||||||
export interface AppServerQueryClientRunner {
|
const MODELS_QUERY_KEY = ["models"] as const;
|
||||||
runWithClient<T>(
|
const RUNTIME_CONFIG_QUERY_KEY = ["runtime-config"] as const;
|
||||||
context: AppServerQueryContext,
|
const SKILLS_QUERY_KEY = ["skills"] as const;
|
||||||
operation: (client: AppServerClient) => Promise<T>,
|
const PERMISSION_PROFILES_QUERY_KEY = ["permission-profiles"] as const;
|
||||||
options?: AppServerClientAccessOptions,
|
const RATE_LIMITS_QUERY_KEY = ["rate-limits"] as const;
|
||||||
): Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppServerQueryOptions<T> {
|
interface AppServerQueryOptions<T> {
|
||||||
readonly queryKey: readonly unknown[];
|
readonly queryKey: readonly unknown[];
|
||||||
readonly queryFn: () => Promise<T>;
|
readonly queryFn: (context: { signal: AbortSignal }) => Promise<T>;
|
||||||
readonly staleTime: number;
|
readonly staleTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThreadListKind = "active" | "archived";
|
type ActiveThreadsQueryKey = typeof ACTIVE_THREADS_QUERY_KEY;
|
||||||
|
|
||||||
interface MetadataResourceSnapshot<T> {
|
interface MetadataResourceSnapshot<T> {
|
||||||
readonly value: T;
|
readonly value: T;
|
||||||
readonly probe: DiagnosticProbeResult;
|
readonly probe: DiagnosticProbeResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MetadataResourceKind = "skills" | "permissionProfiles" | "rateLimits";
|
type MetadataResourceKind = "skills" | "permissionProfiles" | "rateLimits";
|
||||||
type MetadataNotificationResourceKind = "skills" | "rateLimits";
|
|
||||||
type MetadataResourceValue = readonly SkillMetadata[] | readonly RuntimePermissionProfileSummary[] | RateLimitSnapshot | null;
|
type MetadataResourceValue = readonly SkillMetadata[] | readonly RuntimePermissionProfileSummary[] | RateLimitSnapshot | null;
|
||||||
|
|
||||||
interface MetadataNotificationRefresh {
|
|
||||||
dirty: boolean;
|
|
||||||
readonly generation: number;
|
|
||||||
promise: Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AppServerQueryCache {
|
export class AppServerQueryCache {
|
||||||
private readonly context: AppServerQueryContext;
|
private readonly context: Readonly<AppServerExecutionContext>;
|
||||||
private readonly client: QueryClient;
|
private readonly client: QueryClient;
|
||||||
private readonly clientRunner: AppServerQueryClientRunner | null;
|
private readonly clientAccess: AppServerClientAccess;
|
||||||
private activeThreadCursorKnown = false;
|
private readonly observerUnsubscribes = new Set<() => void>();
|
||||||
private activeThreadCursor: string | null = null;
|
|
||||||
private activeThreadRevision = 0;
|
|
||||||
private metadataRefreshCount = 0;
|
|
||||||
private readonly metadataProjectionListeners = new Set<() => void>();
|
|
||||||
private readonly metadataResourceFetches = new Map<MetadataResourceKind, Set<Promise<void>>>();
|
|
||||||
private readonly metadataNotificationRefreshes = new Map<MetadataNotificationResourceKind, MetadataNotificationRefresh>();
|
|
||||||
private generation = 0;
|
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
|
||||||
constructor(context: AppServerQueryContext, options: { client?: QueryClient; clientRunner?: AppServerQueryClientRunner } = {}) {
|
constructor(context: AppServerExecutionContext, clientAccess: AppServerClientAccess) {
|
||||||
this.context = cloneAppServerQueryContextIdentity(context);
|
this.context = Object.freeze({ ...context });
|
||||||
this.client = options.client ?? createAppServerQueryClient();
|
this.client = createAppServerQueryClient();
|
||||||
this.clientRunner = options.clientRunner ?? null;
|
this.clientAccess = clientAccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
this.generation += 1;
|
for (const unsubscribe of [...this.observerUnsubscribes]) unsubscribe();
|
||||||
this.activeThreadCursorKnown = false;
|
this.observerUnsubscribes.clear();
|
||||||
this.activeThreadCursor = null;
|
|
||||||
this.activeThreadRevision = 0;
|
|
||||||
this.metadataRefreshCount = 0;
|
|
||||||
this.metadataProjectionListeners.clear();
|
|
||||||
this.metadataResourceFetches.clear();
|
|
||||||
this.metadataNotificationRefreshes.clear();
|
|
||||||
this.client.clear();
|
this.client.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
activeThreadsSnapshot(): readonly Thread[] | null {
|
activeThreadsSnapshot(): readonly Thread[] | null {
|
||||||
if (this.disposed) return null;
|
if (this.disposed) return null;
|
||||||
return this.threadListSnapshot("active");
|
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
|
||||||
|
const threads = activeThreadsFromData(data);
|
||||||
|
return threads ? cloneThreads(threads) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
recentActiveThreadsSnapshot(): readonly Thread[] | null {
|
||||||
|
if (this.disposed) return null;
|
||||||
|
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
|
||||||
|
const threads = recentActiveThreadsFromData(data);
|
||||||
|
return threads ? cloneThreads(threads) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
archivedThreadsSnapshot(): readonly Thread[] | null {
|
archivedThreadsSnapshot(): readonly Thread[] | null {
|
||||||
|
|
@ -114,102 +107,163 @@ export class AppServerQueryCache {
|
||||||
return this.threadListSnapshot("archived");
|
return this.threadListSnapshot("archived");
|
||||||
}
|
}
|
||||||
|
|
||||||
observeActiveThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options: { emitCurrent?: boolean } = {}): () => void {
|
observeActiveThreadsResult(
|
||||||
|
listener: ObservedPaginatedResultListener<readonly Thread[]>,
|
||||||
|
options: { emitCurrent?: boolean } = {},
|
||||||
|
): () => void {
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
return this.observeQueryResult(this.threadListQueryOptions("active"), cloneThreads, listener, options);
|
const observer = new InfiniteQueryObserver(this.client, {
|
||||||
|
...this.activeThreadsQueryOptions(),
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
const emit = (result: InfiniteQueryObserverResult<ActiveThreadData>): void => {
|
||||||
|
if (!this.disposed) listener(this.projectObservedActiveThreadsResult(result));
|
||||||
|
};
|
||||||
|
const unsubscribe = observer.subscribe(emit);
|
||||||
|
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
|
||||||
|
return this.trackObserver(() => {
|
||||||
|
unsubscribe();
|
||||||
|
observer.destroy();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options: { emitCurrent?: boolean } = {}): () => void {
|
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options: { emitCurrent?: boolean } = {}): () => void {
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
return this.observeQueryResult(this.threadListQueryOptions("archived"), cloneThreads, listener, options);
|
return this.observeQueryResult(this.archivedThreadsQueryOptions(), cloneThreads, listener, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchActiveThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
fetchActiveThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
||||||
this.assertUsable();
|
return this.runWhileActive(async () => {
|
||||||
return this.fetchThreadList("active", options);
|
const key = ACTIVE_THREADS_QUERY_KEY;
|
||||||
|
if (options.force) {
|
||||||
|
if (this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward") {
|
||||||
|
await this.client.cancelQueries({ queryKey: key, exact: true });
|
||||||
|
}
|
||||||
|
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||||
|
this.assertUsable();
|
||||||
|
}
|
||||||
|
return this.readThroughQueryCancellation(
|
||||||
|
async () => {
|
||||||
|
const data = await this.client.fetchInfiniteQuery(this.activeThreadsQueryOptions());
|
||||||
|
return cloneThreads(activeThreadsFromData(data) ?? []);
|
||||||
|
},
|
||||||
|
() => this.activeThreadsSnapshot(),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchArchivedThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
refreshActiveThreads(): Promise<readonly Thread[]> {
|
||||||
this.assertUsable();
|
|
||||||
return this.fetchThreadList("archived", options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshActiveThreads(): Promise<readonly Thread[]> {
|
|
||||||
return this.fetchActiveThreads({ force: true });
|
return this.fetchActiveThreads({ force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchAllActiveThreads(): Promise<readonly Thread[]> {
|
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]> {
|
||||||
this.assertUsable();
|
return this.runWhileActive(async () => {
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return [];
|
const key = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
|
||||||
const snapshot = this.activeThreadsSnapshot();
|
const options = {
|
||||||
if (snapshot && this.activeThreadCursorKnown && !this.activeThreadCursor) return snapshot;
|
queryKey: key,
|
||||||
for (let attempt = 0; attempt < FULL_ACTIVE_THREAD_FETCH_ATTEMPTS; attempt += 1) {
|
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||||
const revision = this.activeThreadRevision;
|
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { signal })),
|
||||||
const threads = await this.runWithClient((client) => listThreads(client, this.context.vaultPath));
|
};
|
||||||
if (this.activeThreadRevision !== revision) continue;
|
return this.readFreshThroughQueryCancellation(key, async () => cloneThreads(await this.client.fetchQuery(options)));
|
||||||
this.storeThreadList("active", threads);
|
});
|
||||||
this.rememberActiveThreadCursor(null);
|
|
||||||
return cloneThreads(threads);
|
|
||||||
}
|
|
||||||
throw new Error("Active thread inventory changed while it was being fetched.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasMoreActiveThreads(): boolean {
|
hasMoreActiveThreads(): boolean {
|
||||||
if (this.disposed) return false;
|
if (this.disposed) return false;
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return false;
|
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY));
|
||||||
return Boolean(this.activeThreadCursor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadMoreActiveThreads(): Promise<readonly Thread[]> {
|
loadMoreActiveThreads(): Promise<readonly Thread[]> {
|
||||||
this.assertUsable();
|
return this.runWhileActive(async () => {
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return [];
|
const current = this.activeThreadsSnapshot() ?? (await this.fetchActiveThreads());
|
||||||
const current = this.activeThreadsSnapshot() ?? (await this.fetchActiveThreads());
|
const observer = new InfiniteQueryObserver(this.client, {
|
||||||
const cursor = this.activeThreadCursor;
|
...this.activeThreadsQueryOptions(),
|
||||||
if (!cursor) return current;
|
enabled: false,
|
||||||
const revision = this.activeThreadRevision;
|
});
|
||||||
const page = await this.runWithClient((client) => readThreadPage(client, this.context.vaultPath, { cursor, archived: false }));
|
try {
|
||||||
if (page.nextCursor === cursor) throw new Error("Codex app-server returned a repeated thread list cursor.");
|
if (!observer.getCurrentResult().hasNextPage) return current;
|
||||||
if (this.activeThreadRevision !== revision) return this.activeThreadsSnapshot() ?? current;
|
const result = await observer.fetchNextPage({ cancelRefetch: false, throwOnError: true });
|
||||||
const latest = this.activeThreadsSnapshot() ?? current;
|
this.assertUsable();
|
||||||
const existingIds = new Set(latest.map((thread) => thread.id));
|
return result.data ? cloneThreads(activeThreadsFromData(result.data) ?? []) : current;
|
||||||
const threads = [...latest, ...page.threads.filter((thread) => !existingIds.has(thread.id))];
|
} catch (error) {
|
||||||
this.storeThreadList("active", threads);
|
if (error instanceof CancelledError) return this.activeThreadsSnapshot() ?? current;
|
||||||
this.rememberActiveThreadCursor(page.nextCursor);
|
throw error;
|
||||||
return cloneThreads(threads);
|
} finally {
|
||||||
|
observer.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshArchivedThreads(): Promise<readonly Thread[]> {
|
refreshArchivedThreads(): Promise<readonly Thread[]> {
|
||||||
return this.fetchArchivedThreads({ force: true });
|
return this.fetchArchivedThreads({ force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
private threadListSnapshot(kind: ThreadListKind): readonly Thread[] | null {
|
fetchArchivedThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return null;
|
return this.runWhileActive(async () => {
|
||||||
const threads = this.client.getQueryData<readonly Thread[]>(this.threadListQueryKey(kind));
|
const key = ARCHIVED_THREADS_QUERY_KEY;
|
||||||
return threads ? cloneThreads(threads) : null;
|
if (options.force) {
|
||||||
|
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||||
|
this.assertUsable();
|
||||||
|
}
|
||||||
|
return this.readFreshThroughQueryCancellation(key, async () =>
|
||||||
|
cloneThreads(await this.client.fetchQuery(this.archivedThreadsQueryOptions())),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchThreadList(kind: ThreadListKind, options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
applyThreadListMutations(mutations: readonly ThreadListMutation[]): void {
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return [];
|
|
||||||
const key = this.threadListQueryKey(kind);
|
|
||||||
if (options.force) {
|
|
||||||
await this.client.invalidateQueries({ queryKey: key });
|
|
||||||
this.assertUsable();
|
|
||||||
}
|
|
||||||
const threads = await this.client.fetchQuery(this.threadListQueryOptions(kind));
|
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
return cloneThreads(threads);
|
if (mutations.length === 0) return;
|
||||||
|
const activeMutations = mutations.filter((mutation) => mutation.list === "active");
|
||||||
|
if (activeMutations.length > 0) {
|
||||||
|
const key = ACTIVE_THREADS_QUERY_KEY;
|
||||||
|
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
|
||||||
|
const fetchIsNextPage = this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward";
|
||||||
|
void this.client.cancelQueries({ queryKey: key, exact: true });
|
||||||
|
const before = this.client.getQueryData<ActiveThreadData>(key);
|
||||||
|
const after = activeMutations.reduce<ActiveThreadData | undefined>(applyActiveThreadMutation, before);
|
||||||
|
if (after !== before) this.client.setQueryData(key, after);
|
||||||
|
void this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||||
|
const searchKey = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
|
||||||
|
void this.client.cancelQueries({ queryKey: searchKey, exact: true });
|
||||||
|
void this.client.invalidateQueries({ queryKey: searchKey, refetchType: "none" });
|
||||||
|
|
||||||
|
const refreshRequested = activeMutations.some((mutation) => mutation.kind === "refresh");
|
||||||
|
if ((wasFetching && !fetchIsNextPage) || (refreshRequested && before)) {
|
||||||
|
void this.fetchActiveThreads({ force: true }).catch(() => {
|
||||||
|
// Query observers retain refresh failures while the event projection remains last-known-good state.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const archivedMutations = mutations.filter((mutation) => mutation.list === "archived");
|
||||||
|
if (archivedMutations.length > 0) {
|
||||||
|
const key = ARCHIVED_THREADS_QUERY_KEY;
|
||||||
|
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
|
||||||
|
void this.client.cancelQueries({ queryKey: key, exact: true });
|
||||||
|
const before = this.archivedThreadsSnapshot();
|
||||||
|
const after = archivedMutations.reduce<readonly Thread[] | null>(applyThreadListMutation, before);
|
||||||
|
if (after !== before && after) this.client.setQueryData(key, cloneThreads(after));
|
||||||
|
void this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||||
|
|
||||||
|
const refreshRequested = archivedMutations.some((mutation) => mutation.kind === "refresh");
|
||||||
|
if (wasFetching || (refreshRequested && before)) {
|
||||||
|
void this.fetchArchivedThreads({ force: true }).catch(() => {
|
||||||
|
// Query observers retain refresh failures while the event projection remains last-known-good state.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private storeThreadList(kind: ThreadListKind, threads: readonly Thread[]): void {
|
private threadListSnapshot(kind: ThreadListKind): readonly Thread[] | null {
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return;
|
if (kind === "active") return this.activeThreadsSnapshot();
|
||||||
this.client.setQueryData(this.threadListQueryKey(kind), cloneThreads(threads));
|
const threads = this.client.getQueryData<readonly Thread[]>(ARCHIVED_THREADS_QUERY_KEY);
|
||||||
if (kind === "active") this.bumpActiveThreadRevision();
|
return threads ? cloneThreads(threads) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
appServerMetadataSnapshot(): SharedServerMetadata | null {
|
appServerMetadataSnapshot(): SharedServerMetadata | null {
|
||||||
if (this.disposed) return null;
|
if (this.disposed) return null;
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return null;
|
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(RUNTIME_CONFIG_QUERY_KEY);
|
||||||
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(appServerRuntimeConfigQueryKey(this.context));
|
|
||||||
if (!runtimeConfig) return null;
|
if (!runtimeConfig) return null;
|
||||||
const skills = this.metadataResourceState("skills");
|
const skills = this.metadataResourceState("skills");
|
||||||
const permissionProfiles = this.metadataResourceState("permissionProfiles");
|
const permissionProfiles = this.metadataResourceState("permissionProfiles");
|
||||||
|
|
@ -227,95 +281,112 @@ export class AppServerQueryCache {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
observeAppServerMetadataResult(
|
observeAppServerMetadataResources(
|
||||||
listener: ObservedResultListener<SharedServerMetadata>,
|
listener: (resource: SharedServerMetadataResource) => void,
|
||||||
options: { emitCurrent?: boolean } = {},
|
options: { emitCurrent?: boolean } = {},
|
||||||
): () => void {
|
): () => void {
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
const generation = this.generation;
|
let unsubscribed = false;
|
||||||
const observers = [
|
const emit = (resource: SharedServerMetadataResource): void => {
|
||||||
new QueryObserver(this.client, { ...this.runtimeConfigQueryOptions(), enabled: false }),
|
if (!this.disposed && !unsubscribed) listener(cloneSharedServerMetadataResource(resource));
|
||||||
new QueryObserver(this.client, { ...this.skillsQueryOptions(), enabled: false }),
|
};
|
||||||
new QueryObserver(this.client, { ...this.permissionProfilesQueryOptions(), enabled: false }),
|
|
||||||
new QueryObserver(this.client, { ...this.rateLimitsQueryOptions(), enabled: false }),
|
const runtimeConfig = new QueryObserver(this.client, { ...this.runtimeConfigQueryOptions(), enabled: false });
|
||||||
new QueryObserver(this.client, { ...this.modelsQueryOptions(), enabled: false }),
|
const models = new QueryObserver(this.client, { ...this.modelsQueryOptions(), enabled: false });
|
||||||
|
const skills = new QueryObserver(this.client, { ...this.skillsQueryOptions(), enabled: false });
|
||||||
|
const permissionProfiles = new QueryObserver(this.client, { ...this.permissionProfilesQueryOptions(), enabled: false });
|
||||||
|
const rateLimits = new QueryObserver(this.client, { ...this.rateLimitsQueryOptions(), enabled: false });
|
||||||
|
const emitRuntimeConfig = (result: QueryObserverResult<RuntimeConfigSnapshot>, includeFetching = false): void => {
|
||||||
|
if (result.isFetching && !includeFetching) return;
|
||||||
|
emit({ id: "runtimeConfig", value: result.data ? cloneRuntimeConfigSnapshot(result.data) : undefined });
|
||||||
|
};
|
||||||
|
const emitModels = (result: QueryObserverResult<readonly ModelMetadata[]>, includeFetching = false): void => {
|
||||||
|
if (result.isFetching && !includeFetching) return;
|
||||||
|
emit({ id: "models", value: result.data ? cloneModelMetadata(result.data) : undefined, probe: this.modelsProbe() });
|
||||||
|
};
|
||||||
|
const emitSkills = (result: QueryObserverResult<MetadataResourceSnapshot<readonly SkillMetadata[]>>, includeFetching = false): void => {
|
||||||
|
if (result.isFetching && !includeFetching) return;
|
||||||
|
const state = this.metadataResourceState("skills");
|
||||||
|
emit({ id: "skills", value: state.value ?? undefined, probe: state.probe });
|
||||||
|
};
|
||||||
|
const emitPermissionProfiles = (
|
||||||
|
result: QueryObserverResult<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>>,
|
||||||
|
includeFetching = false,
|
||||||
|
): void => {
|
||||||
|
if (result.isFetching && !includeFetching) return;
|
||||||
|
const state = this.metadataResourceState("permissionProfiles");
|
||||||
|
emit({ id: "permissionProfiles", value: state.value ?? undefined, probe: state.probe });
|
||||||
|
};
|
||||||
|
const emitRateLimits = (
|
||||||
|
result: QueryObserverResult<MetadataResourceSnapshot<RateLimitSnapshot | null>>,
|
||||||
|
includeFetching = false,
|
||||||
|
): void => {
|
||||||
|
if (result.isFetching && !includeFetching) return;
|
||||||
|
const state = this.metadataResourceState("rateLimits");
|
||||||
|
emit({ id: "rateLimits", value: state.value, probe: state.probe });
|
||||||
|
};
|
||||||
|
const unsubscribers = [
|
||||||
|
runtimeConfig.subscribe(emitRuntimeConfig),
|
||||||
|
models.subscribe(emitModels),
|
||||||
|
skills.subscribe(emitSkills),
|
||||||
|
permissionProfiles.subscribe(emitPermissionProfiles),
|
||||||
|
rateLimits.subscribe(emitRateLimits),
|
||||||
];
|
];
|
||||||
let queued = false;
|
if (options.emitCurrent ?? true) {
|
||||||
let disposed = false;
|
emitRuntimeConfig(runtimeConfig.getCurrentResult(), true);
|
||||||
const emit = (): void => {
|
emitModels(models.getCurrentResult(), true);
|
||||||
queued = false;
|
emitSkills(skills.getCurrentResult(), true);
|
||||||
if (disposed || generation !== this.generation) return;
|
emitPermissionProfiles(permissionProfiles.getCurrentResult(), true);
|
||||||
const metadata = this.appServerMetadataSnapshot();
|
emitRateLimits(rateLimits.getCurrentResult(), true);
|
||||||
const runtimeState = this.client.getQueryState(appServerRuntimeConfigQueryKey(this.context));
|
}
|
||||||
listener({
|
return this.trackObserver(() => {
|
||||||
value: metadata,
|
unsubscribed = true;
|
||||||
error: runtimeState?.error instanceof Error ? runtimeState.error : null,
|
|
||||||
isFetching: observers.some((observer) => observer.getCurrentResult().isFetching),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const schedule = (): void => {
|
|
||||||
if (disposed || generation !== this.generation) return;
|
|
||||||
if (this.metadataRefreshCount > 0 || queued) return;
|
|
||||||
queued = true;
|
|
||||||
queueMicrotask(emit);
|
|
||||||
};
|
|
||||||
this.metadataProjectionListeners.add(schedule);
|
|
||||||
const unsubscribers = observers.map((observer) => observer.subscribe(schedule));
|
|
||||||
if (options.emitCurrent ?? true) emit();
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
for (const unsubscribe of unsubscribers) unsubscribe();
|
for (const unsubscribe of unsubscribers) unsubscribe();
|
||||||
this.metadataProjectionListeners.delete(schedule);
|
runtimeConfig.destroy();
|
||||||
};
|
models.destroy();
|
||||||
|
skills.destroy();
|
||||||
|
permissionProfiles.destroy();
|
||||||
|
rateLimits.destroy();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshAppServerMetadata(): Promise<SharedServerMetadata | null> {
|
refreshAppServerMetadata(): Promise<void> {
|
||||||
this.assertUsable();
|
return this.runWhileActive(async () => {
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return null;
|
const runtimeResult = this.fetchRuntimeConfig().then(
|
||||||
return this.runMetadataRefresh(async () => {
|
|
||||||
const runtimeResult = this.fetchRuntimeConfig(true).then(
|
|
||||||
() => ({ ok: true as const }),
|
() => ({ ok: true as const }),
|
||||||
(error: unknown) => ({ ok: false as const, error }),
|
(error: unknown) => ({ ok: false as const, error }),
|
||||||
);
|
);
|
||||||
const [, runtime] = await Promise.all([
|
const [, runtime] = await Promise.all([
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
this.fetchMetadataResource("skills", true),
|
this.fetchMetadataResource("skills"),
|
||||||
this.fetchMetadataResource("permissionProfiles", true),
|
this.fetchMetadataResource("permissionProfiles"),
|
||||||
this.fetchMetadataResource("rateLimits", true),
|
this.fetchMetadataResource("rateLimits"),
|
||||||
this.fetchModels({ force: true }),
|
this.fetchModels({ force: true }),
|
||||||
]),
|
]),
|
||||||
runtimeResult,
|
runtimeResult,
|
||||||
]);
|
]);
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
if (!runtime.ok) throw runtime.error;
|
if (!runtime.ok) throw runtime.error;
|
||||||
return this.appServerMetadataSnapshot();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshSkills(): Promise<SharedServerMetadata | null> {
|
refreshSkills(): Promise<void> {
|
||||||
this.assertUsable();
|
return this.runWhileActive(async () => {
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return null;
|
await this.refreshNotifiedMetadataResource("skills");
|
||||||
return this.runMetadataRefresh(async () => {
|
|
||||||
const current = await this.refreshMetadataResourceNotification("skills");
|
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
return current ? this.appServerMetadataSnapshot() : null;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshRateLimits(): Promise<SharedServerMetadata | null> {
|
refreshRateLimits(): Promise<void> {
|
||||||
this.assertUsable();
|
return this.runWhileActive(async () => {
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return null;
|
await this.refreshNotifiedMetadataResource("rateLimits");
|
||||||
return this.runMetadataRefresh(async () => {
|
|
||||||
const current = await this.refreshMetadataResourceNotification("rateLimits");
|
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
return current ? this.appServerMetadataSnapshot() : null;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
modelsSnapshot(): readonly ModelMetadata[] | null {
|
modelsSnapshot(): readonly ModelMetadata[] | null {
|
||||||
if (this.disposed) return null;
|
if (this.disposed) return null;
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return null;
|
const models = this.client.getQueryData<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
|
||||||
const models = this.client.getQueryData<readonly ModelMetadata[]>(appServerModelsQueryKey(this.context));
|
|
||||||
return models ? cloneModelMetadata(models) : null;
|
return models ? cloneModelMetadata(models) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,167 +395,125 @@ export class AppServerQueryCache {
|
||||||
return this.observeQueryResult(this.modelsQueryOptions(), cloneModelMetadata, listener, options);
|
return this.observeQueryResult(this.modelsQueryOptions(), cloneModelMetadata, listener, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchModels(options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
|
fetchModels(options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
|
||||||
this.assertUsable();
|
return this.runWhileActive(async () => {
|
||||||
if (!appServerQueryContextIsComplete(this.context)) return [];
|
const key = MODELS_QUERY_KEY;
|
||||||
const key = appServerModelsQueryKey(this.context);
|
if (options.force) {
|
||||||
if (options.force) {
|
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||||
await this.client.invalidateQueries({ queryKey: key });
|
this.assertUsable();
|
||||||
|
}
|
||||||
|
const models = await this.client.fetchQuery(this.modelsQueryOptions());
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
}
|
return cloneModelMetadata(models);
|
||||||
const models = await this.client.fetchQuery(this.modelsQueryOptions());
|
});
|
||||||
this.assertUsable();
|
|
||||||
return cloneModelMetadata(models);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(): Promise<readonly ModelMetadata[]> {
|
refreshModels(): Promise<readonly ModelMetadata[]> {
|
||||||
return this.fetchModels({ force: true });
|
return this.fetchModels({ force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
private threadListQueryOptions(kind: ThreadListKind): AppServerQueryOptions<readonly Thread[]> {
|
private activeThreadsQueryOptions(): InfiniteQueryObserverOptions<
|
||||||
|
ThreadPage,
|
||||||
|
Error,
|
||||||
|
ActiveThreadData,
|
||||||
|
ActiveThreadsQueryKey,
|
||||||
|
ActiveThreadCursor
|
||||||
|
> {
|
||||||
return {
|
return {
|
||||||
queryKey: this.threadListQueryKey(kind),
|
queryKey: ACTIVE_THREADS_QUERY_KEY,
|
||||||
queryFn: async (): Promise<readonly Thread[]> => {
|
queryFn: async ({ pageParam, signal }) => {
|
||||||
if (kind === "active") {
|
signal.throwIfAborted();
|
||||||
const revision = this.activeThreadRevision;
|
const page = await this.runWithClient((client) =>
|
||||||
const page = await this.runWithClient((client) => readThreadPage(client, this.context.vaultPath, { archived: false }));
|
readThreadPage(client, this.context.vaultPath, { cursor: pageParam, archived: false }),
|
||||||
if (this.activeThreadRevision !== revision) return this.activeThreadsSnapshot() ?? [];
|
);
|
||||||
this.rememberActiveThreadCursor(page.nextCursor);
|
signal.throwIfAborted();
|
||||||
return cloneThreads(page.threads);
|
return page;
|
||||||
}
|
|
||||||
return cloneThreads(await this.runWithClient((client) => listThreads(client, this.context.vaultPath, { archived: true })));
|
|
||||||
},
|
},
|
||||||
staleTime: THREAD_LIST_STALE_TIME_MS,
|
initialPageParam: null,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private threadListQueryKey(kind: ThreadListKind): ReturnType<typeof activeThreadsQueryKey> | ReturnType<typeof archivedThreadsQueryKey> {
|
private archivedThreadsQueryOptions(): AppServerQueryOptions<readonly Thread[]> {
|
||||||
return kind === "archived" ? archivedThreadsQueryKey(this.context) : activeThreadsQueryKey(this.context);
|
return {
|
||||||
|
queryKey: ARCHIVED_THREADS_QUERY_KEY,
|
||||||
|
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||||
|
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { archived: true, signal })).then(cloneThreads),
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private runtimeConfigQueryOptions(): AppServerQueryOptions<RuntimeConfigSnapshot> {
|
private runtimeConfigQueryOptions(): AppServerQueryOptions<RuntimeConfigSnapshot> {
|
||||||
return {
|
return {
|
||||||
queryKey: appServerRuntimeConfigQueryKey(this.context),
|
queryKey: RUNTIME_CONFIG_QUERY_KEY,
|
||||||
queryFn: async (): Promise<RuntimeConfigSnapshot> =>
|
queryFn: async (): Promise<RuntimeConfigSnapshot> =>
|
||||||
this.runWithClient(async (client) =>
|
this.runWithClient(async (client) =>
|
||||||
runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, this.context.vaultPath)),
|
runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, this.context.vaultPath)),
|
||||||
),
|
),
|
||||||
staleTime: APP_SERVER_METADATA_STALE_TIME_MS,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private skillsQueryOptions(forceReload = false): AppServerQueryOptions<MetadataResourceSnapshot<readonly SkillMetadata[]>> {
|
private skillsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly SkillMetadata[]>> {
|
||||||
return {
|
return {
|
||||||
queryKey: appServerSkillsQueryKey(this.context),
|
queryKey: SKILLS_QUERY_KEY,
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
this.runWithClient(async (client) =>
|
this.runWithClient(async (client) => successfulMetadataResource(await readSkillMetadataProbe(client, this.context.vaultPath))),
|
||||||
successfulMetadataResource(await readSkillMetadataProbe(client, this.context.vaultPath, forceReload)),
|
|
||||||
),
|
|
||||||
staleTime: APP_SERVER_METADATA_STALE_TIME_MS,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private permissionProfilesQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>> {
|
private permissionProfilesQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>> {
|
||||||
return {
|
return {
|
||||||
queryKey: appServerPermissionProfilesQueryKey(this.context),
|
queryKey: PERMISSION_PROFILES_QUERY_KEY,
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
this.runWithClient(async (client) =>
|
this.runWithClient(async (client) =>
|
||||||
successfulMetadataResource(await readPermissionProfileMetadataProbe(client, this.context.vaultPath)),
|
successfulMetadataResource(await readPermissionProfileMetadataProbe(client, this.context.vaultPath)),
|
||||||
),
|
),
|
||||||
staleTime: APP_SERVER_METADATA_STALE_TIME_MS,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private rateLimitsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<RateLimitSnapshot | null>> {
|
private rateLimitsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<RateLimitSnapshot | null>> {
|
||||||
return {
|
return {
|
||||||
queryKey: appServerRateLimitsQueryKey(this.context),
|
queryKey: RATE_LIMITS_QUERY_KEY,
|
||||||
queryFn: async () => this.runWithClient(async (client) => successfulMetadataResource(await readRateLimitMetadataProbe(client))),
|
queryFn: async () => this.runWithClient(async (client) => successfulMetadataResource(await readRateLimitMetadataProbe(client))),
|
||||||
staleTime: APP_SERVER_METADATA_STALE_TIME_MS,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchRuntimeConfig(force: boolean): Promise<RuntimeConfigSnapshot> {
|
private async fetchRuntimeConfig(): Promise<RuntimeConfigSnapshot> {
|
||||||
const key = appServerRuntimeConfigQueryKey(this.context);
|
|
||||||
if (force) {
|
|
||||||
await this.client.invalidateQueries({ queryKey: key });
|
|
||||||
this.assertUsable();
|
|
||||||
}
|
|
||||||
const runtimeConfig = await this.client.fetchQuery(this.runtimeConfigQueryOptions());
|
const runtimeConfig = await this.client.fetchQuery(this.runtimeConfigQueryOptions());
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
return cloneRuntimeConfigSnapshot(runtimeConfig);
|
return cloneRuntimeConfigSnapshot(runtimeConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchMetadataResource(resource: MetadataResourceKind, force: boolean, reloadSkills = false): Promise<void> {
|
private fetchMetadataResource(resource: MetadataResourceKind): Promise<void> {
|
||||||
const refresh = (async (): Promise<void> => {
|
return (async (): Promise<void> => {
|
||||||
if (resource === "skills") {
|
if (resource === "skills") {
|
||||||
const options = this.skillsQueryOptions(reloadSkills);
|
const options = this.skillsQueryOptions();
|
||||||
if (force) {
|
|
||||||
await this.client.invalidateQueries({ queryKey: options.queryKey });
|
|
||||||
this.assertUsable();
|
|
||||||
}
|
|
||||||
await this.client.fetchQuery(options);
|
await this.client.fetchQuery(options);
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resource === "permissionProfiles") {
|
if (resource === "permissionProfiles") {
|
||||||
const options = this.permissionProfilesQueryOptions();
|
const options = this.permissionProfilesQueryOptions();
|
||||||
if (force) {
|
|
||||||
await this.client.invalidateQueries({ queryKey: options.queryKey });
|
|
||||||
this.assertUsable();
|
|
||||||
}
|
|
||||||
await this.client.fetchQuery(options);
|
await this.client.fetchQuery(options);
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const options = this.rateLimitsQueryOptions();
|
const options = this.rateLimitsQueryOptions();
|
||||||
if (force) {
|
|
||||||
await this.client.invalidateQueries({ queryKey: options.queryKey });
|
|
||||||
this.assertUsable();
|
|
||||||
}
|
|
||||||
await this.client.fetchQuery(options);
|
await this.client.fetchQuery(options);
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
})();
|
})();
|
||||||
const active = this.metadataResourceFetches.get(resource) ?? new Set<Promise<void>>();
|
|
||||||
active.add(refresh);
|
|
||||||
this.metadataResourceFetches.set(resource, active);
|
|
||||||
const cleanup = (): void => {
|
|
||||||
active.delete(refresh);
|
|
||||||
if (active.size === 0 && this.metadataResourceFetches.get(resource) === active) this.metadataResourceFetches.delete(resource);
|
|
||||||
};
|
|
||||||
void refresh.then(cleanup, cleanup);
|
|
||||||
return refresh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshMetadataResourceNotification(resource: MetadataNotificationResourceKind): Promise<boolean> {
|
private async refreshNotifiedMetadataResource(resource: "skills" | "rateLimits"): Promise<void> {
|
||||||
const generation = this.generation;
|
const queryKey = resource === "skills" ? SKILLS_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
|
||||||
const current = this.metadataNotificationRefreshes.get(resource);
|
await this.client.cancelQueries({ queryKey, exact: true });
|
||||||
if (current?.generation === generation) {
|
this.assertUsable();
|
||||||
current.dirty = true;
|
try {
|
||||||
return current.promise.then(() => generation === this.generation);
|
await this.fetchMetadataResource(resource);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof CancelledError)) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeFetches = [...(this.metadataResourceFetches.get(resource) ?? [])];
|
|
||||||
const refresh: MetadataNotificationRefresh = {
|
|
||||||
dirty: false,
|
|
||||||
generation,
|
|
||||||
promise: Promise.resolve(),
|
|
||||||
};
|
|
||||||
refresh.promise = (async (): Promise<void> => {
|
|
||||||
if (activeFetches.length > 0) await Promise.allSettled(activeFetches);
|
|
||||||
if (generation !== this.generation) return;
|
|
||||||
for (;;) {
|
|
||||||
refresh.dirty = false;
|
|
||||||
await this.fetchMetadataResource(resource, true, resource === "skills").catch(() => undefined);
|
|
||||||
if (generation !== this.generation) return;
|
|
||||||
if (!metadataNotificationRefreshIsDirty(refresh)) return;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
this.metadataNotificationRefreshes.set(resource, refresh);
|
|
||||||
const cleanup = (): void => {
|
|
||||||
if (this.metadataNotificationRefreshes.get(resource) === refresh) this.metadataNotificationRefreshes.delete(resource);
|
|
||||||
};
|
|
||||||
void refresh.promise.then(cleanup, cleanup);
|
|
||||||
return refresh.promise.then(() => generation === this.generation);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private metadataResourceState(resource: "skills"): { value: readonly SkillMetadata[] | null; probe: DiagnosticProbeResult };
|
private metadataResourceState(resource: "skills"): { value: readonly SkillMetadata[] | null; probe: DiagnosticProbeResult };
|
||||||
|
|
@ -495,11 +524,7 @@ export class AppServerQueryCache {
|
||||||
private metadataResourceState(resource: "rateLimits"): { value: RateLimitSnapshot | null; probe: DiagnosticProbeResult };
|
private metadataResourceState(resource: "rateLimits"): { value: RateLimitSnapshot | null; probe: DiagnosticProbeResult };
|
||||||
private metadataResourceState(resource: MetadataResourceKind): { value: MetadataResourceValue; probe: DiagnosticProbeResult } {
|
private metadataResourceState(resource: MetadataResourceKind): { value: MetadataResourceValue; probe: DiagnosticProbeResult } {
|
||||||
const key =
|
const key =
|
||||||
resource === "skills"
|
resource === "skills" ? SKILLS_QUERY_KEY : resource === "permissionProfiles" ? PERMISSION_PROFILES_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
|
||||||
? appServerSkillsQueryKey(this.context)
|
|
||||||
: resource === "permissionProfiles"
|
|
||||||
? appServerPermissionProfilesQueryKey(this.context)
|
|
||||||
: appServerRateLimitsQueryKey(this.context);
|
|
||||||
const state = this.client.getQueryState<MetadataResourceSnapshot<MetadataResourceValue>>(key);
|
const state = this.client.getQueryState<MetadataResourceSnapshot<MetadataResourceValue>>(key);
|
||||||
const failedProbe = diagnosticProbeFromError(state?.error);
|
const failedProbe = diagnosticProbeFromError(state?.error);
|
||||||
return {
|
return {
|
||||||
|
|
@ -509,7 +534,7 @@ export class AppServerQueryCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
private modelsProbe(): DiagnosticProbeResult {
|
private modelsProbe(): DiagnosticProbeResult {
|
||||||
const state = this.client.getQueryState<readonly ModelMetadata[]>(appServerModelsQueryKey(this.context));
|
const state = this.client.getQueryState<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
|
||||||
return (
|
return (
|
||||||
diagnosticProbeFromError(state?.error) ??
|
diagnosticProbeFromError(state?.error) ??
|
||||||
(state?.data
|
(state?.data
|
||||||
|
|
@ -518,22 +543,9 @@ export class AppServerQueryCache {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runMetadataRefresh<T>(operation: () => Promise<T>): Promise<T> {
|
|
||||||
const generation = this.generation;
|
|
||||||
this.metadataRefreshCount += 1;
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} finally {
|
|
||||||
if (generation === this.generation) {
|
|
||||||
this.metadataRefreshCount -= 1;
|
|
||||||
if (this.metadataRefreshCount === 0) for (const listener of this.metadataProjectionListeners) listener();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private modelsQueryOptions(): AppServerQueryOptions<readonly ModelMetadata[]> {
|
private modelsQueryOptions(): AppServerQueryOptions<readonly ModelMetadata[]> {
|
||||||
return {
|
return {
|
||||||
queryKey: appServerModelsQueryKey(this.context),
|
queryKey: MODELS_QUERY_KEY,
|
||||||
queryFn: async (): Promise<readonly ModelMetadata[]> => {
|
queryFn: async (): Promise<readonly ModelMetadata[]> => {
|
||||||
try {
|
try {
|
||||||
return cloneModelMetadata(
|
return cloneModelMetadata(
|
||||||
|
|
@ -549,68 +561,107 @@ export class AppServerQueryCache {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private observeQueryResult<T>(
|
private observeQueryResult<TQuery, TValue>(
|
||||||
queryOptions: AppServerQueryOptions<T>,
|
queryOptions: AppServerQueryOptions<TQuery>,
|
||||||
clone: (value: T) => T,
|
project: (value: TQuery) => TValue,
|
||||||
listener: ObservedResultListener<T>,
|
listener: ObservedResultListener<TValue>,
|
||||||
options: { emitCurrent?: boolean },
|
options: { emitCurrent?: boolean },
|
||||||
): () => void {
|
): () => void {
|
||||||
const observer = new QueryObserver<T>(this.client, {
|
const observer = new QueryObserver<TQuery>(this.client, {
|
||||||
...queryOptions,
|
...queryOptions,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
const emit = (result: QueryObserverResult<T>): void => {
|
const emit = (result: QueryObserverResult<TQuery>): void => {
|
||||||
listener(this.cloneObservedResult(result, clone));
|
if (!this.disposed) listener(this.projectObservedResult(result, project));
|
||||||
};
|
};
|
||||||
const unsubscribe = observer.subscribe(emit);
|
const unsubscribe = observer.subscribe(emit);
|
||||||
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
|
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
|
||||||
return unsubscribe;
|
return this.trackObserver(() => {
|
||||||
|
unsubscribe();
|
||||||
|
observer.destroy();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private cloneObservedResult<T>(result: QueryObserverResult<T>, clone: (value: T) => T): ObservedResult<T> {
|
private projectObservedResult<TQuery, TValue>(
|
||||||
|
result: QueryObserverResult<TQuery>,
|
||||||
|
project: (value: TQuery) => TValue,
|
||||||
|
): ObservedResult<TValue> {
|
||||||
return {
|
return {
|
||||||
value: result.data === undefined ? null : clone(result.data),
|
value: result.data === undefined ? null : project(result.data),
|
||||||
error: result.error instanceof Error ? result.error : null,
|
error: result.error instanceof Error ? result.error : null,
|
||||||
isFetching: result.isFetching,
|
isFetching: result.isFetching,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private projectObservedActiveThreadsResult(
|
||||||
|
result: InfiniteQueryObserverResult<ActiveThreadData>,
|
||||||
|
): ObservedPaginatedResult<readonly Thread[]> {
|
||||||
|
const threads = activeThreadsFromData(result.data);
|
||||||
|
return {
|
||||||
|
value: threads ? cloneThreads(threads) : null,
|
||||||
|
error: result.error instanceof Error ? result.error : null,
|
||||||
|
isFetching: result.isFetching,
|
||||||
|
hasMore: result.hasNextPage,
|
||||||
|
isFetchingNextPage: result.isFetchingNextPage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private runWithClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
|
private runWithClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
|
||||||
this.assertUsable();
|
this.assertUsable();
|
||||||
const generation = this.generation;
|
return this.clientAccess.withClient(operation, options);
|
||||||
const runner = this.clientRunner;
|
}
|
||||||
if (!runner) throw new Error("Codex app-server query client runner is not configured.");
|
|
||||||
return runner.runWithClient(this.context, operation, options).then(
|
private async readThroughQueryCancellation<T>(read: () => Promise<T>, fallback?: () => T | null): Promise<T> {
|
||||||
(result) => {
|
for (;;) {
|
||||||
if (this.disposed || generation !== this.generation) throw new DisposedAppServerQueryCacheError();
|
try {
|
||||||
return result;
|
const value = await read();
|
||||||
},
|
this.assertUsable();
|
||||||
(error: unknown) => {
|
return value;
|
||||||
if (this.disposed || generation !== this.generation) throw new DisposedAppServerQueryCacheError();
|
} catch (error) {
|
||||||
throw error;
|
if (!(error instanceof CancelledError)) throw error;
|
||||||
},
|
this.assertUsable();
|
||||||
);
|
const fallbackValue = fallback?.();
|
||||||
|
if (fallbackValue != null) return fallbackValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readFreshThroughQueryCancellation<T>(queryKey: readonly unknown[], read: () => Promise<T>): Promise<T> {
|
||||||
|
for (;;) {
|
||||||
|
const value = await this.readThroughQueryCancellation(read);
|
||||||
|
this.assertUsable();
|
||||||
|
if (!this.client.getQueryState(queryKey)?.isInvalidated) return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertUsable(): void {
|
private assertUsable(): void {
|
||||||
if (this.disposed) throw new DisposedAppServerQueryCacheError();
|
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||||
}
|
}
|
||||||
|
|
||||||
private rememberActiveThreadCursor(cursor: string | null): void {
|
private runWhileActive<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
this.activeThreadCursorKnown = true;
|
this.assertUsable();
|
||||||
this.activeThreadCursor = cursor;
|
return (async () => {
|
||||||
this.bumpActiveThreadRevision();
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
this.assertUsable();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bumpActiveThreadRevision(): void {
|
private trackObserver(unsubscribe: () => void): () => void {
|
||||||
this.activeThreadRevision += 1;
|
let subscribed = true;
|
||||||
}
|
const trackedUnsubscribe = (): void => {
|
||||||
}
|
if (!subscribed) return;
|
||||||
|
subscribed = false;
|
||||||
class DisposedAppServerQueryCacheError extends Error {
|
this.observerUnsubscribes.delete(trackedUnsubscribe);
|
||||||
constructor() {
|
unsubscribe();
|
||||||
super("Codex app-server query cache was disposed.");
|
};
|
||||||
this.name = "DisposedAppServerQueryCacheError";
|
this.observerUnsubscribes.add(trackedUnsubscribe);
|
||||||
|
return trackedUnsubscribe;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -630,17 +681,12 @@ function diagnosticProbeFromError(error: unknown): DiagnosticProbeResult | null
|
||||||
return error instanceof MetadataResourceQueryError ? error.probe : null;
|
return error instanceof MetadataResourceQueryError ? error.probe : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function metadataNotificationRefreshIsDirty(refresh: MetadataNotificationRefresh): boolean {
|
|
||||||
return refresh.dirty;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAppServerQueryClient(): QueryClient {
|
function createAppServerQueryClient(): QueryClient {
|
||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
gcTime: APP_SERVER_QUERY_GC_TIME_MS,
|
networkMode: "always",
|
||||||
retry: false,
|
retry: false,
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
export interface AppServerQueryContext {
|
|
||||||
codexPath: string;
|
|
||||||
vaultPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppServerContextLease {
|
|
||||||
readonly context: Readonly<AppServerQueryContext>;
|
|
||||||
readonly generation: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppServerQueryContextIdentity extends AppServerQueryContext {
|
|
||||||
readonly generation: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppServerQueryScope = readonly ["app-server", number, string, string];
|
|
||||||
export type AppServerActiveThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "active"];
|
|
||||||
export type AppServerArchivedThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "archived"];
|
|
||||||
export type AppServerModelsQueryKey = readonly [...AppServerQueryScope, "models"];
|
|
||||||
export type AppServerRuntimeConfigQueryKey = readonly [...AppServerQueryScope, "runtime-config"];
|
|
||||||
export type AppServerSkillsQueryKey = readonly [...AppServerQueryScope, "skills"];
|
|
||||||
export type AppServerPermissionProfilesQueryKey = readonly [...AppServerQueryScope, "permission-profiles"];
|
|
||||||
export type AppServerRateLimitsQueryKey = readonly [...AppServerQueryScope, "rate-limits"];
|
|
||||||
|
|
||||||
export function appServerQueryContextIsComplete(context: AppServerQueryContext): boolean {
|
|
||||||
return nonEmptyString(context.codexPath) && nonEmptyString(context.vaultPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneAppServerQueryContext(context: AppServerQueryContext): AppServerQueryContext {
|
|
||||||
return { ...context };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAppServerContextLease(context: AppServerQueryContext, generation: number): AppServerContextLease {
|
|
||||||
return Object.freeze({
|
|
||||||
context: Object.freeze(cloneAppServerQueryContext(context)),
|
|
||||||
generation,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerQueryContextIdentity(lease: AppServerContextLease): AppServerQueryContextIdentity {
|
|
||||||
return {
|
|
||||||
...lease.context,
|
|
||||||
generation: lease.generation,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cloneAppServerQueryContextIdentity(identity: AppServerQueryContextIdentity): AppServerQueryContextIdentity {
|
|
||||||
return { ...identity };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerQueryContextRawEquals(left: AppServerQueryContext, right: AppServerQueryContext): boolean {
|
|
||||||
return left.codexPath === right.codexPath && left.vaultPath === right.vaultPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appServerQueryContextMatches(left: AppServerQueryContext, right: AppServerQueryContext): boolean {
|
|
||||||
return appServerQueryContextIsComplete(left) && appServerQueryContextIsComplete(right) && appServerQueryContextRawEquals(left, right);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appServerQueryContextKey(context: AppServerQueryContext): string {
|
|
||||||
return `${context.codexPath}\u0000${context.vaultPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerQueryContextIdentityMatches(left: AppServerQueryContextIdentity, right: AppServerQueryContextIdentity): boolean {
|
|
||||||
return left.generation === right.generation && appServerQueryContextMatches(left, right);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerQueryContextIdentityKey(identity: AppServerQueryContextIdentity): string {
|
|
||||||
return `${String(identity.generation)}\u0000${appServerQueryContextKey(identity)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appServerQueryScope(context: AppServerQueryContextIdentity): AppServerQueryScope {
|
|
||||||
return ["app-server", context.generation, context.codexPath, context.vaultPath];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activeThreadsQueryKey(context: AppServerQueryContextIdentity): AppServerActiveThreadsQueryKey {
|
|
||||||
return [...appServerQueryScope(context), "threads", "active"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function archivedThreadsQueryKey(context: AppServerQueryContextIdentity): AppServerArchivedThreadsQueryKey {
|
|
||||||
return [...appServerQueryScope(context), "threads", "archived"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerModelsQueryKey(context: AppServerQueryContextIdentity): AppServerModelsQueryKey {
|
|
||||||
return [...appServerQueryScope(context), "models"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerRuntimeConfigQueryKey(context: AppServerQueryContextIdentity): AppServerRuntimeConfigQueryKey {
|
|
||||||
return [...appServerQueryScope(context), "runtime-config"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerSkillsQueryKey(context: AppServerQueryContextIdentity): AppServerSkillsQueryKey {
|
|
||||||
return [...appServerQueryScope(context), "skills"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerPermissionProfilesQueryKey(context: AppServerQueryContextIdentity): AppServerPermissionProfilesQueryKey {
|
|
||||||
return [...appServerQueryScope(context), "permission-profiles"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerRateLimitsQueryKey(context: AppServerQueryContextIdentity): AppServerRateLimitsQueryKey {
|
|
||||||
return [...appServerQueryScope(context), "rate-limits"];
|
|
||||||
}
|
|
||||||
|
|
||||||
function nonEmptyString(value: string): boolean {
|
|
||||||
return value.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
@ -16,16 +16,9 @@ export type SkillMetadataProbeResult = MetadataProbeResult<SkillMetadata[], "ski
|
||||||
export type PermissionProfileMetadataProbeResult = MetadataProbeResult<RuntimePermissionProfileSummary[], "permissionProfiles">;
|
export type PermissionProfileMetadataProbeResult = MetadataProbeResult<RuntimePermissionProfileSummary[], "permissionProfiles">;
|
||||||
export type RateLimitMetadataProbeResult = MetadataProbeResult<RateLimitSnapshot | null, "rateLimits">;
|
export type RateLimitMetadataProbeResult = MetadataProbeResult<RateLimitSnapshot | null, "rateLimits">;
|
||||||
|
|
||||||
export async function readSkillMetadataProbe(
|
export async function readSkillMetadataProbe(client: AppServerRequestClient, vaultPath: string): Promise<SkillMetadataProbeResult> {
|
||||||
client: AppServerRequestClient | null,
|
|
||||||
vaultPath: string,
|
|
||||||
forceReload = false,
|
|
||||||
): Promise<SkillMetadataProbeResult> {
|
|
||||||
if (!client) {
|
|
||||||
return { value: [], probe: diagnosticProbeError("skills", new Error("Codex app-server is not connected."), Date.now()) };
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const catalog = await listSkillCatalog(client, vaultPath, { forceReload });
|
const catalog = await listSkillCatalog(client, vaultPath);
|
||||||
return { value: catalog.skills, probe: diagnosticProbeOk("skills", `${String(catalog.totalCount)} skills`, Date.now()) };
|
return { value: catalog.skills, probe: diagnosticProbeOk("skills", `${String(catalog.totalCount)} skills`, Date.now()) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { value: [], probe: diagnosticProbeError("skills", error, Date.now()) };
|
return { value: [], probe: diagnosticProbeError("skills", error, Date.now()) };
|
||||||
|
|
@ -33,15 +26,9 @@ export async function readSkillMetadataProbe(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readPermissionProfileMetadataProbe(
|
export async function readPermissionProfileMetadataProbe(
|
||||||
client: AppServerRequestClient | null,
|
client: AppServerRequestClient,
|
||||||
vaultPath: string,
|
vaultPath: string,
|
||||||
): Promise<PermissionProfileMetadataProbeResult> {
|
): Promise<PermissionProfileMetadataProbeResult> {
|
||||||
if (!client) {
|
|
||||||
return {
|
|
||||||
value: [],
|
|
||||||
probe: diagnosticProbeError("permissionProfiles", new Error("Codex app-server is not connected."), Date.now()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const profiles = await listPermissionProfiles(client, vaultPath);
|
const profiles = await listPermissionProfiles(client, vaultPath);
|
||||||
return { value: profiles, probe: diagnosticProbeOk("permissionProfiles", `${String(profiles.length)} profiles`, Date.now()) };
|
return { value: profiles, probe: diagnosticProbeOk("permissionProfiles", `${String(profiles.length)} profiles`, Date.now()) };
|
||||||
|
|
@ -50,13 +37,7 @@ export async function readPermissionProfileMetadataProbe(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readRateLimitMetadataProbe(client: AppServerRequestClient | null): Promise<RateLimitMetadataProbeResult> {
|
export async function readRateLimitMetadataProbe(client: AppServerRequestClient): Promise<RateLimitMetadataProbeResult> {
|
||||||
if (!client) {
|
|
||||||
return {
|
|
||||||
value: null,
|
|
||||||
probe: diagnosticProbeError("rateLimits", new Error("Codex app-server is not connected."), Date.now()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const response = await readAccountRateLimits(client);
|
const response = await readAccountRateLimits(client);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,13 @@ export interface ObservedResult<T> {
|
||||||
|
|
||||||
export type ObservedResultListener<T> = (result: ObservedResult<T>) => void;
|
export type ObservedResultListener<T> = (result: ObservedResult<T>) => void;
|
||||||
|
|
||||||
|
export interface ObservedPaginatedResult<T> extends ObservedResult<T> {
|
||||||
|
readonly hasMore: boolean;
|
||||||
|
readonly isFetchingNextPage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObservedPaginatedResultListener<T> = (result: ObservedPaginatedResult<T>) => void;
|
||||||
|
|
||||||
export function observedInitialLoading<T>(result: ObservedResult<T>, currentValue: T | null | undefined): boolean {
|
export function observedInitialLoading<T>(result: ObservedResult<T>, currentValue: T | null | undefined): boolean {
|
||||||
return currentValue == null && result.isFetching;
|
return currentValue == null && result.isFetching;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
import type { ModelMetadata } from "../../domain/catalog/metadata";
|
|
||||||
import type { SharedServerMetadata } from "../../domain/server/metadata";
|
|
||||||
import type { Thread } from "../../domain/threads/model";
|
|
||||||
import { AppServerQueryCache, type AppServerQueryClientRunner } from "./cache";
|
|
||||||
import {
|
|
||||||
type AppServerContextLease,
|
|
||||||
type AppServerQueryContext,
|
|
||||||
type AppServerQueryContextIdentity,
|
|
||||||
appServerQueryContextIdentity,
|
|
||||||
appServerQueryContextIdentityKey,
|
|
||||||
appServerQueryContextIdentityMatches,
|
|
||||||
appServerQueryContextRawEquals,
|
|
||||||
createAppServerContextLease,
|
|
||||||
} from "./keys";
|
|
||||||
import type { ObservedResultListener } from "./observed-result";
|
|
||||||
|
|
||||||
export interface AppServerResourceStoreOptions {
|
|
||||||
cacheFactory?: (context: AppServerQueryContextIdentity) => AppServerQueryCache;
|
|
||||||
clientRunner?: AppServerQueryClientRunner;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StaleAppServerResourceContextError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Codex app-server resource context changed while loading.");
|
|
||||||
this.name = "StaleAppServerResourceContextError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isStaleAppServerResourceContextError(error: unknown): error is StaleAppServerResourceContextError {
|
|
||||||
return error instanceof StaleAppServerResourceContextError;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResourceObserver<T> {
|
|
||||||
readonly observe: (cache: AppServerQueryCache, listener: (value: T) => void, options: { emitCurrent?: boolean }) => () => void;
|
|
||||||
readonly listener: (value: T) => void;
|
|
||||||
readonly options: { emitCurrent?: boolean };
|
|
||||||
firstSubscribe: boolean;
|
|
||||||
unsubscribeQuery: (() => void) | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AppServerResourceStore {
|
|
||||||
private readonly observers = new Set<ResourceObserver<unknown>>();
|
|
||||||
private readonly cacheFactory: (context: AppServerQueryContextIdentity) => AppServerQueryCache;
|
|
||||||
private cache: AppServerQueryCache | null = null;
|
|
||||||
private lease: AppServerContextLease | null = null;
|
|
||||||
private generation = 0;
|
|
||||||
|
|
||||||
constructor(options: AppServerResourceStoreOptions = {}) {
|
|
||||||
this.cacheFactory =
|
|
||||||
options.cacheFactory ??
|
|
||||||
((context) => new AppServerQueryCache(context, options.clientRunner ? { clientRunner: options.clientRunner } : {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
initialize(context: AppServerQueryContext): AppServerContextLease {
|
|
||||||
if (this.lease) throw new Error("Codex app-server resource store is already initialized.");
|
|
||||||
this.lease = this.nextLease(context);
|
|
||||||
this.cache = this.cacheFactory(this.contextIdentity());
|
|
||||||
this.rebindObservers();
|
|
||||||
return this.lease;
|
|
||||||
}
|
|
||||||
|
|
||||||
replaceContext(context: AppServerQueryContext): AppServerContextLease {
|
|
||||||
if (!this.lease) throw new Error("Codex app-server resource store is not initialized.");
|
|
||||||
if (appServerQueryContextRawEquals(this.lease.context, context)) return this.lease;
|
|
||||||
this.unsubscribeObservers();
|
|
||||||
this.cache?.dispose();
|
|
||||||
this.lease = this.nextLease(context);
|
|
||||||
this.cache = this.cacheFactory(this.contextIdentity());
|
|
||||||
this.rebindObservers();
|
|
||||||
return this.lease;
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.unsubscribeObservers();
|
|
||||||
this.cache?.dispose();
|
|
||||||
this.cache = null;
|
|
||||||
this.lease = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
contextLease(): AppServerContextLease {
|
|
||||||
if (!this.lease) throw new Error("Codex app-server resource store is not initialized.");
|
|
||||||
return this.lease;
|
|
||||||
}
|
|
||||||
|
|
||||||
contextIdentity(): AppServerQueryContextIdentity {
|
|
||||||
return appServerQueryContextIdentity(this.contextLease());
|
|
||||||
}
|
|
||||||
|
|
||||||
contextKey(): string {
|
|
||||||
return appServerQueryContextIdentityKey(this.contextIdentity());
|
|
||||||
}
|
|
||||||
|
|
||||||
contextKeyFor(context: AppServerQueryContextIdentity): string {
|
|
||||||
return appServerQueryContextIdentityKey(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
activeThreadsSnapshot(): readonly Thread[] | null {
|
|
||||||
return this.cache?.activeThreadsSnapshot() ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
archivedThreadsSnapshot(): readonly Thread[] | null {
|
|
||||||
return this.cache?.archivedThreadsSnapshot() ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAllActiveThreads(): Promise<readonly Thread[]> {
|
|
||||||
return this.runForCurrentContext((cache) => cache.fetchAllActiveThreads());
|
|
||||||
}
|
|
||||||
|
|
||||||
hasMoreActiveThreads(): boolean {
|
|
||||||
return this.cache?.hasMoreActiveThreads() ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadMoreActiveThreads(): Promise<readonly Thread[]> {
|
|
||||||
return this.runForCurrentContext((cache) => cache.loadMoreActiveThreads());
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshActiveThreads(): Promise<readonly Thread[]> {
|
|
||||||
return this.runForCurrentContext((cache) => cache.refreshActiveThreads());
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshArchivedThreads(): Promise<readonly Thread[]> {
|
|
||||||
return this.runForCurrentContext((cache) => cache.refreshArchivedThreads());
|
|
||||||
}
|
|
||||||
|
|
||||||
observeActiveThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
|
|
||||||
return this.observeCurrentContext(
|
|
||||||
(cache, contextListener, observeOptions) => cache.observeActiveThreadsResult(contextListener, observeOptions),
|
|
||||||
listener,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
|
|
||||||
return this.observeCurrentContext(
|
|
||||||
(cache, contextListener, observeOptions) => cache.observeArchivedThreadsResult(contextListener, observeOptions),
|
|
||||||
listener,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
appServerMetadataSnapshot(): SharedServerMetadata | null {
|
|
||||||
return this.cache?.appServerMetadataSnapshot() ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshAppServerMetadata(): Promise<SharedServerMetadata | null> {
|
|
||||||
return this.runForCurrentContext((cache) => cache.refreshAppServerMetadata());
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshSkills(): Promise<SharedServerMetadata | null> {
|
|
||||||
return this.runForCurrentContext((cache) => cache.refreshSkills());
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshRateLimits(): Promise<SharedServerMetadata | null> {
|
|
||||||
return this.runForCurrentContext((cache) => cache.refreshRateLimits());
|
|
||||||
}
|
|
||||||
|
|
||||||
observeAppServerMetadataResult(listener: ObservedResultListener<SharedServerMetadata>, options?: { emitCurrent?: boolean }): () => void {
|
|
||||||
return this.observeCurrentContext(
|
|
||||||
(cache, contextListener, observeOptions) => cache.observeAppServerMetadataResult(contextListener, observeOptions),
|
|
||||||
listener,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
modelsSnapshot(): readonly ModelMetadata[] | null {
|
|
||||||
return this.cache?.modelsSnapshot() ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchModels(): Promise<readonly ModelMetadata[]> {
|
|
||||||
return this.runForCurrentContext((cache) => cache.fetchModels());
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshModels(): Promise<readonly ModelMetadata[]> {
|
|
||||||
return this.runForCurrentContext((cache) => cache.refreshModels());
|
|
||||||
}
|
|
||||||
|
|
||||||
observeModelsResult(listener: ObservedResultListener<readonly ModelMetadata[]>, options?: { emitCurrent?: boolean }): () => void {
|
|
||||||
return this.observeCurrentContext(
|
|
||||||
(cache, contextListener, observeOptions) => cache.observeModelsResult(contextListener, observeOptions),
|
|
||||||
listener,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private nextLease(context: AppServerQueryContext): AppServerContextLease {
|
|
||||||
return createAppServerContextLease(context, ++this.generation);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isCurrent(context: AppServerQueryContextIdentity): boolean {
|
|
||||||
return Boolean(this.lease && appServerQueryContextIdentityMatches(this.contextIdentity(), context));
|
|
||||||
}
|
|
||||||
|
|
||||||
private runForCurrentContext<T>(operation: (cache: AppServerQueryCache) => Promise<T>): Promise<T> {
|
|
||||||
const context = this.contextIdentity();
|
|
||||||
const cache = this.currentCache();
|
|
||||||
return this.runForIdentity(context, () => operation(cache));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runForIdentity<T>(context: AppServerQueryContextIdentity, operation: () => Promise<T>): Promise<T> {
|
|
||||||
if (!this.isCurrent(context)) throw new StaleAppServerResourceContextError();
|
|
||||||
let result: T;
|
|
||||||
try {
|
|
||||||
result = await operation();
|
|
||||||
} catch (error) {
|
|
||||||
if (!this.isCurrent(context)) throw new StaleAppServerResourceContextError();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (!this.isCurrent(context)) throw new StaleAppServerResourceContextError();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private observeCurrentContext<T>(
|
|
||||||
observe: ResourceObserver<T>["observe"],
|
|
||||||
listener: ResourceObserver<T>["listener"],
|
|
||||||
options: { emitCurrent?: boolean } = {},
|
|
||||||
): () => void {
|
|
||||||
const observer: ResourceObserver<T> = {
|
|
||||||
observe,
|
|
||||||
listener,
|
|
||||||
options,
|
|
||||||
firstSubscribe: true,
|
|
||||||
unsubscribeQuery: null,
|
|
||||||
};
|
|
||||||
this.observers.add(observer as ResourceObserver<unknown>);
|
|
||||||
this.bindObserver(observer);
|
|
||||||
return () => {
|
|
||||||
this.observers.delete(observer as ResourceObserver<unknown>);
|
|
||||||
observer.unsubscribeQuery?.();
|
|
||||||
observer.unsubscribeQuery = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private bindObserver<T>(observer: ResourceObserver<T>): void {
|
|
||||||
if (!this.lease || !this.cache) return;
|
|
||||||
const context = this.contextIdentity();
|
|
||||||
const cache = this.cache;
|
|
||||||
const observeOptions: { emitCurrent?: boolean } = {};
|
|
||||||
if (observer.firstSubscribe) {
|
|
||||||
if (observer.options.emitCurrent !== undefined) observeOptions.emitCurrent = observer.options.emitCurrent;
|
|
||||||
} else {
|
|
||||||
observeOptions.emitCurrent = true;
|
|
||||||
}
|
|
||||||
observer.unsubscribeQuery = observer.observe(
|
|
||||||
cache,
|
|
||||||
(value) => {
|
|
||||||
if (this.isCurrent(context)) observer.listener(value);
|
|
||||||
},
|
|
||||||
observeOptions,
|
|
||||||
);
|
|
||||||
observer.firstSubscribe = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private unsubscribeObservers(): void {
|
|
||||||
for (const observer of this.observers) {
|
|
||||||
observer.unsubscribeQuery?.();
|
|
||||||
observer.unsubscribeQuery = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private rebindObservers(): void {
|
|
||||||
for (const observer of this.observers) this.bindObserver(observer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private currentCache(): AppServerQueryCache {
|
|
||||||
if (!this.cache) throw new Error("Codex app-server resource store is not initialized.");
|
|
||||||
return this.cache;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { ModelMetadata } from "../../domain/catalog/metadata";
|
import type { ModelMetadata } from "../../domain/catalog/metadata";
|
||||||
import { cloneRuntimeConfigSnapshot } from "../../domain/runtime/config";
|
import { cloneRuntimeConfigSnapshot } from "../../domain/runtime/config";
|
||||||
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
|
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
|
||||||
import type { SharedServerMetadata } from "../../domain/server/metadata";
|
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../domain/server/metadata";
|
||||||
import { cloneToolInventorySnapshot } from "../../domain/server/tool-inventory";
|
import { cloneToolInventorySnapshot } from "../../domain/server/tool-inventory";
|
||||||
import type { Thread } from "../../domain/threads/model";
|
import type { Thread } from "../../domain/threads/model";
|
||||||
|
|
||||||
|
|
@ -34,6 +34,40 @@ export function cloneSharedServerMetadata(metadata: SharedServerMetadata): Share
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cloneSharedServerMetadataResource(resource: SharedServerMetadataResource): SharedServerMetadataResource {
|
||||||
|
switch (resource.id) {
|
||||||
|
case "runtimeConfig":
|
||||||
|
return {
|
||||||
|
id: resource.id,
|
||||||
|
value: resource.value ? cloneRuntimeConfigSnapshot(resource.value) : undefined,
|
||||||
|
};
|
||||||
|
case "models":
|
||||||
|
return {
|
||||||
|
id: resource.id,
|
||||||
|
value: resource.value ? cloneModelMetadata(resource.value) : undefined,
|
||||||
|
probe: { ...resource.probe },
|
||||||
|
};
|
||||||
|
case "skills":
|
||||||
|
return {
|
||||||
|
id: resource.id,
|
||||||
|
value: resource.value?.map((skill) => ({ ...skill })),
|
||||||
|
probe: { ...resource.probe },
|
||||||
|
};
|
||||||
|
case "permissionProfiles":
|
||||||
|
return {
|
||||||
|
id: resource.id,
|
||||||
|
value: resource.value?.map((profile) => ({ ...profile })),
|
||||||
|
probe: { ...resource.probe },
|
||||||
|
};
|
||||||
|
case "rateLimits":
|
||||||
|
return {
|
||||||
|
id: resource.id,
|
||||||
|
value: resource.value ? cloneRateLimitSnapshot(resource.value) : resource.value,
|
||||||
|
probe: { ...resource.probe },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cloneRateLimitSnapshot(snapshot: RateLimitSnapshot): RateLimitSnapshot {
|
function cloneRateLimitSnapshot(snapshot: RateLimitSnapshot): RateLimitSnapshot {
|
||||||
return {
|
return {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
|
|
|
||||||
31
src/app-server/query/thread-list-mutation.ts
Normal file
31
src/app-server/query/thread-list-mutation.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { Thread } from "../../domain/threads/model";
|
||||||
|
|
||||||
|
export type ThreadListKind = "active" | "archived";
|
||||||
|
|
||||||
|
export type ThreadListMutation =
|
||||||
|
| { readonly kind: "upsert"; readonly list: ThreadListKind; readonly thread: Thread }
|
||||||
|
| { readonly kind: "remove"; readonly list: ThreadListKind; readonly threadId: string }
|
||||||
|
| {
|
||||||
|
readonly kind: "update";
|
||||||
|
readonly list: ThreadListKind;
|
||||||
|
readonly threadId: string;
|
||||||
|
readonly changes: Partial<Pick<Thread, "name" | "recencyAt">>;
|
||||||
|
}
|
||||||
|
| { readonly kind: "refresh"; readonly list: ThreadListKind };
|
||||||
|
|
||||||
|
export function applyThreadListMutation(snapshot: readonly Thread[] | null, mutation: ThreadListMutation): readonly Thread[] | null {
|
||||||
|
switch (mutation.kind) {
|
||||||
|
case "refresh":
|
||||||
|
return snapshot;
|
||||||
|
case "upsert": {
|
||||||
|
if (!snapshot) return null;
|
||||||
|
const index = snapshot.findIndex((thread) => thread.id === mutation.thread.id);
|
||||||
|
if (index < 0) return [mutation.thread, ...snapshot];
|
||||||
|
return snapshot.map((thread) => (thread.id === mutation.thread.id ? mutation.thread : thread));
|
||||||
|
}
|
||||||
|
case "remove":
|
||||||
|
return snapshot?.filter((thread) => thread.id !== mutation.threadId) ?? null;
|
||||||
|
case "update":
|
||||||
|
return snapshot?.map((thread) => (thread.id === mutation.threadId ? { ...thread, ...mutation.changes } : thread)) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,7 @@ export interface EphemeralStructuredTurnClient {
|
||||||
|
|
||||||
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
|
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
|
||||||
|
|
||||||
export type EphemeralStructuredTurnClientFactory = (
|
type EphemeralStructuredTurnClientFactory = (
|
||||||
codexPath: string,
|
codexPath: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
handlers: AppServerClientHandlers,
|
handlers: AppServerClientHandlers,
|
||||||
|
|
@ -67,6 +67,12 @@ export type EphemeralStructuredTurnRunner = (options: RunEphemeralStructuredTurn
|
||||||
export interface EphemeralStructuredTurnDependencies {
|
export interface EphemeralStructuredTurnDependencies {
|
||||||
clientFactory?: EphemeralStructuredTurnClientFactory | undefined;
|
clientFactory?: EphemeralStructuredTurnClientFactory | undefined;
|
||||||
timers?: EphemeralStructuredTurnTimers | undefined;
|
timers?: EphemeralStructuredTurnTimers | undefined;
|
||||||
|
clientLifecycle?:
|
||||||
|
| {
|
||||||
|
created(client: EphemeralStructuredTurnClient): void;
|
||||||
|
disposed(client: EphemeralStructuredTurnClient): void;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runEphemeralStructuredTurn(
|
export async function runEphemeralStructuredTurn(
|
||||||
|
|
@ -131,6 +137,7 @@ export async function runEphemeralStructuredTurn(
|
||||||
abortOperation(new Error(options.exitedMessage));
|
abortOperation(new Error(options.exitedMessage));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
dependencies.clientLifecycle?.created(client);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runAbortable(client.connect());
|
await runAbortable(client.connect());
|
||||||
|
|
@ -175,8 +182,12 @@ export async function runEphemeralStructuredTurn(
|
||||||
} finally {
|
} finally {
|
||||||
state = completeEphemeralStructuredTurnState(state);
|
state = completeEphemeralStructuredTurnState(state);
|
||||||
timers.clearTimeout(timeout);
|
timers.clearTimeout(timeout);
|
||||||
await deleteEphemeralStructuredTurnThread(client, threadId);
|
try {
|
||||||
client.disconnect();
|
await deleteEphemeralStructuredTurnThread(client, threadId);
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
dependencies.clientLifecycle?.disposed(client);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../doma
|
||||||
import type { ThreadActivationSnapshot } from "../../domain/threads/activation";
|
import type { ThreadActivationSnapshot } from "../../domain/threads/activation";
|
||||||
import type { ArchiveThreadInput } from "../../domain/threads/archive-markdown";
|
import type { ArchiveThreadInput } from "../../domain/threads/archive-markdown";
|
||||||
import type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal";
|
import type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal";
|
||||||
import type { HistoricalTurn } from "../../domain/threads/history";
|
|
||||||
import type { Thread } from "../../domain/threads/model";
|
import type { Thread } from "../../domain/threads/model";
|
||||||
import { REFERENCED_THREAD_TURN_LIMIT } from "../../domain/threads/reference";
|
import { REFERENCED_THREAD_TURN_LIMIT } from "../../domain/threads/reference";
|
||||||
import type { TurnTranscriptSummary } from "../../domain/threads/transcript";
|
import type { TurnTranscriptSummary } from "../../domain/threads/transcript";
|
||||||
|
|
@ -23,8 +22,6 @@ import {
|
||||||
} from "../protocol/turn";
|
} from "../protocol/turn";
|
||||||
import type { AppServerRequestClient } from "./request-client";
|
import type { AppServerRequestClient } from "./request-client";
|
||||||
|
|
||||||
const THREAD_LIST_PAGE_LIMIT = 100;
|
|
||||||
|
|
||||||
export type ThreadTurnSortDirection = "asc" | "desc";
|
export type ThreadTurnSortDirection = "asc" | "desc";
|
||||||
|
|
||||||
interface TurnTranscriptSummaryPage {
|
interface TurnTranscriptSummaryPage {
|
||||||
|
|
@ -63,6 +60,7 @@ export interface AppServerThreadListOptions {
|
||||||
export interface ThreadPage {
|
export interface ThreadPage {
|
||||||
readonly threads: readonly Thread[];
|
readonly threads: readonly Thread[];
|
||||||
readonly nextCursor: string | null;
|
readonly nextCursor: string | null;
|
||||||
|
readonly fetchedSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startThread(
|
export function startThread(
|
||||||
|
|
@ -107,17 +105,21 @@ export function resumeThread(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listThreads(client: AppServerRequestClient, cwd: string, options: { archived?: boolean } = {}): Promise<Thread[]> {
|
export async function listThreads(
|
||||||
|
client: AppServerRequestClient,
|
||||||
|
cwd: string,
|
||||||
|
options: { archived?: boolean; signal?: AbortSignal } = {},
|
||||||
|
): Promise<Thread[]> {
|
||||||
const archived = options.archived ?? false;
|
const archived = options.archived ?? false;
|
||||||
const threads: Thread[] = [];
|
const threads: Thread[] = [];
|
||||||
const seenCursors = new Set<string>();
|
const seenCursors = new Set<string>();
|
||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
options.signal?.throwIfAborted();
|
||||||
const page = await readThreadPage(client, cwd, {
|
const page = await readThreadPage(client, cwd, {
|
||||||
archived,
|
archived,
|
||||||
cursor,
|
cursor,
|
||||||
limit: THREAD_LIST_PAGE_LIMIT,
|
|
||||||
});
|
});
|
||||||
threads.push(...page.threads);
|
threads.push(...page.threads);
|
||||||
|
|
||||||
|
|
@ -141,11 +143,11 @@ export async function readThreadPage(
|
||||||
const page = await readThreadRecordPage(client, cwd, {
|
const page = await readThreadRecordPage(client, cwd, {
|
||||||
...options,
|
...options,
|
||||||
archived,
|
archived,
|
||||||
limit: options.limit ?? THREAD_LIST_PAGE_LIMIT,
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
threads: threadsFromThreadRecords(page.data, { archived }),
|
threads: threadsFromThreadRecords(page.data, { archived }),
|
||||||
nextCursor: page.nextCursor ?? null,
|
nextCursor: page.nextCursor ?? null,
|
||||||
|
fetchedSize: page.data.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,47 +186,61 @@ export async function readReferencedThreadTurnTranscriptSummaries(
|
||||||
return chronologicalTurnTranscriptSummariesFromTurnRecords(response.data);
|
return chronologicalTurnTranscriptSummariesFromTurnRecords(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadRollbackSnapshot {
|
type ThreadForkPosition =
|
||||||
thread: Thread;
|
| { readonly kind: "through-turn"; readonly turnId: string }
|
||||||
cwd: string;
|
| { readonly kind: "before-turn"; readonly turnId: string };
|
||||||
turns: readonly HistoricalTurn[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThreadRollbackResult {
|
interface AppServerForkThreadOptions {
|
||||||
readonly thread: ThreadRecord & {
|
readonly position?: ThreadForkPosition;
|
||||||
readonly cwd: string;
|
readonly deferGoalContinuation?: boolean;
|
||||||
readonly turns: readonly HistoricalTurn[];
|
readonly runtime?: {
|
||||||
|
readonly model?: string;
|
||||||
|
readonly reasoningEffort?: string | null;
|
||||||
|
readonly serviceTier?: ServiceTier | null;
|
||||||
|
readonly approvalPolicy?: RuntimePermissionState["approvalPolicy"];
|
||||||
|
readonly approvalsReviewer?: ApprovalsReviewer;
|
||||||
|
readonly permissions?: string;
|
||||||
|
readonly sandboxPolicy?: RuntimePermissionState["sandboxPolicy"];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadRollbackSnapshotFromAppServerResponse(response: ThreadRollbackResult): ThreadRollbackSnapshot {
|
|
||||||
return {
|
|
||||||
thread: threadFromThreadRecord(response.thread),
|
|
||||||
cwd: response.thread.cwd,
|
|
||||||
turns: response.thread.turns,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rollbackThread(client: AppServerRequestClient, threadId: string, numTurns?: number): Promise<ThreadRollbackSnapshot> {
|
|
||||||
const response = await client.request("thread/rollback", { threadId, numTurns: numTurns ?? 1 });
|
|
||||||
return threadRollbackSnapshotFromAppServerResponse(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function forkThread(
|
export async function forkThread(
|
||||||
client: AppServerRequestClient,
|
client: AppServerRequestClient,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
lastTurnId: string | null = null,
|
options: AppServerForkThreadOptions = {},
|
||||||
): Promise<Thread> {
|
): Promise<Thread> {
|
||||||
|
const position = options.position;
|
||||||
|
const runtime = options.runtime;
|
||||||
|
const permissions = runtime?.permissions;
|
||||||
|
const sandbox = permissions === undefined ? appServerSandboxMode(runtime?.sandboxPolicy) : undefined;
|
||||||
const response = await client.request("thread/fork", {
|
const response = await client.request("thread/fork", {
|
||||||
threadId,
|
threadId,
|
||||||
cwd,
|
cwd,
|
||||||
excludeTurns: true,
|
excludeTurns: true,
|
||||||
...(lastTurnId ? { lastTurnId } : {}),
|
...(position?.kind === "through-turn" ? { lastTurnId: position.turnId } : {}),
|
||||||
|
...(position?.kind === "before-turn" ? { beforeTurnId: position.turnId } : {}),
|
||||||
|
...(options.deferGoalContinuation ? { deferGoalContinuation: true } : {}),
|
||||||
|
...(runtime?.model !== undefined ? { model: runtime.model } : {}),
|
||||||
|
...(runtime?.serviceTier !== undefined ? { serviceTier: runtime.serviceTier } : {}),
|
||||||
|
...(runtime?.approvalPolicy !== undefined && runtime.approvalPolicy !== null ? { approvalPolicy: runtime.approvalPolicy } : {}),
|
||||||
|
...(runtime?.approvalsReviewer !== undefined ? { approvalsReviewer: runtime.approvalsReviewer } : {}),
|
||||||
|
...(permissions !== undefined ? { permissions } : {}),
|
||||||
|
...(sandbox !== undefined ? { sandbox } : {}),
|
||||||
|
...(runtime?.reasoningEffort !== undefined ? { config: { model_reasoning_effort: runtime.reasoningEffort } } : {}),
|
||||||
});
|
});
|
||||||
return threadFromThreadRecord(response.thread);
|
return threadFromThreadRecord(response.thread);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appServerSandboxMode(
|
||||||
|
policy: RuntimePermissionState["sandboxPolicy"] | undefined,
|
||||||
|
): "read-only" | "workspace-write" | "danger-full-access" | undefined {
|
||||||
|
if (!policy || policy.type === "externalSandbox") return undefined;
|
||||||
|
if (policy.type === "dangerFullAccess") return "danger-full-access";
|
||||||
|
if (policy.type === "readOnly") return "read-only";
|
||||||
|
return "workspace-write";
|
||||||
|
}
|
||||||
|
|
||||||
export interface EphemeralThreadForkSnapshot {
|
export interface EphemeralThreadForkSnapshot {
|
||||||
readonly activation: ThreadActivationSnapshot;
|
readonly activation: ThreadActivationSnapshot;
|
||||||
readonly sourceThreadId: string;
|
readonly sourceThreadId: string;
|
||||||
|
|
@ -245,13 +261,12 @@ export class EphemeralThreadCleanupRequiredError extends Error {
|
||||||
export async function forkEphemeralThread(
|
export async function forkEphemeralThread(
|
||||||
client: AppServerRequestClient,
|
client: AppServerRequestClient,
|
||||||
sourceThreadId: string,
|
sourceThreadId: string,
|
||||||
cwd: string,
|
vaultPath: string,
|
||||||
): Promise<EphemeralThreadForkSnapshot> {
|
): Promise<EphemeralThreadForkSnapshot> {
|
||||||
const source = await client.request("thread/read", { threadId: sourceThreadId, includeTurns: false });
|
const config = await client.request("config/read", { cwd: vaultPath });
|
||||||
const config = await client.request("config/read", { cwd: source.thread.cwd });
|
|
||||||
const response = await client.request("thread/fork", {
|
const response = await client.request("thread/fork", {
|
||||||
threadId: sourceThreadId,
|
threadId: sourceThreadId,
|
||||||
cwd,
|
cwd: vaultPath,
|
||||||
ephemeral: true,
|
ephemeral: true,
|
||||||
sandbox: "read-only",
|
sandbox: "read-only",
|
||||||
approvalPolicy: "never",
|
approvalPolicy: "never",
|
||||||
|
|
@ -310,7 +325,6 @@ export function threadActivationSnapshotFromAppServerResponse(response: ThreadAc
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
thread: threadFromThreadRecord(response.thread),
|
thread: threadFromThreadRecord(response.thread),
|
||||||
cwd: response.cwd,
|
|
||||||
approvalPolicyKnown: "approvalPolicy" in response,
|
approvalPolicyKnown: "approvalPolicy" in response,
|
||||||
sandboxPolicyKnown: "sandbox" in response || "sandboxPolicy" in response,
|
sandboxPolicyKnown: "sandbox" in response || "sandboxPolicy" in response,
|
||||||
permissionProfileKnown: "activePermissionProfile" in response,
|
permissionProfileKnown: "activePermissionProfile" in response,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { CodexInput } from "../../domain/chat/input";
|
import type { CodexInput } from "../../domain/chat/input";
|
||||||
import type { ClientResponseByMethod } from "../connection/client";
|
import type { ClientResponseByMethod } from "../connection/client";
|
||||||
import type { ClientRequestParams } from "../connection/rpc-messages";
|
import type { ClientRequestParams } from "../connection/rpc-messages";
|
||||||
import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
|
import { appServerTurnInputFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
|
||||||
import type { AppServerRequestClient } from "./request-client";
|
import type { AppServerRequestClient } from "./request-client";
|
||||||
|
|
||||||
type AppServerTurnRuntimeOverrides = Partial<AppServerTurnRuntimeParams>;
|
type AppServerTurnRuntimeOverrides = Partial<AppServerTurnRuntimeParams>;
|
||||||
|
|
@ -32,14 +32,14 @@ export function startTurn(
|
||||||
options: AppServerStartTurnOptions,
|
options: AppServerStartTurnOptions,
|
||||||
): Promise<ClientResponseByMethod["turn/start"]> {
|
): Promise<ClientResponseByMethod["turn/start"]> {
|
||||||
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
|
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
|
||||||
const additionalContext = toAdditionalContext(input);
|
const prepared = toTurnInput(input, contextSubmissionId(clientUserMessageId, "user"));
|
||||||
const params: ClientRequestParams<"turn/start"> = {
|
const params: ClientRequestParams<"turn/start"> = {
|
||||||
threadId,
|
threadId,
|
||||||
cwd,
|
cwd,
|
||||||
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
||||||
...(additionalContext !== undefined ? { additionalContext } : {}),
|
...(prepared.additionalContext !== undefined ? { additionalContext: prepared.additionalContext } : {}),
|
||||||
...appServerTurnRuntimeParams(runtime),
|
...appServerTurnRuntimeParams(runtime),
|
||||||
input: toUserInput(input),
|
input: prepared.input,
|
||||||
};
|
};
|
||||||
return client.request("turn/start", params);
|
return client.request("turn/start", params);
|
||||||
}
|
}
|
||||||
|
|
@ -72,13 +72,13 @@ export function steerTurn(
|
||||||
input: string | CodexInput,
|
input: string | CodexInput,
|
||||||
clientUserMessageId?: string | null,
|
clientUserMessageId?: string | null,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const additionalContext = toAdditionalContext(input);
|
const prepared = toTurnInput(input, contextSubmissionId(clientUserMessageId, "steer"));
|
||||||
return client.request("turn/steer", {
|
return client.request("turn/steer", {
|
||||||
threadId,
|
threadId,
|
||||||
expectedTurnId,
|
expectedTurnId,
|
||||||
input: toUserInput(input),
|
input: prepared.input,
|
||||||
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
||||||
...(additionalContext !== undefined ? { additionalContext } : {}),
|
...(prepared.additionalContext !== undefined ? { additionalContext: prepared.additionalContext } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,14 +86,9 @@ export function interruptTurn(client: AppServerRequestClient, threadId: string,
|
||||||
return client.request("turn/interrupt", { threadId, turnId });
|
return client.request("turn/interrupt", { threadId, turnId });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toUserInput(input: string | CodexInput): ClientRequestParams<"turn/start">["input"] {
|
function toTurnInput(input: string | CodexInput, submissionId: string) {
|
||||||
if (typeof input !== "string") return toAppServerUserInput(input);
|
if (typeof input !== "string") return appServerTurnInputFromCodexInput(input, submissionId);
|
||||||
return toAppServerUserInput([{ type: "text", text: input }]);
|
return { input: toAppServerUserInput([{ type: "text", text: input }]) };
|
||||||
}
|
|
||||||
|
|
||||||
function toAdditionalContext(input: string | CodexInput): ClientRequestParams<"turn/start">["additionalContext"] | undefined {
|
|
||||||
if (typeof input === "string") return undefined;
|
|
||||||
return additionalContextFromCodexInput(input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams {
|
function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams {
|
||||||
|
|
@ -105,3 +100,11 @@ function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | und
|
||||||
if (runtime?.approvalsReviewer !== undefined) params.approvalsReviewer = runtime.approvalsReviewer;
|
if (runtime?.approvalsReviewer !== undefined) params.approvalsReviewer = runtime.approvalsReviewer;
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fallbackContextSubmissionSequence = 0;
|
||||||
|
|
||||||
|
function contextSubmissionId(clientUserMessageId: string | null | undefined, kind: "user" | "steer"): string {
|
||||||
|
if (clientUserMessageId) return clientUserMessageId;
|
||||||
|
fallbackContextSubmissionSequence += 1;
|
||||||
|
return `local-${kind}-${String(Date.now())}-fallback-${String(fallbackContextSubmissionSequence)}-1`;
|
||||||
|
}
|
||||||
|
|
|
||||||
53
src/domain/chat/context-budget.ts
Normal file
53
src/domain/chat/context-budget.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
export function utf8ByteLength(value: string): number {
|
||||||
|
return encoder.encode(value).byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitUtf8Context(value: string, maxBytes: number, maxParts: number): { parts: string[]; includedBytes: number } {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let rest = value;
|
||||||
|
while (rest && parts.length < maxParts) {
|
||||||
|
const prefix = utf8Prefix(rest, maxBytes);
|
||||||
|
if (!prefix) break;
|
||||||
|
const boundary = preferredBoundary(prefix, rest.length > prefix.length);
|
||||||
|
const part = prefix.slice(0, boundary);
|
||||||
|
parts.push(part);
|
||||||
|
rest = rest.slice(part.length);
|
||||||
|
}
|
||||||
|
return { parts, includedBytes: parts.reduce((total, part) => total + utf8ByteLength(part), 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateUtf8(value: string, maxBytes: number): string {
|
||||||
|
return utf8Prefix(value, maxBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utf8Prefix(value: string, maxBytes: number): string {
|
||||||
|
if (maxBytes <= 0 || !value) return "";
|
||||||
|
if (utf8ByteLength(value) <= maxBytes) return value;
|
||||||
|
let low = 0;
|
||||||
|
let high = value.length;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = Math.ceil((low + high) / 2);
|
||||||
|
const candidate = safeCodeUnitBoundary(value, middle);
|
||||||
|
if (utf8ByteLength(value.slice(0, candidate)) <= maxBytes) low = middle;
|
||||||
|
else high = middle - 1;
|
||||||
|
}
|
||||||
|
return value.slice(0, safeCodeUnitBoundary(value, low));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCodeUnitBoundary(value: string, index: number): number {
|
||||||
|
if (index <= 0 || index >= value.length) return index;
|
||||||
|
const code = value.charCodeAt(index);
|
||||||
|
return code >= 0xdc00 && code <= 0xdfff ? index - 1 : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferredBoundary(prefix: string, hasRemainder: boolean): number {
|
||||||
|
if (!hasRemainder) return prefix.length;
|
||||||
|
const minimum = Math.floor(prefix.length / 2);
|
||||||
|
for (const marker of ["\n\n", "\n", " "]) {
|
||||||
|
const index = prefix.lastIndexOf(marker);
|
||||||
|
if (index >= minimum) return index + marker.length;
|
||||||
|
}
|
||||||
|
return prefix.length;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
export interface RequestMention {
|
export interface VaultFileReference {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ACTIVE_FILE_MENTION_NAME = "<active>";
|
export interface SkillReference {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACTIVE_FILE_REFERENCE_NAME = "<active>";
|
||||||
|
|
||||||
export interface RequestAdditionalContext {
|
export interface RequestAdditionalContext {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -16,8 +21,13 @@ export type CodexInputItem =
|
||||||
| { type: "image"; url: string; detail?: UserInputImageDetail }
|
| { type: "image"; url: string; detail?: UserInputImageDetail }
|
||||||
| { type: "localImage"; path: string; detail?: UserInputImageDetail }
|
| { type: "localImage"; path: string; detail?: UserInputImageDetail }
|
||||||
| { type: "skill"; name: string; path: string }
|
| { type: "skill"; name: string; path: string }
|
||||||
| { type: "mention"; name: string; path: string }
|
| { type: "fileReference"; name: string; path: string }
|
||||||
| { type: "additionalContext"; key: string; value: string; kind: RequestAdditionalContext["kind"] };
|
| {
|
||||||
|
type: "additionalContext";
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
kind: RequestAdditionalContext["kind"];
|
||||||
|
};
|
||||||
|
|
||||||
export type CodexInput = CodexInputItem[];
|
export type CodexInput = CodexInputItem[];
|
||||||
type UserInputImageDetail = "auto" | "low" | "high" | "original";
|
type UserInputImageDetail = "auto" | "low" | "high" | "original";
|
||||||
|
|
@ -26,15 +36,15 @@ export function codexTextInput(text: string): CodexInput {
|
||||||
return [{ type: "text", text }];
|
return [{ type: "text", text }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function codexTextInputWithMentions(
|
export function codexTextInputWithReferences(
|
||||||
text: string,
|
text: string,
|
||||||
mentions: readonly RequestMention[],
|
fileReferences: readonly VaultFileReference[],
|
||||||
skills: readonly RequestMention[] = [],
|
skills: readonly SkillReference[] = [],
|
||||||
additionalContext: readonly RequestAdditionalContext[] = [],
|
additionalContext: readonly RequestAdditionalContext[] = [],
|
||||||
): CodexInput {
|
): CodexInput {
|
||||||
return [
|
return [
|
||||||
...codexTextInput(text),
|
...codexTextInput(text),
|
||||||
...mentions.map((mention) => ({ type: "mention" as const, name: mention.name, path: mention.path })),
|
...fileReferences.map((reference) => ({ type: "fileReference" as const, name: reference.name, path: reference.path })),
|
||||||
...skills.map((skill) => ({ type: "skill" as const, name: skill.name, path: skill.path })),
|
...skills.map((skill) => ({ type: "skill" as const, name: skill.name, path: skill.path })),
|
||||||
...additionalContext.map((context) => ({
|
...additionalContext.map((context) => ({
|
||||||
type: "additionalContext" as const,
|
type: "additionalContext" as const,
|
||||||
|
|
|
||||||
10
src/domain/chat/submission-id.ts
Normal file
10
src/domain/chat/submission-id.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
const PANEL_SUBMISSION_CLIENT_ID_PATTERN = /^local-(?:user|steer)-\d+-[A-Za-z0-9_-]+-[a-z0-9]+-[a-z0-9]+$/;
|
||||||
|
|
||||||
|
export function isPanelSubmissionClientId(value: string | null | undefined): value is string {
|
||||||
|
return typeof value === "string" && PANEL_SUBMISSION_CLIENT_ID_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function turnContextSubmissionId(value: string): string {
|
||||||
|
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
|
||||||
|
return safe || "context";
|
||||||
|
}
|
||||||
19
src/domain/markdown/code-ranges.ts
Normal file
19
src/domain/markdown/code-ranges.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||||
|
import { visit } from "unist-util-visit";
|
||||||
|
|
||||||
|
export type MarkdownCodeRange = readonly [start: number, end: number];
|
||||||
|
|
||||||
|
export function markdownCodeRanges(markdown: string): MarkdownCodeRange[] {
|
||||||
|
const ranges: MarkdownCodeRange[] = [];
|
||||||
|
visit(fromMarkdown(markdown), (node) => {
|
||||||
|
if (node.type !== "code" && node.type !== "inlineCode") return;
|
||||||
|
const start = node.position?.start.offset;
|
||||||
|
const end = node.position?.end.offset;
|
||||||
|
if (start !== undefined && end !== undefined) ranges.push([start, end]);
|
||||||
|
});
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownCodeRangeContainsOffset(ranges: readonly MarkdownCodeRange[], offset: number): boolean {
|
||||||
|
return ranges.some(([start, end]) => offset >= start && offset < end);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
export function yamlFrontmatterString(value: string): string {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function yamlFrontmatterInlineList(values: readonly string[]): string {
|
|
||||||
return `[${values.map(yamlFrontmatterString).join(", ")}]`;
|
|
||||||
}
|
|
||||||
|
|
@ -11,24 +11,10 @@ interface PendingRequestQueueSource {
|
||||||
readonly pendingMcpElicitations: readonly unknown[];
|
readonly pendingMcpElicitations: readonly unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingRequestCountSource {
|
|
||||||
readonly pendingApprovals: number;
|
|
||||||
readonly pendingUserInputs: number;
|
|
||||||
readonly pendingMcpElicitations: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pendingRequestCountsFromQueues(source: PendingRequestQueueSource): PendingRequestCounts {
|
export function pendingRequestCountsFromQueues(source: PendingRequestQueueSource): PendingRequestCounts {
|
||||||
return pendingRequestCounts({
|
const approvals = source.approvals.length;
|
||||||
pendingApprovals: source.approvals.length,
|
const userInputs = source.pendingUserInputs.length;
|
||||||
pendingUserInputs: source.pendingUserInputs.length,
|
const mcpElicitations = source.pendingMcpElicitations.length;
|
||||||
pendingMcpElicitations: source.pendingMcpElicitations.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pendingRequestCounts(source: PendingRequestCountSource): PendingRequestCounts {
|
|
||||||
const approvals = source.pendingApprovals;
|
|
||||||
const userInputs = source.pendingUserInputs;
|
|
||||||
const mcpElicitations = source.pendingMcpElicitations;
|
|
||||||
return {
|
return {
|
||||||
approvals,
|
approvals,
|
||||||
userInputs,
|
userInputs,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export interface RuntimeConfigSnapshot {
|
||||||
readonly autoCompactTokenLimit: number | null;
|
readonly autoCompactTokenLimit: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot {
|
function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot {
|
||||||
return {
|
return {
|
||||||
profile: null,
|
profile: null,
|
||||||
model: null,
|
model: null,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ export interface CollaborationMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeSettingsPatch {
|
export interface RuntimeSettingsPatch {
|
||||||
cwd?: string | null;
|
|
||||||
approvalPolicy?: RuntimeApprovalPolicy | null;
|
approvalPolicy?: RuntimeApprovalPolicy | null;
|
||||||
approvalsReviewer?: ApprovalsReviewer | null;
|
approvalsReviewer?: ApprovalsReviewer | null;
|
||||||
permissions?: string | null;
|
permissions?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -125,12 +125,6 @@ export function upsertMcpServerDiagnostic(diagnostics: Diagnostics, server: McpS
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertMcpServerStatusDiagnostics(diagnostics: Diagnostics, servers: readonly McpServerStatusSummary[]): Diagnostics {
|
|
||||||
let next = diagnostics;
|
|
||||||
for (const server of servers) next = upsertMcpServerDiagnostic(next, mcpServerDiagnosticFromStatus(server));
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replaceMcpServerStatusDiagnostics(diagnostics: Diagnostics, servers: readonly McpServerStatusSummary[]): Diagnostics {
|
export function replaceMcpServerStatusDiagnostics(diagnostics: Diagnostics, servers: readonly McpServerStatusSummary[]): Diagnostics {
|
||||||
return {
|
return {
|
||||||
...diagnostics,
|
...diagnostics,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { SkillMetadata } from "../catalog/metadata";
|
import type { ModelMetadata, SkillMetadata } from "../catalog/metadata";
|
||||||
import type { RuntimeConfigSnapshot } from "../runtime/config";
|
import type { RuntimeConfigSnapshot } from "../runtime/config";
|
||||||
import type { RateLimitSnapshot } from "../runtime/metrics";
|
import type { RateLimitSnapshot } from "../runtime/metrics";
|
||||||
import type { RuntimePermissionProfileSummary } from "../runtime/permissions";
|
import type { RuntimePermissionProfileSummary } from "../runtime/permissions";
|
||||||
import type { Diagnostics } from "./diagnostics";
|
import type { DiagnosticProbeResult, Diagnostics } from "./diagnostics";
|
||||||
|
|
||||||
export interface SharedServerMetadata {
|
export interface SharedServerMetadata {
|
||||||
runtimeConfig: RuntimeConfigSnapshot | null;
|
runtimeConfig: RuntimeConfigSnapshot | null;
|
||||||
|
|
@ -11,3 +11,26 @@ export interface SharedServerMetadata {
|
||||||
rateLimit: RateLimitSnapshot | null;
|
rateLimit: RateLimitSnapshot | null;
|
||||||
serverDiagnostics: Diagnostics;
|
serverDiagnostics: Diagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SharedServerMetadataResource =
|
||||||
|
| { readonly id: "runtimeConfig"; readonly value: RuntimeConfigSnapshot | undefined }
|
||||||
|
| {
|
||||||
|
readonly id: "models";
|
||||||
|
readonly value: readonly ModelMetadata[] | undefined;
|
||||||
|
readonly probe: DiagnosticProbeResult;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: "skills";
|
||||||
|
readonly value: readonly SkillMetadata[] | undefined;
|
||||||
|
readonly probe: DiagnosticProbeResult;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: "permissionProfiles";
|
||||||
|
readonly value: readonly RuntimePermissionProfileSummary[] | undefined;
|
||||||
|
readonly probe: DiagnosticProbeResult;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: "rateLimits";
|
||||||
|
readonly value: RateLimitSnapshot | null | undefined;
|
||||||
|
readonly probe: DiagnosticProbeResult;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import type { Thread } from "./model";
|
||||||
|
|
||||||
export interface ThreadActivationSnapshot extends RuntimePermissionState, RuntimePermissionKnownState {
|
export interface ThreadActivationSnapshot extends RuntimePermissionState, RuntimePermissionKnownState {
|
||||||
thread: Thread;
|
thread: Thread;
|
||||||
cwd: string;
|
|
||||||
model: string | null;
|
model: string | null;
|
||||||
serviceTier: ServiceTier | null;
|
serviceTier: ServiceTier | null;
|
||||||
approvalsReviewer: ApprovalsReviewer | null;
|
approvalsReviewer: ApprovalsReviewer | null;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../markdown/frontmatter";
|
import type { Link, Nodes } from "mdast";
|
||||||
import { isExternalFileHref, parseFileHref } from "../vault/file-hrefs";
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||||
|
import { toMarkdown } from "mdast-util-to-markdown";
|
||||||
|
import { visit } from "unist-util-visit";
|
||||||
|
|
||||||
|
import { parseFileHref } from "../vault/file-hrefs";
|
||||||
import { isFilesystemAbsolutePath, isVaultConfigPath, normalizeFilePath, vaultRelativePath } from "../vault/paths";
|
import { isFilesystemAbsolutePath, isVaultConfigPath, normalizeFilePath, vaultRelativePath } from "../vault/paths";
|
||||||
import type { Thread } from "./model";
|
import type { Thread } from "./model";
|
||||||
import { referencedThreadMetadataFromPrompt } from "./reference";
|
|
||||||
import { threadArchiveTitle } from "./title";
|
import { threadArchiveTitle } from "./title";
|
||||||
import type { ThreadTranscriptEntry } from "./transcript";
|
import type { ThreadTranscriptEntry } from "./transcript";
|
||||||
|
|
||||||
interface ParsedMarkdownLink {
|
interface MarkdownSourceReplacement {
|
||||||
raw: string;
|
start: number;
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
end: number;
|
end: number;
|
||||||
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArchiveExportSettings {
|
export interface ArchiveMarkdownOptions {
|
||||||
archiveExportFolderTemplate: string;
|
|
||||||
archiveExportFilenameTemplate: string;
|
|
||||||
archiveExportTags?: string;
|
archiveExportTags?: string;
|
||||||
vaultPath?: string;
|
vaultPath?: string;
|
||||||
vaultConfigDir?: string;
|
vaultConfigDir?: string;
|
||||||
|
|
@ -25,56 +25,31 @@ export interface ArchiveThreadInput extends Thread {
|
||||||
transcriptEntries: readonly ThreadTranscriptEntry[];
|
transcriptEntries: readonly ThreadTranscriptEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function archivedThreadMarkdown(
|
export function archivedThreadMarkdown(thread: ArchiveThreadInput, exportedAt = new Date(), settings: ArchiveMarkdownOptions = {}): string {
|
||||||
thread: ArchiveThreadInput,
|
|
||||||
exportedAt = new Date(),
|
|
||||||
settings?: Partial<ArchiveExportSettings>,
|
|
||||||
): string {
|
|
||||||
const title = threadArchiveTitle(thread);
|
const title = threadArchiveTitle(thread);
|
||||||
const tags = normalizedArchiveTags(settings?.archiveExportTags ?? "");
|
const tags = normalizedArchiveTags(settings.archiveExportTags ?? "");
|
||||||
const lines = [
|
const frontmatter = [
|
||||||
"---",
|
"---",
|
||||||
`title: ${yamlFrontmatterString(title)}`,
|
`title: ${yamlFrontmatterString(title)}`,
|
||||||
`thread_id: ${yamlFrontmatterString(thread.id)}`,
|
`thread_id: ${yamlFrontmatterString(thread.id)}`,
|
||||||
`created: ${yamlFrontmatterString(formatDate(exportedAt))}`,
|
`created: ${yamlFrontmatterString(formatDate(exportedAt))}`,
|
||||||
...frontmatterTagsLines(tags),
|
...frontmatterTagsLines(tags),
|
||||||
"---",
|
"---",
|
||||||
"",
|
].join("\n");
|
||||||
`# ${title}`,
|
const body = `${trimTrailingBlankLines([`# ${title}`, "", ...transcriptMarkdownLines(thread.transcriptEntries)]).join("\n")}\n`;
|
||||||
"",
|
const normalizedBody = settings.vaultPath ? normalizeExportedMarkdownLinks(body, settings.vaultPath, settings.vaultConfigDir) : body;
|
||||||
...transcriptMarkdownLines(thread.transcriptEntries),
|
return `${frontmatter}\n\n${normalizedBody}`;
|
||||||
];
|
|
||||||
const markdown = `${trimTrailingBlankLines(lines).join("\n")}\n`;
|
|
||||||
return settings?.vaultPath ? normalizeExportedMarkdownLinks(markdown, settings.vaultPath, settings.vaultConfigDir) : markdown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
|
function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
|
||||||
const lines = markdown.split("\n");
|
const replacements: MarkdownSourceReplacement[] = [];
|
||||||
let inFence = false;
|
visit(fromMarkdown(markdown), "link", (link) => {
|
||||||
return lines
|
const replacement = normalizedExportedMarkdownLink(link, vaultPath, vaultConfigDir);
|
||||||
.map((line) => {
|
if (replacement) replacements.push(replacement);
|
||||||
if (line.trimStart().startsWith("```")) {
|
});
|
||||||
inFence = !inFence;
|
let output = markdown;
|
||||||
return line;
|
for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
|
||||||
}
|
output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`;
|
||||||
return inFence ? line : normalizeExportedMarkdownLinksInLine(line, vaultPath, vaultConfigDir);
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExportedMarkdownLinksInLine(line: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
|
|
||||||
let output = "";
|
|
||||||
let index = 0;
|
|
||||||
while (index < line.length) {
|
|
||||||
const parsed = parseMarkdownLinkAt(line, index);
|
|
||||||
if (!parsed || isInsideInlineCode(line, index)) {
|
|
||||||
output += line[index] ?? "";
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
output += normalizedExportedMarkdownLink(parsed, vaultPath, vaultConfigDir);
|
|
||||||
index = parsed.end;
|
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
@ -99,18 +74,19 @@ function markdownLinesFromTranscriptEntry(entry: ThreadTranscriptEntry): string[
|
||||||
switch (entry.kind) {
|
switch (entry.kind) {
|
||||||
case "user": {
|
case "user": {
|
||||||
const heading = timestampedHeading("User", entry.timestamp);
|
const heading = timestampedHeading("User", entry.timestamp);
|
||||||
const referenced = referencedThreadMetadataFromPrompt(entry.text);
|
return [
|
||||||
if (referenced) {
|
heading,
|
||||||
return [
|
"",
|
||||||
heading,
|
entry.text,
|
||||||
"",
|
"",
|
||||||
referenced.text,
|
...(entry.referencedThread
|
||||||
"",
|
? [
|
||||||
`> Referenced: ${referenced.reference.title} (${String(referenced.reference.includedTurns)}/${String(referenced.reference.turnLimit)} turns, ${referenced.reference.threadId})`,
|
`> Referenced: ${entry.referencedThread.title} (${String(entry.referencedThread.includedTurns)}/${String(entry.referencedThread.turnLimit)} turns${entry.referencedThread.truncated ? ", truncated" : ""}, ${entry.referencedThread.threadId})`,
|
||||||
"",
|
"",
|
||||||
];
|
]
|
||||||
}
|
: []),
|
||||||
return [heading, "", entry.text, ""];
|
...(entry.contexts ?? []).flatMap((context) => [`> Context: ${archiveContextLabel(context, entry.text)}`, ""]),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
case "assistant":
|
case "assistant":
|
||||||
return [timestampedHeading("Codex", entry.timestamp), "", entry.text, ""];
|
return [timestampedHeading("Codex", entry.timestamp), "", entry.text, ""];
|
||||||
|
|
@ -119,6 +95,23 @@ function markdownLinesFromTranscriptEntry(entry: ThreadTranscriptEntry): string[
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function archiveContextLabel(context: NonNullable<ThreadTranscriptEntry["contexts"]>[number], visibleText: string): string {
|
||||||
|
const truncated = context.truncated ? " (truncated)" : "";
|
||||||
|
if (context.kind === "obsidian") return `Obsidian context${truncated}`;
|
||||||
|
const url = visibleWebUrl(visibleText);
|
||||||
|
return `Web page${truncated}${url ? ` (${url})` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleWebUrl(text: string): string | null {
|
||||||
|
const firstToken = text.trim().split(/\s+/, 1)[0] ?? "";
|
||||||
|
try {
|
||||||
|
const parsed = new URL(firstToken);
|
||||||
|
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.toString() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function timestampedHeading(label: string, unixSeconds: number | null): string {
|
function timestampedHeading(label: string, unixSeconds: number | null): string {
|
||||||
const timestamp = formatUnixTimestamp(unixSeconds);
|
const timestamp = formatUnixTimestamp(unixSeconds);
|
||||||
return timestamp ? `## ${label} - ${timestamp}` : `## ${label}`;
|
return timestamp ? `## ${label} - ${timestamp}` : `## ${label}`;
|
||||||
|
|
@ -146,67 +139,45 @@ function stripMatchingQuotes(value: string): string {
|
||||||
return (first === `"` || first === `'`) && first === last ? value.slice(1, -1) : value;
|
return (first === `"` || first === `'`) && first === last ? value.slice(1, -1) : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrappedMarkdownHref(value: string): string {
|
function normalizedExportedMarkdownLink(
|
||||||
if (value.startsWith("<") && value.endsWith(">")) return value.slice(1, -1);
|
link: Link,
|
||||||
return value;
|
vaultPath: string,
|
||||||
}
|
vaultConfigDir: string | null | undefined,
|
||||||
|
): MarkdownSourceReplacement | null {
|
||||||
|
const start = link.position?.start.offset;
|
||||||
|
const end = link.position?.end.offset;
|
||||||
|
if (start === undefined || end === undefined || !link.url) return null;
|
||||||
|
|
||||||
function parseMarkdownLinkAt(line: string, start: number): ParsedMarkdownLink | null {
|
const parsed = parseFileHref(link.url);
|
||||||
if (line[start] !== "[" || line[start - 1] === "!") return null;
|
if (!parsed) return null;
|
||||||
|
const vaultRelative = vaultRelativePath(vaultPath, parsed.path);
|
||||||
|
if (vaultRelative && !archiveExportShouldKeepAbsolute(vaultRelative, vaultConfigDir)) {
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
value: markdownNodeSource({ ...link, url: `${vaultRelative}${parsed.subpath}` }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const labelEnd = line.indexOf("]", start + 1);
|
if (!isFilesystemAbsolutePath(normalizeFilePath(parsed.path))) return null;
|
||||||
if (labelEnd === -1 || line[labelEnd + 1] !== "(") return null;
|
|
||||||
|
|
||||||
const hrefStart = labelEnd + 2;
|
|
||||||
const hrefEnd = line[hrefStart] === "<" ? line.indexOf(">)", hrefStart + 1) : line.indexOf(")", hrefStart);
|
|
||||||
if (hrefEnd === -1) return null;
|
|
||||||
|
|
||||||
const end = line[hrefStart] === "<" ? hrefEnd + 2 : hrefEnd + 1;
|
|
||||||
return {
|
return {
|
||||||
raw: line.slice(start, end),
|
start,
|
||||||
label: line.slice(start + 1, labelEnd),
|
|
||||||
href: line.slice(hrefStart, hrefEnd + (line[hrefStart] === "<" ? 1 : 0)),
|
|
||||||
end,
|
end,
|
||||||
|
value: markdownNodeSource({
|
||||||
|
type: "paragraph",
|
||||||
|
children: [...link.children, { type: "text", value: " (" }, { type: "inlineCode", value: link.url }, { type: "text", value: ")" }],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizedExportedMarkdownLink(link: ParsedMarkdownLink, vaultPath: string, vaultConfigDir: string | null | undefined): string {
|
function markdownNodeSource(node: Nodes): string {
|
||||||
const href = unwrappedMarkdownHref(link.href.trim());
|
return toMarkdown(node, { unsafe: [{ character: "|", inConstruct: ["label", "phrasing"] }] }).trimEnd();
|
||||||
if (!href || isExternalFileHref(href)) return link.raw;
|
|
||||||
|
|
||||||
const parsed = parseFileHref(href);
|
|
||||||
if (!parsed) return link.raw;
|
|
||||||
const vaultRelative = vaultRelativePath(vaultPath, parsed.path);
|
|
||||||
if (vaultRelative && !archiveExportShouldKeepAbsolute(vaultRelative, vaultConfigDir)) {
|
|
||||||
return `[${link.label}](${markdownHref(`${vaultRelative}${parsed.subpath}`)})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isFilesystemAbsolutePath(normalizeFilePath(parsed.path)) ? `${link.label} (${markdownCodeSpan(href)})` : link.raw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function archiveExportShouldKeepAbsolute(vaultRelativePath: string, vaultConfigDir: string | null | undefined): boolean {
|
function archiveExportShouldKeepAbsolute(vaultRelativePath: string, vaultConfigDir: string | null | undefined): boolean {
|
||||||
return typeof vaultConfigDir === "string" && vaultConfigDir.length > 0 && isVaultConfigPath(vaultRelativePath, vaultConfigDir);
|
return typeof vaultConfigDir === "string" && vaultConfigDir.length > 0 && isVaultConfigPath(vaultRelativePath, vaultConfigDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
function markdownHref(value: string): string {
|
|
||||||
return /[\s()]/.test(value) ? `<${value}>` : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function markdownCodeSpan(value: string): string {
|
|
||||||
let fenceLength = 1;
|
|
||||||
for (const match of value.matchAll(/`+/g)) fenceLength = Math.max(fenceLength, match[0].length + 1);
|
|
||||||
const fence = "`".repeat(fenceLength);
|
|
||||||
return fenceLength === 1 ? `${fence}${value}${fence}` : `${fence} ${value} ${fence}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInsideInlineCode(line: string, offset: number): boolean {
|
|
||||||
let inCode = false;
|
|
||||||
for (let index = 0; index < offset; index += 1) {
|
|
||||||
if (line[index] === "`") inCode = !inCode;
|
|
||||||
}
|
|
||||||
return inCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
function formatDate(date: Date): string {
|
||||||
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|
@ -220,3 +191,11 @@ function trimTrailingBlankLines(lines: string[]): string[] {
|
||||||
while (result[result.length - 1] === "") result.pop();
|
while (result[result.length - 1] === "") result.pop();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function yamlFrontmatterString(value: string): string {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function yamlFrontmatterInlineList(values: readonly string[]): string {
|
||||||
|
return `[${values.map(yamlFrontmatterString).join(", ")}]`;
|
||||||
|
}
|
||||||
|
|
|
||||||
29
src/domain/threads/deep-link.ts
Normal file
29
src/domain/threads/deep-link.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { Thread } from "./model";
|
||||||
|
import { threadCommandDisplayTitle } from "./title";
|
||||||
|
|
||||||
|
const CODEX_THREAD_LINK_PREFIX = "codex://threads/";
|
||||||
|
const MAX_THREAD_ID_LENGTH = 160;
|
||||||
|
|
||||||
|
export function codexThreadHref(threadId: string): string {
|
||||||
|
return `${CODEX_THREAD_LINK_PREFIX}${encodeURIComponent(threadId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function codexThreadIdFromHref(href: string): string | null {
|
||||||
|
if (!href.startsWith(CODEX_THREAD_LINK_PREFIX)) return null;
|
||||||
|
const encoded = href.slice(CODEX_THREAD_LINK_PREFIX.length);
|
||||||
|
if (!encoded || encoded.includes("/") || encoded.includes("?") || encoded.includes("#")) return null;
|
||||||
|
try {
|
||||||
|
const threadId = decodeURIComponent(encoded);
|
||||||
|
return threadId.length > 0 && threadId.length <= MAX_THREAD_ID_LENGTH ? threadId : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function threadReferenceMarkdown(thread: Thread): string {
|
||||||
|
return `[${markdownLinkLabel(threadCommandDisplayTitle(thread))}](${codexThreadHref(thread.id)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownLinkLabel(value: string): string {
|
||||||
|
return value.replace(/[\\[\]]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,11 @@ export interface Thread {
|
||||||
readonly createdAt: number;
|
readonly createdAt: number;
|
||||||
readonly updatedAt: number;
|
readonly updatedAt: number;
|
||||||
readonly recencyAt?: number | null;
|
readonly recencyAt?: number | null;
|
||||||
|
/**
|
||||||
|
* Whether the loaded app-server thread accepts direct turn input.
|
||||||
|
* `null` means the capability is unavailable, so panel mode policy decides.
|
||||||
|
*/
|
||||||
|
readonly canAcceptDirectInput?: boolean | null;
|
||||||
readonly provenance: ThreadProvenance;
|
readonly provenance: ThreadProvenance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { truncateUtf8, utf8ByteLength } from "../chat/context-budget";
|
||||||
import type { Thread } from "./model";
|
import type { Thread } from "./model";
|
||||||
import { threadDisplayTitle } from "./title";
|
|
||||||
import type { TurnTranscriptSummary } from "./transcript";
|
import type { TurnTranscriptSummary } from "./transcript";
|
||||||
|
|
||||||
export const REFERENCED_THREAD_TURN_LIMIT = 20;
|
export const REFERENCED_THREAD_TURN_LIMIT = 20;
|
||||||
|
|
@ -9,162 +9,70 @@ export interface ReferencedThreadMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
includedTurns: number;
|
includedTurns: number;
|
||||||
turnLimit: number;
|
turnLimit: number;
|
||||||
|
omittedTurns?: number;
|
||||||
|
truncated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReferencedThreadEnvelope {
|
const REFERENCED_THREAD_CONTEXT_MAX_BYTES = 18_000;
|
||||||
version: 1;
|
|
||||||
reference: ReferencedThreadMetadata;
|
|
||||||
visibleText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReferencedThreadPromptBundle {
|
|
||||||
prompt: string;
|
|
||||||
referencedThread: ReferencedThreadMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
function referencedThreadPrompt(thread: Thread, turns: readonly TurnTranscriptSummary[], userRequest: string): string {
|
|
||||||
const reference = referencedThreadMetadata(thread, turns.length);
|
|
||||||
const envelope = referencedThreadEnvelope(reference, userRequest);
|
|
||||||
|
|
||||||
|
export function referencedThreadContext(thread: Thread, turns: readonly TurnTranscriptSummary[]): string {
|
||||||
|
const rendered = turns.map((turn, index) => renderedReferenceTurn(turn, index + 1));
|
||||||
|
const included: string[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
for (let index = rendered.length - 1; index >= 0; index -= 1) {
|
||||||
|
const value = rendered[index];
|
||||||
|
if (value === undefined) continue;
|
||||||
|
const nextBytes = utf8ByteLength(value);
|
||||||
|
if (bytes + nextBytes > REFERENCED_THREAD_CONTEXT_MAX_BYTES) {
|
||||||
|
if (included.length === 0) {
|
||||||
|
const turn = turns[index];
|
||||||
|
if (turn) included.unshift(truncatedReferenceTurn(turn, index + 1, REFERENCED_THREAD_CONTEXT_MAX_BYTES));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
included.unshift(value);
|
||||||
|
bytes += nextBytes;
|
||||||
|
}
|
||||||
|
const omittedTurns = turns.length - included.length;
|
||||||
return [
|
return [
|
||||||
REFERENCED_THREAD_ENVELOPE_START,
|
"Referenced thread context for the current user input:",
|
||||||
JSON.stringify(envelopeMetadata(envelope)),
|
`Thread: ${thread.id}`,
|
||||||
|
`Included turns: ${String(included.length)}`,
|
||||||
|
`Omitted turns: ${String(omittedTurns)}`,
|
||||||
"",
|
"",
|
||||||
"Reference thread history:",
|
...included,
|
||||||
...turns.flatMap((turn, index) => {
|
|
||||||
const lines = [`Turn ${String(index + 1)}:`];
|
|
||||||
if (turn.userText) lines.push(`User:\n${turn.userText}`);
|
|
||||||
if (turn.assistantText) lines.push(`Codex:\n${turn.assistantText}`);
|
|
||||||
return ["", ...lines];
|
|
||||||
}),
|
|
||||||
"",
|
|
||||||
REFERENCED_THREAD_ENVELOPE_END,
|
|
||||||
"",
|
|
||||||
"Current user request:",
|
|
||||||
envelope.visibleText,
|
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function referencedThreadMetadata(thread: Thread, count: number): ReferencedThreadMetadata {
|
function renderedReferenceTurn(turn: TurnTranscriptSummary, index: number): string {
|
||||||
return {
|
const lines = [`Turn ${String(index)}:`];
|
||||||
threadId: thread.id,
|
if (turn.userText) lines.push(`User:\n${turn.userText}`);
|
||||||
title: threadDisplayTitle(thread),
|
if (turn.assistantText) lines.push(`Codex:\n${turn.assistantText}`);
|
||||||
includedTurns: count,
|
return `${lines.join("\n")}\n\n`;
|
||||||
turnLimit: REFERENCED_THREAD_TURN_LIMIT,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function referencedThreadPromptBundle(
|
function truncatedReferenceTurn(turn: TurnTranscriptSummary, index: number, maxBytes: number): string {
|
||||||
thread: Thread,
|
const user = turn.userText ?? "";
|
||||||
turns: readonly TurnTranscriptSummary[],
|
const assistant = turn.assistantText ?? "";
|
||||||
userRequest: string,
|
const prefix = `Turn ${String(index)}:\n`;
|
||||||
): ReferencedThreadPromptBundle {
|
const userLabel = user ? "User:\n" : "";
|
||||||
const prompt = referencedThreadPrompt(thread, [...turns], userRequest);
|
const assistantLabel = assistant ? "Codex:\n" : "";
|
||||||
return {
|
const separator = user && assistant ? "\n" : "";
|
||||||
prompt,
|
const suffix = "\n[Turn fields truncated]\n\n";
|
||||||
referencedThread: referencedThreadMetadata(thread, turns.length),
|
const fixedBytes = utf8ByteLength(`${prefix}${userLabel}${separator}${assistantLabel}${suffix}`);
|
||||||
};
|
const available = Math.max(maxBytes - fixedBytes, 0);
|
||||||
}
|
const userBytes = utf8ByteLength(user);
|
||||||
|
const assistantBytes = utf8ByteLength(assistant);
|
||||||
export function referencedThreadMetadataFromPrompt(text: string): { text: string; reference: ReferencedThreadMetadata } | null {
|
let assistantBudget = assistant ? Math.min(assistantBytes, Math.floor(available / 2)) : 0;
|
||||||
const envelope = referencedThreadEnvelopeFromPrompt(text);
|
let userBudget = user ? Math.min(userBytes, available - assistantBudget) : 0;
|
||||||
return envelope ? { text: envelope.visibleText, reference: envelope.reference } : null;
|
const remaining = available - userBudget - assistantBudget;
|
||||||
}
|
assistantBudget += Math.min(Math.max(assistantBytes - assistantBudget, 0), remaining);
|
||||||
|
userBudget += Math.min(Math.max(userBytes - userBudget, 0), available - userBudget - assistantBudget);
|
||||||
const REFERENCED_THREAD_ENVELOPE_START = "[Codex Panel referenced thread v1]";
|
return [
|
||||||
const REFERENCED_THREAD_ENVELOPE_END = "[/Codex Panel referenced thread]";
|
prefix,
|
||||||
|
user ? `${userLabel}${truncateUtf8(user, userBudget)}` : "",
|
||||||
interface ReferencedThreadEnvelopeMetadata {
|
separator,
|
||||||
version: 1;
|
assistant ? `${assistantLabel}${truncateUtf8(assistant, assistantBudget)}` : "",
|
||||||
threadId: string;
|
suffix,
|
||||||
title: string;
|
].join("");
|
||||||
includedTurns: number;
|
|
||||||
turnLimit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function referencedThreadEnvelope(reference: ReferencedThreadMetadata, visibleText: string): ReferencedThreadEnvelope {
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
reference,
|
|
||||||
visibleText,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function envelopeMetadata(envelope: ReferencedThreadEnvelope): ReferencedThreadEnvelopeMetadata {
|
|
||||||
return {
|
|
||||||
version: envelope.version,
|
|
||||||
threadId: envelope.reference.threadId,
|
|
||||||
title: envelope.reference.title,
|
|
||||||
includedTurns: envelope.reference.includedTurns,
|
|
||||||
turnLimit: envelope.reference.turnLimit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function referencedThreadEnvelopeFromPrompt(text: string): ReferencedThreadEnvelope | null {
|
|
||||||
const headerStart = text.indexOf(REFERENCED_THREAD_ENVELOPE_START);
|
|
||||||
const headerEnd = text.indexOf(REFERENCED_THREAD_ENVELOPE_END);
|
|
||||||
const requestMarker = "\nCurrent user request:\n";
|
|
||||||
const requestStart = text.indexOf(requestMarker);
|
|
||||||
if (headerStart !== 0 || headerEnd === -1 || requestStart === -1 || requestStart < headerEnd) return null;
|
|
||||||
|
|
||||||
const metadataText = firstNonEmptyLine(text.slice(REFERENCED_THREAD_ENVELOPE_START.length, headerEnd));
|
|
||||||
const metadata = referencedThreadEnvelopeMetadataFromJson(metadataText);
|
|
||||||
const visibleText = text.slice(requestStart + requestMarker.length).trim();
|
|
||||||
if (!metadata || !visibleText) return null;
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
visibleText,
|
|
||||||
reference: {
|
|
||||||
threadId: metadata.threadId,
|
|
||||||
title: metadata.title,
|
|
||||||
includedTurns: metadata.includedTurns,
|
|
||||||
turnLimit: metadata.turnLimit,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstNonEmptyLine(text: string): string | null {
|
|
||||||
return (
|
|
||||||
text
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.find((line) => line.length > 0) ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function referencedThreadEnvelopeMetadataFromJson(text: string | null): ReferencedThreadEnvelopeMetadata | null {
|
|
||||||
if (!text) return null;
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(text) as unknown;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!parsed || typeof parsed !== "object") return null;
|
|
||||||
const value = parsed as Record<string, unknown>;
|
|
||||||
if (value["version"] !== 1) return null;
|
|
||||||
const threadId = stringValue(value["threadId"]);
|
|
||||||
const title = stringValue(value["title"]);
|
|
||||||
const includedTurns = finiteNonNegativeInteger(value["includedTurns"]);
|
|
||||||
const turnLimit = finitePositiveInteger(value["turnLimit"]);
|
|
||||||
if (!threadId || !title || includedTurns === null || turnLimit === null) return null;
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
threadId,
|
|
||||||
title,
|
|
||||||
includedTurns,
|
|
||||||
turnLimit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringValue(value: unknown): string | null {
|
|
||||||
return typeof value === "string" && value.length > 0 ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteNonNegativeInteger(value: unknown): number | null {
|
|
||||||
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function finitePositiveInteger(value: unknown): number | null {
|
|
||||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
|
import type { ThreadTitleContext } from "./title-generation-model";
|
||||||
|
|
||||||
|
type ThreadRenameAutoNameState = { kind: "checking" } | { kind: "unavailable" } | { kind: "ready"; context: ThreadTitleContext };
|
||||||
|
|
||||||
export type ThreadRenameLifecycleState =
|
export type ThreadRenameLifecycleState =
|
||||||
| { kind: "idle" }
|
| { kind: "idle" }
|
||||||
| { kind: "editing"; draft: string }
|
| { kind: "editing"; draft: string; autoName: ThreadRenameAutoNameState }
|
||||||
| { kind: "generating"; draft: string; originalDraft: string; generationToken: number };
|
| { kind: "saving"; draft: string; autoName: ThreadRenameAutoNameState; saveToken: number }
|
||||||
|
| { kind: "generating"; draft: string; autoName: Extract<ThreadRenameAutoNameState, { kind: "ready" }>; generationToken: number };
|
||||||
|
|
||||||
export type ThreadRenameActiveState = Exclude<ThreadRenameLifecycleState, { kind: "idle" }>;
|
export type ThreadRenameActiveState = Exclude<ThreadRenameLifecycleState, { kind: "idle" }>;
|
||||||
export type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
|
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
|
||||||
|
type ThreadRenameSavingState = Extract<ThreadRenameLifecycleState, { kind: "saving" }>;
|
||||||
|
|
||||||
export type ThreadRenameLifecycleEvent =
|
export type ThreadRenameLifecycleEvent =
|
||||||
| { type: "started"; draft: string }
|
| { type: "started"; draft: string }
|
||||||
| { type: "draft-updated"; draft: string }
|
| { type: "draft-updated"; draft: string }
|
||||||
|
| { type: "auto-name-context-resolved"; context: ThreadTitleContext | null }
|
||||||
| { type: "cancelled" }
|
| { type: "cancelled" }
|
||||||
|
| { type: "save-started"; saveToken: number }
|
||||||
|
| { type: "save-failed"; saveToken: number }
|
||||||
|
| { type: "save-succeeded"; saveToken: number }
|
||||||
| { type: "generation-started"; generationToken: number }
|
| { type: "generation-started"; generationToken: number }
|
||||||
| { type: "generation-succeeded"; generationToken: number; draft: string }
|
| { type: "generation-succeeded"; generationToken: number; draft: string }
|
||||||
| { type: "generation-finished"; generationToken: number }
|
| { type: "generation-finished"; generationToken: number }
|
||||||
|
|
@ -25,25 +35,40 @@ export function transitionThreadRenameLifecycleState(
|
||||||
): ThreadRenameLifecycleState {
|
): ThreadRenameLifecycleState {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "started":
|
case "started":
|
||||||
return { kind: "editing", draft: event.draft };
|
if (state.kind === "saving") return state;
|
||||||
|
return { kind: "editing", draft: event.draft, autoName: { kind: "checking" } };
|
||||||
case "draft-updated":
|
case "draft-updated":
|
||||||
return state.kind === "idle" ? state : { ...state, draft: event.draft };
|
return state.kind === "editing" ? { ...state, draft: event.draft } : state;
|
||||||
|
case "auto-name-context-resolved":
|
||||||
|
if (state.kind !== "editing" && state.kind !== "saving") return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
autoName: event.context ? { kind: "ready", context: event.context } : { kind: "unavailable" },
|
||||||
|
};
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
return state.kind === "saving" || state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
||||||
|
case "save-started":
|
||||||
|
return state.kind === "editing" ? { ...state, kind: "saving", saveToken: event.saveToken } : state;
|
||||||
|
case "save-failed":
|
||||||
|
return threadRenameSaveStillActive(state, event.saveToken)
|
||||||
|
? { kind: "editing", draft: state.draft, autoName: state.autoName }
|
||||||
|
: state;
|
||||||
|
case "save-succeeded":
|
||||||
|
return threadRenameSaveStillActive(state, event.saveToken) ? initialThreadRenameLifecycleState() : state;
|
||||||
case "generation-started":
|
case "generation-started":
|
||||||
if (state.kind !== "editing") return state;
|
if (state.kind !== "editing" || state.autoName.kind !== "ready") return state;
|
||||||
return {
|
return {
|
||||||
kind: "generating",
|
kind: "generating",
|
||||||
draft: state.draft,
|
draft: state.draft,
|
||||||
originalDraft: state.draft,
|
autoName: state.autoName,
|
||||||
generationToken: event.generationToken,
|
generationToken: event.generationToken,
|
||||||
};
|
};
|
||||||
case "generation-succeeded":
|
case "generation-succeeded":
|
||||||
if (!threadRenameGenerationStillActive(state, event.generationToken) || state.draft !== state.originalDraft) return state;
|
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
|
||||||
return { ...state, draft: event.draft };
|
return { ...state, draft: event.draft };
|
||||||
case "generation-finished":
|
case "generation-finished":
|
||||||
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
|
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
|
||||||
return { kind: "editing", draft: state.draft };
|
return { kind: "editing", draft: state.draft, autoName: state.autoName };
|
||||||
case "cleared":
|
case "cleared":
|
||||||
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
||||||
default:
|
default:
|
||||||
|
|
@ -58,6 +83,10 @@ export function threadRenameGenerationStillActive(
|
||||||
return state.kind === "generating" && state.generationToken === generationToken;
|
return state.kind === "generating" && state.generationToken === generationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function threadRenameSaveStillActive(state: ThreadRenameLifecycleState, saveToken: number): state is ThreadRenameSavingState {
|
||||||
|
return state.kind === "saving" && state.saveToken === saveToken;
|
||||||
|
}
|
||||||
|
|
||||||
function unhandledThreadRenameLifecycleEvent(event: never): never {
|
function unhandledThreadRenameLifecycleEvent(event: never): never {
|
||||||
throw new Error(`Unhandled thread rename lifecycle event: ${String(event)}`);
|
throw new Error(`Unhandled thread rename lifecycle event: ${String(event)}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { shortThreadId } from "./id";
|
|
||||||
import { type Thread, threadRecencyAt } from "./model";
|
import { type Thread, threadRecencyAt } from "./model";
|
||||||
import { threadDisplayTitle } from "./title";
|
import { threadDisplayTitle } from "./title";
|
||||||
|
|
||||||
|
|
@ -16,14 +15,18 @@ export type ThreadSearchResolution =
|
||||||
| { kind: "none"; query: string };
|
| { kind: "none"; query: string };
|
||||||
|
|
||||||
const NO_THREAD_SEARCH_MATCH = -1;
|
const NO_THREAD_SEARCH_MATCH = -1;
|
||||||
const EMPTY_THREAD_SEARCH_SCORE = 5;
|
const TITLE_EXACT_SCORE = 0;
|
||||||
|
const TITLE_PREFIX_SCORE = 1;
|
||||||
|
const TITLE_SUBSTRING_SCORE = 2;
|
||||||
|
const TITLE_FUZZY_SCORE = 3;
|
||||||
|
const EMPTY_THREAD_SEARCH_SCORE = 4;
|
||||||
|
|
||||||
export function threadSearchMatches(threads: readonly Thread[], queryText: string): ThreadSearchMatch[] {
|
export function threadSearchMatches(threads: readonly Thread[], queryText: string): ThreadSearchMatch[] {
|
||||||
const query = normalizedThreadSearchQuery(queryText);
|
const query = normalizedThreadSearchQuery(queryText);
|
||||||
return threads
|
return threads
|
||||||
.map((thread, index) => {
|
.map((thread, index) => {
|
||||||
const title = threadDisplayTitle(thread);
|
const title = threadDisplayTitle(thread);
|
||||||
const score = threadSearchScore(thread, title, query);
|
const score = threadSearchScore(title, query);
|
||||||
return { thread, title, score, recencyAt: threadRecencyAt(thread), index };
|
return { thread, title, score, recencyAt: threadRecencyAt(thread), index };
|
||||||
})
|
})
|
||||||
.filter((match) => match.score !== NO_THREAD_SEARCH_MATCH)
|
.filter((match) => match.score !== NO_THREAD_SEARCH_MATCH)
|
||||||
|
|
@ -49,16 +52,21 @@ function normalizedThreadSearchQuery(queryText: string): string {
|
||||||
return queryText.trim().toLowerCase();
|
return queryText.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadSearchScore(thread: Thread, title: string, query: string): number {
|
function threadSearchScore(title: string, query: string): number {
|
||||||
if (!query) return EMPTY_THREAD_SEARCH_SCORE;
|
if (!query) return EMPTY_THREAD_SEARCH_SCORE;
|
||||||
|
|
||||||
const id = thread.id.toLowerCase();
|
|
||||||
const normalizedTitle = title.toLowerCase();
|
const normalizedTitle = title.toLowerCase();
|
||||||
const shortId = shortThreadId(thread.id).toLowerCase();
|
if (normalizedTitle === query) return TITLE_EXACT_SCORE;
|
||||||
if (id === query || shortId === query) return 0;
|
if (normalizedTitle.startsWith(query)) return TITLE_PREFIX_SCORE;
|
||||||
if (normalizedTitle.startsWith(query)) return 1;
|
if (normalizedTitle.includes(query)) return TITLE_SUBSTRING_SCORE;
|
||||||
if (id.startsWith(query) || shortId.startsWith(query)) return 2;
|
return fuzzySubsequenceMatches(normalizedTitle, query) ? TITLE_FUZZY_SCORE : NO_THREAD_SEARCH_MATCH;
|
||||||
if (normalizedTitle.includes(query)) return 3;
|
}
|
||||||
if (id.includes(query)) return 4;
|
|
||||||
return NO_THREAD_SEARCH_MATCH;
|
function fuzzySubsequenceMatches(title: string, query: string): boolean {
|
||||||
|
let queryIndex = 0;
|
||||||
|
for (const character of title) {
|
||||||
|
if (character === query[queryIndex]) queryIndex += 1;
|
||||||
|
if (queryIndex === query.length) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ const DEFAULT_CONTEXT_PAGE_LIMIT = 20;
|
||||||
const DEFAULT_CONTEXT_MAX_PAGES = 5;
|
const DEFAULT_CONTEXT_MAX_PAGES = 5;
|
||||||
|
|
||||||
export const THREAD_TITLE_MAX_CHARS = 40;
|
export const THREAD_TITLE_MAX_CHARS = 40;
|
||||||
export const THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE =
|
|
||||||
"Auto-name needs completed history or visible resumed history with both user and assistant text.";
|
|
||||||
|
|
||||||
export interface ThreadTitleContext {
|
export interface ThreadTitleContext {
|
||||||
userRequest: string;
|
userRequest: string;
|
||||||
assistantResponse: string;
|
assistantResponse: string;
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import { shortThreadId } from "./id";
|
||||||
import type { Thread } from "./model";
|
import type { Thread } from "./model";
|
||||||
|
|
||||||
const MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH = 96;
|
const MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH = 96;
|
||||||
|
const MAX_THREAD_COMMAND_DISPLAY_TITLE_LENGTH = 96;
|
||||||
const UNTITLED_THREAD_TITLE = "Untitled thread";
|
const UNTITLED_THREAD_TITLE = "Untitled thread";
|
||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||||
|
|
||||||
export function threadMeaningfulTitle(thread: Thread): string | null {
|
export function threadMeaningfulTitle(thread: Thread): string | null {
|
||||||
for (const value of [thread.name, thread.preview]) {
|
for (const value of [thread.name, thread.preview]) {
|
||||||
|
|
@ -17,6 +19,10 @@ export function threadDisplayTitle(thread: Thread): string {
|
||||||
return threadMeaningfulTitle(thread) ?? UNTITLED_THREAD_TITLE;
|
return threadMeaningfulTitle(thread) ?? UNTITLED_THREAD_TITLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function threadCommandDisplayTitle(thread: Thread): string {
|
||||||
|
return truncateThreadDisplayTitle(threadDisplayTitle(thread), MAX_THREAD_COMMAND_DISPLAY_TITLE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
export function threadRenameDraftTitle(thread: Thread): string {
|
export function threadRenameDraftTitle(thread: Thread): string {
|
||||||
return threadMeaningfulTitle(thread) ?? "";
|
return threadMeaningfulTitle(thread) ?? "";
|
||||||
}
|
}
|
||||||
|
|
@ -26,9 +32,7 @@ export function threadArchiveTitle(thread: Thread): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function threadArchiveDisplayTitle(thread: Thread): string {
|
export function threadArchiveDisplayTitle(thread: Thread): string {
|
||||||
const title = threadArchiveTitle(thread);
|
return truncateThreadDisplayTitle(threadArchiveTitle(thread), MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH);
|
||||||
if (title.length <= MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH) return title;
|
|
||||||
return `${title.slice(0, MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH - 3).trimEnd()}...`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function threadWindowTitle(activeThreadId: string | null, threads: readonly Thread[], fallbackTitle?: string | null): string {
|
export function threadWindowTitle(activeThreadId: string | null, threads: readonly Thread[], fallbackTitle?: string | null): string {
|
||||||
|
|
@ -47,3 +51,12 @@ export function threadWindowTitle(activeThreadId: string | null, threads: readon
|
||||||
function normalizeThreadTitleText(value: string | null | undefined): string {
|
function normalizeThreadTitleText(value: string | null | undefined): string {
|
||||||
return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateThreadDisplayTitle(title: string, maxLength: number): string {
|
||||||
|
const graphemes = Array.from(GRAPHEME_SEGMENTER.segment(title), ({ segment }) => segment);
|
||||||
|
if (graphemes.length <= maxLength) return title;
|
||||||
|
return `${graphemes
|
||||||
|
.slice(0, maxLength - 3)
|
||||||
|
.join("")
|
||||||
|
.trimEnd()}...`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@ export interface ThreadTranscriptEntry {
|
||||||
kind: ThreadTranscriptEntryKind;
|
kind: ThreadTranscriptEntryKind;
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: number | null;
|
timestamp: number | null;
|
||||||
|
referencedThread?: ReferencedThreadMetadata;
|
||||||
|
contexts?: readonly ThreadTranscriptContext[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadTranscriptContext {
|
||||||
|
kind: "web" | "obsidian";
|
||||||
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnTranscriptSummary {
|
export interface TurnTranscriptSummary {
|
||||||
|
|
@ -46,3 +53,5 @@ function lastTranscriptText(
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { ReferencedThreadMetadata } from "./reference";
|
||||||
|
|
|
||||||
387
src/execution-runtime.ts
Normal file
387
src/execution-runtime.ts
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
import type { App } from "obsidian";
|
||||||
|
|
||||||
|
import type { AppServerClient } from "./app-server/connection/client";
|
||||||
|
import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access";
|
||||||
|
import type { AppServerExecutionContext } from "./app-server/connection/execution-context";
|
||||||
|
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
|
||||||
|
import { AppServerQueryCache } from "./app-server/query/cache";
|
||||||
|
import {
|
||||||
|
type EphemeralStructuredTurnClient,
|
||||||
|
type EphemeralStructuredTurnRunner,
|
||||||
|
runEphemeralStructuredTurn,
|
||||||
|
} from "./app-server/services/ephemeral-structured-turn";
|
||||||
|
import { createThreadGoalOperationCoordinator } from "./features/chat/application/threads/goal-actions";
|
||||||
|
import type { ChatPanelSettingsAccess, ChatRuntimeView, CodexChatHost, WorkspacePanels } from "./features/chat/host/contracts";
|
||||||
|
import { createAppServerSelectionRewriteTransport } from "./features/selection-rewrite/app-server-transport";
|
||||||
|
import type { SelectionRewriteTransport } from "./features/selection-rewrite/transport";
|
||||||
|
import { openThreadPicker, type ThreadPickerController } from "./features/thread-picker/modal.obsidian";
|
||||||
|
import { createThreadOperationsTransport, createThreadTitleTransport } from "./features/threads/app-server/workflow-transports";
|
||||||
|
import type { ThreadCatalog } from "./features/threads/catalog/thread-catalog";
|
||||||
|
import {
|
||||||
|
createThreadOperationCoordinator,
|
||||||
|
type ThreadOperationCoordinator,
|
||||||
|
} from "./features/threads/workflows/thread-operation-coordinator";
|
||||||
|
import type { ThreadLifecycleEvent } from "./features/threads/workflows/thread-operation-event";
|
||||||
|
import { projectThreadListChanges } from "./features/threads/workflows/thread-read-model-projection";
|
||||||
|
import type { ThreadsViewHost, ThreadsViewSettingsAccess } from "./features/threads-view/session";
|
||||||
|
import type { ThreadsViewPanelActivity } from "./features/threads-view/state";
|
||||||
|
import type { ThreadsRuntimeView } from "./features/threads-view/view.obsidian";
|
||||||
|
import { createSettingsAppServerDynamicData } from "./settings/app-server-dynamic-data";
|
||||||
|
import type { SettingsDynamicDataAccess } from "./settings/dynamic-data";
|
||||||
|
import type { CodexPanelSettings } from "./settings/model";
|
||||||
|
import { StaleExecutionRuntimeError } from "./shared/runtime/execution-runtime-lifetime";
|
||||||
|
import { createKeyedOperationQueue } from "./shared/runtime/keyed-operation-queue";
|
||||||
|
|
||||||
|
export interface CodexExecutionRuntimeOptions {
|
||||||
|
app: App;
|
||||||
|
context: AppServerExecutionContext;
|
||||||
|
settings: () => CodexPanelSettings;
|
||||||
|
workspace: WorkspacePanels;
|
||||||
|
onThreadLifecycleEvents(events: readonly ThreadLifecycleEvent[]): void;
|
||||||
|
openNewPanel(): Promise<unknown>;
|
||||||
|
openThreadInCurrentView(threadId: string): Promise<void>;
|
||||||
|
openThreadInAvailableView(threadId: string): Promise<void>;
|
||||||
|
openPanelActivities(): readonly ThreadsViewPanelActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionRuntimeViews {
|
||||||
|
readonly chat: readonly ChatRuntimeView[];
|
||||||
|
readonly threads: readonly ThreadsRuntimeView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CodexExecutionRuntime implements AppServerClientAccess {
|
||||||
|
private readonly context: Readonly<AppServerExecutionContext>;
|
||||||
|
private readonly appServerQueries: AppServerQueryCache;
|
||||||
|
private readonly threadCatalog: ThreadCatalog;
|
||||||
|
private readonly threadOperationCoordinator: ThreadOperationCoordinator;
|
||||||
|
readonly settingsDynamicData: SettingsDynamicDataAccess;
|
||||||
|
private readonly threadNameMutations = createKeyedOperationQueue<string>();
|
||||||
|
private readonly threadGoalOperations = createThreadGoalOperationCoordinator();
|
||||||
|
private readonly runtimeSettingsCommitQueue = createKeyedOperationQueue<string>();
|
||||||
|
private readonly shortLivedClients = new Set<AppServerClient>();
|
||||||
|
private readonly structuredTurnClients = new Set<EphemeralStructuredTurnClient>();
|
||||||
|
private readonly structuredTurnOperations = new Set<AbortController>();
|
||||||
|
private activeThreadPicker: ThreadPickerController | null = null;
|
||||||
|
private readonly chatViews = new Set<ChatRuntimeView>();
|
||||||
|
private readonly threadsViews = new Set<ThreadsRuntimeView>();
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(private readonly options: CodexExecutionRuntimeOptions) {
|
||||||
|
this.context = Object.freeze({ ...options.context });
|
||||||
|
this.appServerQueries = new AppServerQueryCache(this.context, this);
|
||||||
|
this.threadCatalog = this.appServerQueries;
|
||||||
|
this.threadOperationCoordinator = createThreadOperationCoordinator((events) => {
|
||||||
|
this.threadCatalog.applyThreadListMutations(projectThreadListChanges(this.threadCatalog, events));
|
||||||
|
options.onThreadLifecycleEvents(events);
|
||||||
|
});
|
||||||
|
this.settingsDynamicData = createSettingsAppServerDynamicData({
|
||||||
|
vaultPath: this.context.vaultPath,
|
||||||
|
clientAccess: this,
|
||||||
|
appServerQueries: this.appServerQueries,
|
||||||
|
threadCatalog: this.threadCatalog,
|
||||||
|
threadEvents: this.threadOperationCoordinator,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private chatHost(): CodexChatHost {
|
||||||
|
this.assertActive();
|
||||||
|
return {
|
||||||
|
appServerClientAccess: this,
|
||||||
|
appServerContext: this.context,
|
||||||
|
settings: this.chatSettings(),
|
||||||
|
workspace: this.options.workspace,
|
||||||
|
appServerQueries: this.appServerQueries,
|
||||||
|
threadCatalog: this.threadCatalog,
|
||||||
|
threadOperationCoordinator: this.threadOperationCoordinator,
|
||||||
|
threadNameMutations: this.threadNameMutations,
|
||||||
|
threadTitleTransport: this.threadTitleTransport(),
|
||||||
|
threadGoalOperations: this.threadGoalOperations,
|
||||||
|
runtimeSettingsCommitQueue: this.runtimeSettingsCommitQueue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private threadsHost(): ThreadsViewHost {
|
||||||
|
this.assertActive();
|
||||||
|
return {
|
||||||
|
settings: this.threadsSettings(),
|
||||||
|
vaultPath: this.context.vaultPath,
|
||||||
|
threadCatalog: this.threadCatalog,
|
||||||
|
threadEvents: this.threadOperationCoordinator,
|
||||||
|
threadNameMutations: this.threadNameMutations,
|
||||||
|
threadOperationsTransport: createThreadOperationsTransport(this),
|
||||||
|
threadTitleTransport: this.threadTitleTransport(),
|
||||||
|
openNewPanel: () => this.options.openNewPanel(),
|
||||||
|
openThreadInAvailableView: (threadId) => this.options.openThreadInAvailableView(threadId),
|
||||||
|
openPanelActivities: () => this.options.openPanelActivities(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
|
||||||
|
return this.runWithAppServerClient(operation, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
attachChatView(view: ChatRuntimeView): void {
|
||||||
|
this.assertActive();
|
||||||
|
if (this.chatViews.has(view)) return;
|
||||||
|
this.chatViews.add(view);
|
||||||
|
view.attachRuntime(this.chatHost());
|
||||||
|
view.activateRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
detachChatView(view: ChatRuntimeView): void {
|
||||||
|
if (!this.chatViews.delete(view)) return;
|
||||||
|
view.detachRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
attachThreadsView(view: ThreadsRuntimeView): void {
|
||||||
|
this.assertActive();
|
||||||
|
if (this.threadsViews.has(view)) return;
|
||||||
|
this.threadsViews.add(view);
|
||||||
|
view.attachRuntime(this.threadsHost());
|
||||||
|
view.activateRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
detachThreadsView(view: ThreadsRuntimeView): void {
|
||||||
|
if (!this.threadsViews.delete(view)) return;
|
||||||
|
view.detachRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
adoptViews(views: ExecutionRuntimeViews): void {
|
||||||
|
this.assertActive();
|
||||||
|
for (const view of views.chat) {
|
||||||
|
if (
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
view.attachRuntime(this.chatHost());
|
||||||
|
})
|
||||||
|
)
|
||||||
|
this.chatViews.add(view);
|
||||||
|
else
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
view.detachRuntime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const view of views.threads) {
|
||||||
|
if (
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
view.attachRuntime(this.threadsHost());
|
||||||
|
})
|
||||||
|
)
|
||||||
|
this.threadsViews.add(view);
|
||||||
|
else
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
view.detachRuntime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const view of [...this.chatViews]) {
|
||||||
|
if (
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
view.activateRuntime();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
this.chatViews.delete(view);
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
view.detachRuntime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const view of [...this.threadsViews]) {
|
||||||
|
if (
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
view.activateRuntime();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
this.threadsViews.delete(view);
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
view.detachRuntime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionRewriteTransport(): SelectionRewriteTransport {
|
||||||
|
return createAppServerSelectionRewriteTransport({
|
||||||
|
codexPath: this.context.codexPath,
|
||||||
|
cwd: this.context.vaultPath,
|
||||||
|
runner: this.structuredTurnRunner(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openThreadPicker(): void {
|
||||||
|
this.activeThreadPicker?.close();
|
||||||
|
const picker = openThreadPicker(
|
||||||
|
{
|
||||||
|
app: this.options.app,
|
||||||
|
threadCatalog: this.threadCatalog,
|
||||||
|
openThreadInCurrentView: (threadId) => this.options.openThreadInCurrentView(threadId),
|
||||||
|
openThreadInAvailableView: (threadId) => this.options.openThreadInAvailableView(threadId),
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (this.activeThreadPicker === picker) this.activeThreadPicker = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.activeThreadPicker = picker;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): ExecutionRuntimeViews {
|
||||||
|
if (this.disposed) return { chat: [], threads: [] };
|
||||||
|
this.disposed = true;
|
||||||
|
const views: { chat: ChatRuntimeView[]; threads: ThreadsRuntimeView[] } = { chat: [], threads: [] };
|
||||||
|
for (const view of this.chatViews) {
|
||||||
|
if (
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
view.detachRuntime();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
views.chat.push(view);
|
||||||
|
}
|
||||||
|
for (const view of this.threadsViews) {
|
||||||
|
if (
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
view.detachRuntime();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
views.threads.push(view);
|
||||||
|
}
|
||||||
|
this.chatViews.clear();
|
||||||
|
this.threadsViews.clear();
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
this.activeThreadPicker?.close();
|
||||||
|
});
|
||||||
|
this.activeThreadPicker = null;
|
||||||
|
for (const operation of this.structuredTurnOperations)
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
operation.abort();
|
||||||
|
});
|
||||||
|
this.structuredTurnOperations.clear();
|
||||||
|
for (const client of this.structuredTurnClients)
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
this.structuredTurnClients.clear();
|
||||||
|
for (const client of this.shortLivedClients)
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
this.shortLivedClients.clear();
|
||||||
|
this.tryCleanup(() => {
|
||||||
|
this.appServerQueries.dispose();
|
||||||
|
});
|
||||||
|
return views;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runWithAppServerClient<T>(
|
||||||
|
operation: (client: AppServerClient) => Promise<T>,
|
||||||
|
options: AppServerClientAccessOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
this.assertActive();
|
||||||
|
const guardedOperation = (client: AppServerClient): Promise<T> => {
|
||||||
|
this.assertActive();
|
||||||
|
return operation(client);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, {
|
||||||
|
created: (client) => {
|
||||||
|
if (this.disposed) {
|
||||||
|
client.disconnect();
|
||||||
|
throw new StaleExecutionRuntimeError();
|
||||||
|
}
|
||||||
|
this.shortLivedClients.add(client);
|
||||||
|
},
|
||||||
|
disposed: (client) => {
|
||||||
|
this.shortLivedClients.delete(client);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.assertActive();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private structuredTurnRunner(): EphemeralStructuredTurnRunner {
|
||||||
|
return async (options) => {
|
||||||
|
this.assertActive();
|
||||||
|
const operation = new AbortController();
|
||||||
|
const abort = (): void => {
|
||||||
|
operation.abort(options.signal?.reason);
|
||||||
|
};
|
||||||
|
if (options.signal?.aborted) abort();
|
||||||
|
else options.signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
this.structuredTurnOperations.add(operation);
|
||||||
|
try {
|
||||||
|
return await runEphemeralStructuredTurn(
|
||||||
|
{ ...options, signal: operation.signal },
|
||||||
|
{
|
||||||
|
clientLifecycle: {
|
||||||
|
created: (client) => {
|
||||||
|
if (this.disposed) {
|
||||||
|
client.disconnect();
|
||||||
|
throw new StaleExecutionRuntimeError();
|
||||||
|
}
|
||||||
|
this.structuredTurnClients.add(client);
|
||||||
|
},
|
||||||
|
disposed: (client) => {
|
||||||
|
this.structuredTurnClients.delete(client);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
options.signal?.removeEventListener("abort", abort);
|
||||||
|
this.structuredTurnOperations.delete(operation);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private threadTitleTransport() {
|
||||||
|
return createThreadTitleTransport({
|
||||||
|
clientAccess: this,
|
||||||
|
codexPath: this.context.codexPath,
|
||||||
|
vaultPath: this.context.vaultPath,
|
||||||
|
threadNamingModel: () => this.options.settings().threadNamingModel,
|
||||||
|
threadNamingEffort: () => this.options.settings().threadNamingEffort,
|
||||||
|
runner: this.structuredTurnRunner(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertActive(): void {
|
||||||
|
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryCleanup(operation: () => void): boolean {
|
||||||
|
try {
|
||||||
|
operation();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private chatSettings(): ChatPanelSettingsAccess {
|
||||||
|
return {
|
||||||
|
referenceActiveNoteOnSend: () => this.options.settings().referenceActiveNoteOnSend,
|
||||||
|
attachmentFolder: () => this.options.settings().attachmentFolder,
|
||||||
|
archiveExportEnabled: () => this.options.settings().archiveExportEnabled,
|
||||||
|
archiveExportSettings: () => ({
|
||||||
|
archiveExportFolderTemplate: this.options.settings().archiveExportFolderTemplate,
|
||||||
|
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,
|
||||||
|
archiveExportTags: this.options.settings().archiveExportTags,
|
||||||
|
}),
|
||||||
|
scrollThreadFromComposerEdges: () => this.options.settings().scrollThreadFromComposerEdges,
|
||||||
|
sendShortcut: () => this.options.settings().sendShortcut,
|
||||||
|
showToolbar: () => this.options.settings().showToolbar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private threadsSettings(): ThreadsViewSettingsAccess {
|
||||||
|
return {
|
||||||
|
archiveExportEnabled: () => this.options.settings().archiveExportEnabled,
|
||||||
|
archiveExportSettings: () => ({
|
||||||
|
archiveExportFolderTemplate: this.options.settings().archiveExportFolderTemplate,
|
||||||
|
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,
|
||||||
|
archiveExportTags: this.options.settings().archiveExportTags,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { RequestId, ServerNotification, ServerRequest } from "../../../../app-server/connection/rpc-messages";
|
import type { RequestId, ServerNotification, ServerRequest } from "../../../../app-server/connection/rpc-messages";
|
||||||
import type { AppServerQueryContextIdentity } from "../../../../app-server/query/keys";
|
|
||||||
import {
|
import {
|
||||||
routeServerRequest,
|
routeServerRequest,
|
||||||
serverRequestApprovalResponse,
|
serverRequestApprovalResponse,
|
||||||
|
|
@ -15,7 +14,7 @@ import {
|
||||||
type PendingUserInput,
|
type PendingUserInput,
|
||||||
} from "../../../../domain/pending-requests/model";
|
} from "../../../../domain/pending-requests/model";
|
||||||
import type { TurnTranscriptSummary } from "../../../../domain/threads/transcript";
|
import type { TurnTranscriptSummary } from "../../../../domain/threads/transcript";
|
||||||
import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
|
import type { ThreadOperationEvent } from "../../../threads/workflows/thread-operation-event";
|
||||||
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
|
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
|
||||||
import type { LocalIdSource } from "../../application/local-id-source";
|
import type { LocalIdSource } from "../../application/local-id-source";
|
||||||
import { activeThreadId, type ChatAction, type ChatState } from "../../application/state/root-reducer";
|
import { activeThreadId, type ChatAction, type ChatState } from "../../application/state/root-reducer";
|
||||||
|
|
@ -32,17 +31,16 @@ import { classifyAppServerLog } from "./app-server-logs";
|
||||||
import { type ChatNotificationEffect, planChatNotification } from "./notification-plan";
|
import { type ChatNotificationEffect, planChatNotification } from "./notification-plan";
|
||||||
|
|
||||||
export interface ChatInboundHandlerActions {
|
export interface ChatInboundHandlerActions {
|
||||||
refreshActiveThreads: () => void;
|
|
||||||
refreshServerDiagnostics: (options?: { forceResourceProbes?: boolean }) => void;
|
refreshServerDiagnostics: (options?: { forceResourceProbes?: boolean }) => void;
|
||||||
applyAppServerResourceEvent: (event: AppServerResourceEvent) => void;
|
applyAppServerResourceEvent: (event: AppServerResourceEvent) => void;
|
||||||
maybeNameThread: (threadId: string, turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null) => void;
|
maybeNameThread: (threadId: string, turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null) => void;
|
||||||
applyThreadCatalogEvent: (event: ThreadCatalogEvent, sourceContext: AppServerQueryContextIdentity) => void;
|
applyThreadOperationEvent: (event: ThreadOperationEvent) => void;
|
||||||
respondToServerRequest: (requestId: RequestId, result: unknown) => boolean;
|
respondToServerRequest: (requestId: RequestId, result: unknown) => boolean;
|
||||||
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
|
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatInboundHandler {
|
export interface ChatInboundHandler {
|
||||||
handleNotification(notification: ServerNotification, sourceContext: AppServerQueryContextIdentity): void;
|
handleNotification(notification: ServerNotification): void;
|
||||||
handleServerRequest(request: ServerRequest): void;
|
handleServerRequest(request: ServerRequest): void;
|
||||||
handleAppServerLog(message: string): void;
|
handleAppServerLog(message: string): void;
|
||||||
resolveApproval(requestId: PendingRequestId, action: ApprovalAction): void;
|
resolveApproval(requestId: PendingRequestId, action: ApprovalAction): void;
|
||||||
|
|
@ -67,8 +65,8 @@ export function createChatInboundHandler(
|
||||||
): ChatInboundHandler {
|
): ChatInboundHandler {
|
||||||
const context: ChatInboundHandlerContext = { store, actions, localItemIds };
|
const context: ChatInboundHandlerContext = { store, actions, localItemIds };
|
||||||
return {
|
return {
|
||||||
handleNotification: (notification, sourceContext) => {
|
handleNotification: (notification) => {
|
||||||
handleNotification(context, notification, sourceContext);
|
handleNotification(context, notification);
|
||||||
},
|
},
|
||||||
handleServerRequest: (request) => {
|
handleServerRequest: (request) => {
|
||||||
handleServerRequest(context, request);
|
handleServerRequest(context, request);
|
||||||
|
|
@ -108,14 +106,10 @@ function dispatch(context: ChatInboundHandlerContext, action: ChatAction): void
|
||||||
context.store.dispatch(action);
|
context.store.dispatch(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNotification(
|
function handleNotification(context: ChatInboundHandlerContext, notification: ServerNotification): void {
|
||||||
context: ChatInboundHandlerContext,
|
|
||||||
notification: ServerNotification,
|
|
||||||
sourceContext: AppServerQueryContextIdentity,
|
|
||||||
): void {
|
|
||||||
const plan = planChatNotification(state(context), notification, (prefix) => localItemId(context, prefix));
|
const plan = planChatNotification(state(context), notification, (prefix) => localItemId(context, prefix));
|
||||||
for (const action of plan.actions) dispatch(context, action);
|
for (const action of plan.actions) dispatch(context, action);
|
||||||
for (const effect of plan.effects) runNotificationEffect(context, effect, sourceContext);
|
for (const effect of plan.effects) runNotificationEffect(context, effect);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleServerRequest(context: ChatInboundHandlerContext, request: ServerRequest): void {
|
function handleServerRequest(context: ChatInboundHandlerContext, request: ServerRequest): void {
|
||||||
|
|
@ -243,15 +237,8 @@ function localItemId(context: ChatInboundHandlerContext, prefix: string): string
|
||||||
return context.localItemIds.next(prefix);
|
return context.localItemIds.next(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runNotificationEffect(
|
function runNotificationEffect(context: ChatInboundHandlerContext, effect: ChatNotificationEffect): void {
|
||||||
context: ChatInboundHandlerContext,
|
|
||||||
effect: ChatNotificationEffect,
|
|
||||||
sourceContext: AppServerQueryContextIdentity,
|
|
||||||
): void {
|
|
||||||
switch (effect.type) {
|
switch (effect.type) {
|
||||||
case "refresh-threads":
|
|
||||||
context.actions.refreshActiveThreads();
|
|
||||||
return;
|
|
||||||
case "refresh-server-diagnostics":
|
case "refresh-server-diagnostics":
|
||||||
context.actions.refreshServerDiagnostics({ forceResourceProbes: effect.forceResourceProbes === true });
|
context.actions.refreshServerDiagnostics({ forceResourceProbes: effect.forceResourceProbes === true });
|
||||||
return;
|
return;
|
||||||
|
|
@ -261,8 +248,8 @@ function runNotificationEffect(
|
||||||
case "maybe-name-thread":
|
case "maybe-name-thread":
|
||||||
context.actions.maybeNameThread(effect.threadId, effect.turnId, effect.completedTurnTranscriptSummary);
|
context.actions.maybeNameThread(effect.threadId, effect.turnId, effect.completedTurnTranscriptSummary);
|
||||||
return;
|
return;
|
||||||
case "apply-thread-catalog-event":
|
case "apply-thread-operation-event":
|
||||||
context.actions.applyThreadCatalogEvent(effect.event, sourceContext);
|
context.actions.applyThreadOperationEvent(effect.event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,18 @@ import type { ServerNotification } from "../../../../app-server/connection/rpc-m
|
||||||
import { threadFromAppServerRecord } from "../../../../app-server/services/threads";
|
import { threadFromAppServerRecord } from "../../../../app-server/services/threads";
|
||||||
import { threadTokenUsageFromRuntimeUsage } from "../../../../domain/runtime/metrics";
|
import { threadTokenUsageFromRuntimeUsage } from "../../../../domain/runtime/metrics";
|
||||||
import { normalizeExplicitThreadName } from "../../../../domain/threads/model";
|
import { normalizeExplicitThreadName } from "../../../../domain/threads/model";
|
||||||
import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
|
import type { ThreadOperationEvent } from "../../../threads/workflows/thread-operation-event";
|
||||||
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
|
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
|
||||||
import { activeThreadSettingsAppliedAction } from "../../application/state/actions";
|
import { activeThreadSettingsAppliedAction } from "../../application/state/actions";
|
||||||
import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../../application/state/root-reducer";
|
import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../../application/state/root-reducer";
|
||||||
|
import type { SubagentActivityAction } from "../../application/state/subagent-activity";
|
||||||
import { planTurnRuntimeEvents, type TurnRuntimeOutcome } from "../../application/turns/runtime-event-plan";
|
import { planTurnRuntimeEvents, type TurnRuntimeOutcome } from "../../application/turns/runtime-event-plan";
|
||||||
|
import type { TurnRuntimeEvent } from "../../application/turns/runtime-events";
|
||||||
import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items";
|
import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items";
|
||||||
import { type DiagnosticStatusNotification, routeServerNotification, type ThreadLifecycleNotification } from "./notification-routing";
|
import { type DiagnosticStatusNotification, routeServerNotification, type ThreadLifecycleNotification } from "./notification-routing";
|
||||||
import { turnRuntimeEventsFromNotification } from "./runtime-events";
|
import { turnRuntimeEventsFromNotification } from "./runtime-events";
|
||||||
|
|
||||||
export type ChatNotificationEffect =
|
export type ChatNotificationEffect =
|
||||||
| { type: "refresh-threads" }
|
|
||||||
| {
|
| {
|
||||||
type: "maybe-name-thread";
|
type: "maybe-name-thread";
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
|
@ -21,9 +22,9 @@ export type ChatNotificationEffect =
|
||||||
}
|
}
|
||||||
| { type: "refresh-server-diagnostics"; forceResourceProbes?: boolean }
|
| { type: "refresh-server-diagnostics"; forceResourceProbes?: boolean }
|
||||||
| { type: "apply-app-server-resource-event"; event: AppServerResourceEvent }
|
| { type: "apply-app-server-resource-event"; event: AppServerResourceEvent }
|
||||||
| { type: "apply-thread-catalog-event"; event: ThreadCatalogEvent };
|
| { type: "apply-thread-operation-event"; event: ThreadOperationEvent };
|
||||||
|
|
||||||
type TurnRuntimeCompletedTurnTranscriptSummary = Extract<TurnRuntimeOutcome, { type: "turn-completed" }>["completedTurnTranscriptSummary"];
|
type TurnRuntimeCompletedTurnTranscriptSummary = TurnRuntimeOutcome["completedTurnTranscriptSummary"];
|
||||||
|
|
||||||
export interface ChatNotificationPlan {
|
export interface ChatNotificationPlan {
|
||||||
actions: readonly ChatAction[];
|
actions: readonly ChatAction[];
|
||||||
|
|
@ -45,6 +46,7 @@ export function planChatNotification(
|
||||||
});
|
});
|
||||||
switch (route.kind) {
|
switch (route.kind) {
|
||||||
case "inactive":
|
case "inactive":
|
||||||
|
return planTrackedSubagentNotification(state, route.scope.threadId, route.notification, localItemId);
|
||||||
case "ignored":
|
case "ignored":
|
||||||
case "unhandled":
|
case "unhandled":
|
||||||
return EMPTY_PLAN;
|
return EMPTY_PLAN;
|
||||||
|
|
@ -65,34 +67,188 @@ function runtimeEventsPlan(
|
||||||
notification: Parameters<typeof turnRuntimeEventsFromNotification>[0],
|
notification: Parameters<typeof turnRuntimeEventsFromNotification>[0],
|
||||||
localItemId: LocalItemIdProvider,
|
localItemId: LocalItemIdProvider,
|
||||||
): ChatNotificationPlan {
|
): ChatNotificationPlan {
|
||||||
const plan = planTurnRuntimeEvents(state, turnRuntimeEventsFromNotification(notification, localItemId));
|
const events = turnRuntimeEventsFromNotification(notification, localItemId);
|
||||||
|
const plan = planTurnRuntimeEvents(state, events);
|
||||||
return {
|
return {
|
||||||
actions: plan.actions,
|
actions: [
|
||||||
|
...plan.actions,
|
||||||
|
...subagentTrackingActionsFromParentEvents(state, events),
|
||||||
|
...subagentTrackingActionsFromActivityItem(state, notification),
|
||||||
|
],
|
||||||
effects: plan.outcomes.flatMap((outcome) => chatNotificationEffectsFromTurnRuntimeOutcome(state, outcome)),
|
effects: plan.outcomes.flatMap((outcome) => chatNotificationEffectsFromTurnRuntimeOutcome(state, outcome)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subagentTrackingActionsFromActivityItem(
|
||||||
|
state: ChatState,
|
||||||
|
notification: Parameters<typeof turnRuntimeEventsFromNotification>[0],
|
||||||
|
): SubagentActivityAction[] {
|
||||||
|
if (notification.method !== "item/started" && notification.method !== "item/completed") return [];
|
||||||
|
const item = notification.params.item;
|
||||||
|
if (item.type !== "subAgentActivity") return [];
|
||||||
|
const parentTurnId = activeTurnIdForState(state);
|
||||||
|
if (!parentTurnId || notification.params.turnId !== parentTurnId) return [];
|
||||||
|
const tracked: SubagentActivityAction = {
|
||||||
|
type: "subagent-activity/tracked",
|
||||||
|
threadId: item.agentThreadId,
|
||||||
|
parentTurnId,
|
||||||
|
};
|
||||||
|
return item.kind === "interrupted"
|
||||||
|
? [tracked, { type: "subagent-activity/execution-state-changed", threadId: item.agentThreadId, executionState: "failed" }]
|
||||||
|
: [tracked];
|
||||||
|
}
|
||||||
|
|
||||||
|
function planTrackedSubagentNotification(
|
||||||
|
state: ChatState,
|
||||||
|
threadId: string | null,
|
||||||
|
notification: ServerNotification,
|
||||||
|
localItemId: LocalItemIdProvider,
|
||||||
|
): ChatNotificationPlan {
|
||||||
|
if (!threadId || !state.subagentActivity.byThreadId.has(threadId)) return EMPTY_PLAN;
|
||||||
|
const events = subagentRuntimeEvents(notification, localItemId);
|
||||||
|
if (!events) return EMPTY_PLAN;
|
||||||
|
const actions = events.flatMap((event) => subagentActivityActionsFromRuntimeEvent(threadId, notification.method, event));
|
||||||
|
return actions.length > 0 ? { actions, effects: [] } : EMPTY_PLAN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentRuntimeEvents(notification: ServerNotification, localItemId: LocalItemIdProvider): readonly TurnRuntimeEvent[] | null {
|
||||||
|
switch (notification.method) {
|
||||||
|
case "item/agentMessage/delta":
|
||||||
|
case "item/plan/delta":
|
||||||
|
case "turn/plan/updated":
|
||||||
|
case "item/reasoning/summaryTextDelta":
|
||||||
|
case "item/reasoning/textDelta":
|
||||||
|
case "item/reasoning/summaryPartAdded":
|
||||||
|
case "item/started":
|
||||||
|
case "item/completed":
|
||||||
|
case "item/commandExecution/outputDelta":
|
||||||
|
case "item/fileChange/patchUpdated":
|
||||||
|
case "turn/diff/updated":
|
||||||
|
case "hook/started":
|
||||||
|
case "hook/completed":
|
||||||
|
case "item/mcpToolCall/progress":
|
||||||
|
case "item/autoApprovalReview/started":
|
||||||
|
case "item/autoApprovalReview/completed":
|
||||||
|
case "guardianWarning":
|
||||||
|
case "turn/started":
|
||||||
|
case "turn/completed":
|
||||||
|
return turnRuntimeEventsFromNotification(notification, localItemId);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentActivityActionsFromRuntimeEvent(
|
||||||
|
threadId: string,
|
||||||
|
notificationMethod: ServerNotification["method"],
|
||||||
|
event: TurnRuntimeEvent,
|
||||||
|
): SubagentActivityAction[] {
|
||||||
|
switch (event.type) {
|
||||||
|
case "turnStarted":
|
||||||
|
return [{ type: "subagent-activity/turn-started", threadId, childTurnId: event.turnId }];
|
||||||
|
case "turnCompleted":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "subagent-activity/turn-completed",
|
||||||
|
threadId,
|
||||||
|
childTurnId: event.turnId,
|
||||||
|
items: event.completedItems,
|
||||||
|
executionState: event.status === "completed" ? "completed" : "failed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "itemUpserted":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "subagent-activity/item-observed",
|
||||||
|
threadId,
|
||||||
|
item: event.item,
|
||||||
|
advance: notificationMethod === "item/started" || notificationMethod === "turn/plan/updated",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "itemCompleted":
|
||||||
|
return [{ type: "subagent-activity/item-observed", threadId, item: event.item, advance: false }];
|
||||||
|
case "assistantDelta":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "subagent-activity/assistant-delta-appended",
|
||||||
|
threadId,
|
||||||
|
childTurnId: event.turnId,
|
||||||
|
itemId: event.itemId,
|
||||||
|
delta: event.delta,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "planDelta":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "subagent-activity/plan-delta-appended",
|
||||||
|
threadId,
|
||||||
|
childTurnId: event.turnId,
|
||||||
|
itemId: event.itemId,
|
||||||
|
delta: event.delta,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "textDelta":
|
||||||
|
if (notificationMethod === "item/reasoning/textDelta") return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "subagent-activity/text-delta-appended",
|
||||||
|
threadId,
|
||||||
|
childTurnId: event.turnId,
|
||||||
|
itemId: event.itemId,
|
||||||
|
label: event.label,
|
||||||
|
delta: event.delta,
|
||||||
|
kind: event.kind,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "toolOutputDelta":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "subagent-activity/tool-output-appended",
|
||||||
|
threadId,
|
||||||
|
childTurnId: event.turnId,
|
||||||
|
itemId: event.itemId,
|
||||||
|
delta: event.delta,
|
||||||
|
fallbackLabel: event.fallbackLabel,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "hookRunObserved":
|
||||||
|
return event.turnId
|
||||||
|
? [{ type: "subagent-activity/item-observed", threadId, item: { ...event.item, turnId: event.turnId }, advance: true }]
|
||||||
|
: [];
|
||||||
|
case "autoReviewUpdated":
|
||||||
|
case "reviewWarning":
|
||||||
|
return [{ type: "subagent-activity/item-observed", threadId, item: event.item, advance: true }];
|
||||||
|
case "itemOutputDelta":
|
||||||
|
case "turnDiffUpdated":
|
||||||
|
case "requestResolved":
|
||||||
|
case "systemNotice":
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentTrackingActionsFromParentEvents(state: ChatState, events: readonly TurnRuntimeEvent[]): SubagentActivityAction[] {
|
||||||
|
const parentTurnId = activeTurnIdForState(state);
|
||||||
|
if (!parentTurnId) return [];
|
||||||
|
const threadIds = new Set<string>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type !== "itemUpserted" && event.type !== "itemCompleted") continue;
|
||||||
|
if (event.item.kind !== "agent" || event.item.turnId !== parentTurnId) continue;
|
||||||
|
for (const threadId of event.item.receiverThreadIds) threadIds.add(threadId);
|
||||||
|
for (const agent of event.item.agents) threadIds.add(agent.threadId);
|
||||||
|
}
|
||||||
|
return [...threadIds].map((threadId) => ({ type: "subagent-activity/tracked", threadId, parentTurnId }));
|
||||||
|
}
|
||||||
|
|
||||||
function chatNotificationEffectsFromTurnRuntimeOutcome(state: ChatState, outcome: TurnRuntimeOutcome): readonly ChatNotificationEffect[] {
|
function chatNotificationEffectsFromTurnRuntimeOutcome(state: ChatState, outcome: TurnRuntimeOutcome): readonly ChatNotificationEffect[] {
|
||||||
if (activeThreadState(state)?.lifetime?.kind === "ephemeral") return [];
|
if (activeThreadState(state)?.lifetime?.kind === "ephemeral") return [];
|
||||||
switch (outcome.type) {
|
return [
|
||||||
case "turn-started":
|
{
|
||||||
return [
|
type: "maybe-name-thread",
|
||||||
{
|
threadId: outcome.threadId,
|
||||||
type: "apply-thread-catalog-event",
|
turnId: outcome.turnId,
|
||||||
event: { type: "thread-touched", threadId: outcome.threadId, recencyAt: outcome.recencyAt },
|
completedTurnTranscriptSummary: outcome.completedTurnTranscriptSummary,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
case "turn-completed":
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: "maybe-name-thread",
|
|
||||||
threadId: outcome.threadId,
|
|
||||||
turnId: outcome.turnId,
|
|
||||||
completedTurnTranscriptSummary: outcome.completedTurnTranscriptSummary,
|
|
||||||
},
|
|
||||||
{ type: "refresh-threads" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function planDiagnosticStatus(notification: DiagnosticStatusNotification): ChatNotificationPlan {
|
function planDiagnosticStatus(notification: DiagnosticStatusNotification): ChatNotificationPlan {
|
||||||
|
|
@ -135,17 +291,23 @@ function planThreadLifecycle(
|
||||||
case "thread/started":
|
case "thread/started":
|
||||||
return threadStartedPlan(state, notification);
|
return threadStartedPlan(state, notification);
|
||||||
case "thread/archived":
|
case "thread/archived":
|
||||||
return effectPlan({ type: "apply-thread-catalog-event", event: { type: "thread-archived", threadId: notification.params.threadId } });
|
return effectPlan({
|
||||||
|
type: "apply-thread-operation-event",
|
||||||
|
event: { type: "thread-archived", threadId: notification.params.threadId },
|
||||||
|
});
|
||||||
case "thread/deleted":
|
case "thread/deleted":
|
||||||
return effectPlan({ type: "apply-thread-catalog-event", event: { type: "thread-deleted", threadId: notification.params.threadId } });
|
return effectPlan({
|
||||||
|
type: "apply-thread-operation-event",
|
||||||
|
event: { type: "thread-deleted", threadId: notification.params.threadId },
|
||||||
|
});
|
||||||
case "thread/unarchived":
|
case "thread/unarchived":
|
||||||
return effectPlan({
|
return effectPlan({
|
||||||
type: "apply-thread-catalog-event",
|
type: "apply-thread-operation-event",
|
||||||
event: { type: "thread-unarchived", threadId: notification.params.threadId },
|
event: { type: "thread-unarchived", threadId: notification.params.threadId },
|
||||||
});
|
});
|
||||||
case "thread/name/updated":
|
case "thread/name/updated":
|
||||||
return effectPlan({
|
return effectPlan({
|
||||||
type: "apply-thread-catalog-event",
|
type: "apply-thread-operation-event",
|
||||||
event: {
|
event: {
|
||||||
type: "thread-renamed",
|
type: "thread-renamed",
|
||||||
threadId: notification.params.threadId,
|
threadId: notification.params.threadId,
|
||||||
|
|
@ -167,19 +329,25 @@ function threadStartedPlan(
|
||||||
notification: Extract<ThreadLifecycleNotification, { method: "thread/started" }>,
|
notification: Extract<ThreadLifecycleNotification, { method: "thread/started" }>,
|
||||||
): ChatNotificationPlan {
|
): ChatNotificationPlan {
|
||||||
const thread = threadFromAppServerRecord(notification.params.thread);
|
const thread = threadFromAppServerRecord(notification.params.thread);
|
||||||
|
const activeParentTurnId = activeTurnIdForState(state);
|
||||||
|
const trackAction: SubagentActivityAction[] =
|
||||||
|
thread.provenance.kind === "subagent" && thread.provenance.parentThreadId === activeThreadId(state) && activeParentTurnId
|
||||||
|
? [{ type: "subagent-activity/tracked", threadId: thread.id, parentTurnId: activeParentTurnId }]
|
||||||
|
: [];
|
||||||
const effects: ChatNotificationEffect[] =
|
const effects: ChatNotificationEffect[] =
|
||||||
notification.params.thread.ephemeral || thread.provenance.kind === "subagent"
|
notification.params.thread.ephemeral || thread.provenance.kind === "subagent"
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
type: "apply-thread-catalog-event",
|
type: "apply-thread-operation-event",
|
||||||
event: { type: "thread-started", thread },
|
event: {
|
||||||
|
type: "thread-upserted",
|
||||||
|
thread,
|
||||||
|
forkedFromThreadId: notification.params.thread.forkedFromId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (activeThreadId(state) === notification.params.thread.id) {
|
return { actions: trackAction, effects };
|
||||||
return { actions: [{ type: "active-thread/cwd-set", cwd: notification.params.thread.cwd }], effects };
|
|
||||||
}
|
|
||||||
return { actions: [], effects };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadGoalPlan(
|
function threadGoalPlan(
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export type ServerNotificationRoute =
|
||||||
| { kind: "userVisibleNotice"; notification: UserVisibleNoticeNotification }
|
| { kind: "userVisibleNotice"; notification: UserVisibleNoticeNotification }
|
||||||
| { kind: "ignored"; notification: ServerNotification }
|
| { kind: "ignored"; notification: ServerNotification }
|
||||||
| { kind: "unhandled"; notification: ServerNotification }
|
| { kind: "unhandled"; notification: ServerNotification }
|
||||||
| { kind: "inactive"; notification: ServerNotification };
|
| { kind: "inactive"; notification: ServerNotification; scope: AppServerRouteScope };
|
||||||
|
|
||||||
type ServerNotificationScopeExtractors = Partial<{
|
type ServerNotificationScopeExtractors = Partial<{
|
||||||
[Method in ServerNotificationMethod]: (notification: Extract<ServerNotification, { method: Method }>) => AppServerRouteScope;
|
[Method in ServerNotificationMethod]: (notification: Extract<ServerNotification, { method: Method }>) => AppServerRouteScope;
|
||||||
|
|
@ -105,6 +105,9 @@ const IGNORED_SERVER_NOTIFICATION_METHODS = [
|
||||||
"thread/status/changed",
|
"thread/status/changed",
|
||||||
"thread/closed",
|
"thread/closed",
|
||||||
"rawResponseItem/completed",
|
"rawResponseItem/completed",
|
||||||
|
"rawResponse/completed",
|
||||||
|
"thread/environment/connected",
|
||||||
|
"thread/environment/disconnected",
|
||||||
"command/exec/outputDelta",
|
"command/exec/outputDelta",
|
||||||
"process/outputDelta",
|
"process/outputDelta",
|
||||||
"process/exited",
|
"process/exited",
|
||||||
|
|
@ -157,6 +160,7 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors =
|
||||||
"item/autoApprovalReview/completed": threadTurnNotificationScope,
|
"item/autoApprovalReview/completed": threadTurnNotificationScope,
|
||||||
"item/completed": threadTurnNotificationScope,
|
"item/completed": threadTurnNotificationScope,
|
||||||
"rawResponseItem/completed": threadTurnNotificationScope,
|
"rawResponseItem/completed": threadTurnNotificationScope,
|
||||||
|
"rawResponse/completed": threadTurnNotificationScope,
|
||||||
"item/agentMessage/delta": threadTurnNotificationScope,
|
"item/agentMessage/delta": threadTurnNotificationScope,
|
||||||
"item/plan/delta": threadTurnNotificationScope,
|
"item/plan/delta": threadTurnNotificationScope,
|
||||||
"command/exec/outputDelta": unscopedNotificationScope,
|
"command/exec/outputDelta": unscopedNotificationScope,
|
||||||
|
|
@ -200,13 +204,15 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors =
|
||||||
"windows/worldWritableWarning": unscopedNotificationScope,
|
"windows/worldWritableWarning": unscopedNotificationScope,
|
||||||
"windowsSandbox/setupCompleted": unscopedNotificationScope,
|
"windowsSandbox/setupCompleted": unscopedNotificationScope,
|
||||||
"account/login/completed": unscopedNotificationScope,
|
"account/login/completed": unscopedNotificationScope,
|
||||||
|
"thread/environment/connected": threadOnlyNotificationScope,
|
||||||
|
"thread/environment/disconnected": threadOnlyNotificationScope,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function routeServerNotification(notification: ServerNotification, scope: ActiveRouteScope): ServerNotificationRoute {
|
export function routeServerNotification(notification: ServerNotification, scope: ActiveRouteScope): ServerNotificationRoute {
|
||||||
if (isThreadCatalogNotification(notification)) return { kind: "threadLifecycle", notification };
|
if (isThreadCatalogNotification(notification)) return { kind: "threadLifecycle", notification };
|
||||||
const routeScope = serverNotificationScope(notification);
|
const routeScope = serverNotificationScope(notification);
|
||||||
if (!isAppServerRouteScopeInActiveRouteScope(routeScope, scope)) return { kind: "inactive", notification };
|
if (!isAppServerRouteScopeInActiveRouteScope(routeScope, scope)) return { kind: "inactive", notification, scope: routeScope };
|
||||||
if (isIdleThreadStreamUpdate(notification, routeScope, scope)) return { kind: "inactive", notification };
|
if (isIdleThreadStreamUpdate(notification, routeScope, scope)) return { kind: "inactive", notification, scope: routeScope };
|
||||||
|
|
||||||
if (isStreamUpdateNotification(notification)) return { kind: "streamUpdate", notification };
|
if (isStreamUpdateNotification(notification)) return { kind: "streamUpdate", notification };
|
||||||
if (isTurnLifecycleNotification(notification)) return { kind: "turnLifecycle", notification };
|
if (isTurnLifecycleNotification(notification)) return { kind: "turnLifecycle", notification };
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,6 @@ export function turnRuntimeEventsFromNotification(
|
||||||
type: "turnStarted",
|
type: "turnStarted",
|
||||||
threadId: notification.params.threadId,
|
threadId: notification.params.threadId,
|
||||||
turnId: notification.params.turn.id,
|
turnId: notification.params.turn.id,
|
||||||
recencyAt: notification.params.turn.startedAt,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
case "turn/completed":
|
case "turn/completed":
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/execution-state";
|
import { type ExecutionState, RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/items";
|
||||||
import type { ExecutionState } from "../../../domain/thread-stream/items";
|
|
||||||
|
|
||||||
export { RUNNING_EXECUTION_STATE };
|
export { RUNNING_EXECUTION_STATE };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ import {
|
||||||
completedTurnTranscriptSummaryFromTurnRecord,
|
completedTurnTranscriptSummaryFromTurnRecord,
|
||||||
type TurnItem,
|
type TurnItem,
|
||||||
type TurnRecord,
|
type TurnRecord,
|
||||||
turnUserItemText,
|
turnUserItemProjection,
|
||||||
} from "../../../../../app-server/protocol/turn";
|
} from "../../../../../app-server/protocol/turn";
|
||||||
import { jsonPreview } from "../../../../../domain/display/json-preview";
|
import { jsonPreview } from "../../../../../domain/display/json-preview";
|
||||||
import type { HistoricalTurn } from "../../../../../domain/threads/history";
|
import type { HistoricalTurn } from "../../../../../domain/threads/history";
|
||||||
import { referencedThreadMetadataFromPrompt } from "../../../../../domain/threads/reference";
|
|
||||||
import type { TurnTranscriptSummary } from "../../../../../domain/threads/transcript";
|
import type { TurnTranscriptSummary } from "../../../../../domain/threads/transcript";
|
||||||
import { fileMentionsFromInput } from "../../../domain/thread-stream/format/file-mentions";
|
import { contextAttachmentsFromHistoryContexts } from "../../../domain/thread-stream/format/context-attachments";
|
||||||
|
import { threadStreamFileReferences } from "../../../domain/thread-stream/format/file-references";
|
||||||
import { normalizeProposedPlanMarkdown } from "../../../domain/thread-stream/format/proposed-plan";
|
import { normalizeProposedPlanMarkdown } from "../../../domain/thread-stream/format/proposed-plan";
|
||||||
import { userMessageDisplayText } from "../../../domain/thread-stream/format/user-message-text";
|
import { userMessageDisplayText } from "../../../domain/thread-stream/format/user-message-text";
|
||||||
import type { CommandThreadStreamTarget, ThreadStreamDiagnosticSection, ThreadStreamItem } from "../../../domain/thread-stream/items";
|
import type { CommandThreadStreamTarget, ThreadStreamDiagnosticSection, ThreadStreamItem } from "../../../domain/thread-stream/items";
|
||||||
|
|
@ -130,20 +130,23 @@ function turnItemSourceFields(item: { id: string }, turnId?: string): TurnItemSo
|
||||||
}
|
}
|
||||||
|
|
||||||
function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStreamItem {
|
function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStreamItem {
|
||||||
const text = turnUserItemText(item);
|
const projection = turnUserItemProjection(item);
|
||||||
const referencedThread = referencedThreadMetadataFromPrompt(text);
|
const text = projection.text;
|
||||||
const mentionedFiles = fileMentionsFromInput(item.content);
|
const referencedThread = projection.referencedThread;
|
||||||
|
const referencedFiles = threadStreamFileReferences(projection.fileReferences);
|
||||||
|
const contextAttachments = contextAttachmentsFromHistoryContexts(projection.contexts, text);
|
||||||
if (referencedThread) {
|
if (referencedThread) {
|
||||||
return {
|
return {
|
||||||
...turnItemSourceFields(item, turnId),
|
...turnItemSourceFields(item, turnId),
|
||||||
kind: "dialogue",
|
kind: "dialogue",
|
||||||
dialogueKind: "user",
|
dialogueKind: "user",
|
||||||
role: "user",
|
role: "user",
|
||||||
text: userMessageDisplayText(referencedThread.text, item.content),
|
text: userMessageDisplayText(text, item.content),
|
||||||
copyText: referencedThread.text,
|
copyText: text,
|
||||||
referencedThread: referencedThread.reference,
|
referencedThread,
|
||||||
...definedProp("clientId", item.clientId),
|
...definedProp("clientId", item.clientId),
|
||||||
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
|
...(referencedFiles.length > 0 ? { referencedFiles } : {}),
|
||||||
|
...(contextAttachments.length > 0 ? { contextAttachments } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -154,7 +157,8 @@ function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStr
|
||||||
text: userMessageDisplayText(text, item.content),
|
text: userMessageDisplayText(text, item.content),
|
||||||
copyText: text,
|
copyText: text,
|
||||||
...definedProp("clientId", item.clientId),
|
...definedProp("clientId", item.clientId),
|
||||||
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
|
...(referencedFiles.length > 0 ? { referencedFiles } : {}),
|
||||||
|
...(contextAttachments.length > 0 ? { contextAttachments } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { AppServerClient } from "../../../app-server/connection/client";
|
import type { AppServerClient } from "../../../app-server/connection/client";
|
||||||
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../../../app-server/connection/client-access";
|
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../../../app-server/connection/client-access";
|
||||||
import { withShortLivedAppServerClient } from "../../../app-server/connection/short-lived-client";
|
|
||||||
import type { CodexInput } from "../../../domain/chat/input";
|
import type { CodexInput } from "../../../domain/chat/input";
|
||||||
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
|
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
|
||||||
import { createThreadReferenceResolver, type ThreadReferenceResolver } from "./thread-reference-resolver";
|
import { createThreadReferenceResolver, type ThreadReferenceResolver } from "./thread-reference-resolver";
|
||||||
|
|
@ -13,7 +12,7 @@ import {
|
||||||
} from "./transports/session-transports";
|
} from "./transports/session-transports";
|
||||||
|
|
||||||
export interface ChatCurrentAppServerGatewayHost {
|
export interface ChatCurrentAppServerGatewayHost {
|
||||||
codexPath(): string;
|
fallbackClientAccess: AppServerClientAccess;
|
||||||
vaultPath: string;
|
vaultPath: string;
|
||||||
currentClient(): AppServerClient | null;
|
currentClient(): AppServerClient | null;
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +73,7 @@ function createCurrentClientAccess(host: ChatCurrentAppServerGatewayHost): AppSe
|
||||||
return {
|
return {
|
||||||
withClient: async (operation, options: AppServerClientAccessOptions = {}) => {
|
withClient: async (operation, options: AppServerClientAccessOptions = {}) => {
|
||||||
if (options.serverRequests?.kind === "reject") {
|
if (options.serverRequests?.kind === "reject") {
|
||||||
return withShortLivedAppServerClient(host.codexPath(), host.vaultPath, operation, options);
|
return host.fallbackClientAccess.withClient(operation, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = host.currentClient();
|
const client = host.currentClient();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import type { AppServerRequestClient } from "../../../app-server/services/request-client";
|
import type { AppServerRequestClient } from "../../../app-server/services/request-client";
|
||||||
import { readReferencedThreadTurnTranscriptSummaries } from "../../../app-server/services/threads";
|
import { readReferencedThreadTurnTranscriptSummaries } from "../../../app-server/services/threads";
|
||||||
import { type CodexInput, codexTextInputWithAttachments } from "../../../domain/chat/input";
|
import { type CodexInput, codexTextInputWithAttachments } from "../../../domain/chat/input";
|
||||||
|
import { threadReferenceMarkdown } from "../../../domain/threads/deep-link";
|
||||||
import { shortThreadId } from "../../../domain/threads/id";
|
import { shortThreadId } from "../../../domain/threads/id";
|
||||||
import type { Thread } from "../../../domain/threads/model";
|
import type { Thread } from "../../../domain/threads/model";
|
||||||
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadPromptBundle } from "../../../domain/threads/reference";
|
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadContext } from "../../../domain/threads/reference";
|
||||||
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
|
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
|
||||||
import type { ThreadReferenceInput } from "../application/turns/slash-command-execution";
|
import type { ThreadReferenceInput } from "../application/turns/slash-command-execution";
|
||||||
|
|
||||||
|
|
@ -40,12 +41,20 @@ async function referencedThreadInput(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const messageInput = host.prepareInput(message, snapshot);
|
const messageInput = host.prepareInput(message, snapshot);
|
||||||
const reference = referencedThreadPromptBundle(thread, turns, messageInput.text);
|
const reference = referencedThreadContext(thread, turns);
|
||||||
|
const visibleText = `${threadReferenceMarkdown(thread)}\n\n${messageInput.text}`;
|
||||||
host.setStatus(`Referencing ${shortThreadId(thread.id)} (${String(turns.length)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`);
|
host.setStatus(`Referencing ${shortThreadId(thread.id)} (${String(turns.length)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`);
|
||||||
return {
|
return {
|
||||||
text: messageInput.text,
|
text: visibleText,
|
||||||
input: codexTextInputWithAttachments(reference.prompt, messageInput.input),
|
input: codexTextInputWithAttachments(visibleText, [
|
||||||
referencedThread: reference.referencedThread,
|
{
|
||||||
|
type: "additionalContext",
|
||||||
|
key: "codex_panel_referenced_thread",
|
||||||
|
kind: "untrusted",
|
||||||
|
value: reference,
|
||||||
|
},
|
||||||
|
...messageInput.input,
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (host.currentClient() !== client) return null;
|
if (host.currentClient() !== client) return null;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
readThreadGoal,
|
readThreadGoal,
|
||||||
recordThreadGoalUserMessage,
|
recordThreadGoalUserMessage,
|
||||||
resumeThread,
|
resumeThread,
|
||||||
rollbackThread,
|
|
||||||
setThreadGoal,
|
setThreadGoal,
|
||||||
startThread,
|
startThread,
|
||||||
threadActivationSnapshotFromAppServerResponse,
|
threadActivationSnapshotFromAppServerResponse,
|
||||||
|
|
@ -20,8 +19,9 @@ import {
|
||||||
import { interruptTurn, startTurn, steerTurn } from "../../../../app-server/services/turns";
|
import { interruptTurn, startTurn, steerTurn } from "../../../../app-server/services/turns";
|
||||||
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
|
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
|
||||||
import type { ThreadTurnsPage } from "../../../../domain/threads/history";
|
import type { ThreadTurnsPage } from "../../../../domain/threads/history";
|
||||||
|
import type { EffectOutcome } from "../../application/effect-outcome";
|
||||||
import type { RuntimeSettingsTransport } from "../../application/runtime/settings-transport";
|
import type { RuntimeSettingsTransport } from "../../application/runtime/settings-transport";
|
||||||
import type { EphemeralThreadTransport } from "../../application/threads/ephemeral-thread-transport";
|
import type { EphemeralThreadForkResult, EphemeralThreadTransport } from "../../application/threads/ephemeral-thread-transport";
|
||||||
import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../../application/threads/goal-transport";
|
import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../../application/threads/goal-transport";
|
||||||
import type {
|
import type {
|
||||||
ThreadHistoryPage,
|
ThreadHistoryPage,
|
||||||
|
|
@ -29,8 +29,9 @@ import type {
|
||||||
ThreadResumeSnapshot,
|
ThreadResumeSnapshot,
|
||||||
ThreadResumeTransport,
|
ThreadResumeTransport,
|
||||||
} from "../../application/threads/thread-loading-transport";
|
} from "../../application/threads/thread-loading-transport";
|
||||||
import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport";
|
import type { ThreadMutationTransport } from "../../application/threads/thread-mutation-transport";
|
||||||
import type { ThreadStartTransport } from "../../application/threads/thread-start-transport";
|
import type { ThreadStartTransport } from "../../application/threads/thread-start-transport";
|
||||||
|
import type { ThreadSubscriptionTransport } from "../../application/threads/thread-subscription-transport";
|
||||||
import type { ChatTurnTransport } from "../../application/turns/turn-transport";
|
import type { ChatTurnTransport } from "../../application/turns/turn-transport";
|
||||||
import { threadStreamItemsFromTurns } from "../mappers/thread-stream/turn-items";
|
import { threadStreamItemsFromTurns } from "../mappers/thread-stream/turn-items";
|
||||||
|
|
||||||
|
|
@ -65,6 +66,7 @@ export interface ChatConnectedSessionTransports {
|
||||||
readonly threadResume: ThreadResumeTransport;
|
readonly threadResume: ThreadResumeTransport;
|
||||||
readonly threadMutation: ThreadMutationTransport;
|
readonly threadMutation: ThreadMutationTransport;
|
||||||
readonly threadEphemeral: EphemeralThreadTransport;
|
readonly threadEphemeral: EphemeralThreadTransport;
|
||||||
|
readonly threadSubscription: ThreadSubscriptionTransport;
|
||||||
readonly threadGoal: ThreadGoalTransport;
|
readonly threadGoal: ThreadGoalTransport;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +85,7 @@ export function createChatConnectedSessionTransports(host: ChatAppServerTranspor
|
||||||
threadResume: createChatThreadResumeTransport(host),
|
threadResume: createChatThreadResumeTransport(host),
|
||||||
threadMutation: createChatThreadMutationTransport(host),
|
threadMutation: createChatThreadMutationTransport(host),
|
||||||
threadEphemeral: createChatEphemeralThreadTransport(host),
|
threadEphemeral: createChatEphemeralThreadTransport(host),
|
||||||
|
threadSubscription: createChatThreadSubscriptionTransport(host),
|
||||||
threadGoal: createChatThreadGoalTransport(host),
|
threadGoal: createChatThreadGoalTransport(host),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +93,7 @@ export function createChatConnectedSessionTransports(host: ChatAppServerTranspor
|
||||||
function createChatThreadStartTransport(host: ChatCurrentAppServerTransportHost): ThreadStartTransport {
|
function createChatThreadStartTransport(host: ChatCurrentAppServerTransportHost): ThreadStartTransport {
|
||||||
return {
|
return {
|
||||||
startThread: (request) =>
|
startThread: (request) =>
|
||||||
withCurrentChatAppServerClient(host, async (client) => {
|
runCurrentChatAppServerEffect(host, async (client) => {
|
||||||
const response = await startThread(client, {
|
const response = await startThread(client, {
|
||||||
cwd: host.vaultPath,
|
cwd: host.vaultPath,
|
||||||
serviceTier: request.serviceTier,
|
serviceTier: request.serviceTier,
|
||||||
|
|
@ -105,7 +108,7 @@ function createChatTurnTransport(host: ChatAppServerTransportHost): ChatTurnTran
|
||||||
return {
|
return {
|
||||||
ensureConnected: async () => (await host.connectedClient()) !== null,
|
ensureConnected: async () => (await host.connectedClient()) !== null,
|
||||||
startTurn: (request) =>
|
startTurn: (request) =>
|
||||||
withCurrentChatAppServerClient(host, async (client) => {
|
runCurrentChatAppServerEffect(host, async (client) => {
|
||||||
const response = await startTurn(client, {
|
const response = await startTurn(client, {
|
||||||
threadId: request.threadId,
|
threadId: request.threadId,
|
||||||
cwd: host.vaultPath,
|
cwd: host.vaultPath,
|
||||||
|
|
@ -114,13 +117,10 @@ function createChatTurnTransport(host: ChatAppServerTransportHost): ChatTurnTran
|
||||||
});
|
});
|
||||||
return { turnId: response.turn.id };
|
return { turnId: response.turn.id };
|
||||||
}),
|
}),
|
||||||
steerTurn: async (request) => {
|
steerTurn: (request) =>
|
||||||
const steered = await withCurrentChatAppServerClient(host, async (client) => {
|
runCurrentChatAppServerEffect(host, async (client) => {
|
||||||
await steerTurn(client, request.threadId, request.turnId, request.input, request.clientUserMessageId);
|
await steerTurn(client, request.threadId, request.turnId, request.input, request.clientUserMessageId);
|
||||||
return true;
|
}),
|
||||||
});
|
|
||||||
return steered ?? false;
|
|
||||||
},
|
|
||||||
interruptTurn: async (threadId, turnId) => {
|
interruptTurn: async (threadId, turnId) => {
|
||||||
const interrupted = await withCurrentChatAppServerClient(host, async (client) => {
|
const interrupted = await withCurrentChatAppServerClient(host, async (client) => {
|
||||||
await interruptTurn(client, threadId, turnId);
|
await interruptTurn(client, threadId, turnId);
|
||||||
|
|
@ -152,48 +152,39 @@ function createChatThreadHistoryTransport(host: CurrentChatAppServerClientHost):
|
||||||
|
|
||||||
function createChatThreadResumeTransport(host: ChatAppServerTransportHost): ThreadResumeTransport {
|
function createChatThreadResumeTransport(host: ChatAppServerTransportHost): ThreadResumeTransport {
|
||||||
return {
|
return {
|
||||||
resumeThread: (threadId): Promise<ThreadResumeSnapshot | null> =>
|
ensureConnected: async () => (await host.connectedClient()) !== null,
|
||||||
withConnectedChatAppServerClient(host, (client) => resumeChatThread(client, threadId, host.vaultPath)),
|
resumeThread: (threadId): Promise<EffectOutcome<ThreadResumeSnapshot>> =>
|
||||||
|
runCurrentChatAppServerEffect(host, (client) => resumeChatThread(client, threadId, host.vaultPath)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChatThreadMutationTransport(host: ChatAppServerTransportHost): ThreadMutationTransport {
|
function createChatThreadMutationTransport(host: ChatAppServerTransportHost): ThreadMutationTransport {
|
||||||
return {
|
return {
|
||||||
compactThread: async (threadId) => {
|
ensureConnected: async () => (await host.connectedClient()) !== null,
|
||||||
const result = await withConnectedChatAppServerClient(host, async (client) => {
|
compactThread: (threadId) =>
|
||||||
|
runCurrentChatAppServerEffect(host, async (client) => {
|
||||||
await compactThread(client, threadId);
|
await compactThread(client, threadId);
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return result ?? false;
|
|
||||||
},
|
|
||||||
forkThread: (threadId, lastTurnId = null) =>
|
|
||||||
withConnectedChatAppServerClient(host, (client) => forkThread(client, threadId, host.vaultPath, lastTurnId)),
|
|
||||||
rollbackThread: (threadId) =>
|
|
||||||
withConnectedChatAppServerClient(host, async (client): Promise<ThreadRollbackSnapshot> => {
|
|
||||||
const snapshot = await rollbackThread(client, threadId);
|
|
||||||
return {
|
|
||||||
thread: snapshot.thread,
|
|
||||||
cwd: snapshot.cwd,
|
|
||||||
items: threadStreamItemsFromTurns(snapshot.turns),
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
|
forkThread: (threadId, options) =>
|
||||||
|
runCurrentChatAppServerEffect(host, (client) => forkThread(client, threadId, host.vaultPath, options)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChatEphemeralThreadTransport(host: ChatAppServerTransportHost): EphemeralThreadTransport {
|
function createChatEphemeralThreadTransport(host: ChatAppServerTransportHost): EphemeralThreadTransport {
|
||||||
return {
|
return {
|
||||||
forkEphemeralThread: (sourceThreadId) =>
|
forkEphemeralThread: async (sourceThreadId) => {
|
||||||
withConnectedChatAppServerClient(host, async (client) => {
|
const client = host.currentClient();
|
||||||
try {
|
if (!client) return { kind: "not-started" };
|
||||||
const snapshot = await forkEphemeralThread(client, sourceThreadId, host.vaultPath);
|
const value = await forkEphemeralThreadResult(client, sourceThreadId, host.vaultPath);
|
||||||
return { kind: "ready" as const, ...snapshot };
|
if (!chatAppServerClientIsStale(host, client)) return { kind: "completed-current", value };
|
||||||
} catch (error) {
|
const threadId = value.kind === "ready" ? value.activation.thread.id : value.threadId;
|
||||||
if (error instanceof EphemeralThreadCleanupRequiredError) {
|
try {
|
||||||
return { kind: "cleanup-required" as const, threadId: error.threadId };
|
await unsubscribeThread(client, threadId, { timeoutMs: 5_000 });
|
||||||
}
|
} catch {
|
||||||
throw error;
|
// The superseded connection remains the only valid cleanup context.
|
||||||
}
|
}
|
||||||
}),
|
return { kind: "completed-stale", value };
|
||||||
|
},
|
||||||
unsubscribeEphemeralThread: async (threadId) => {
|
unsubscribeEphemeralThread: async (threadId) => {
|
||||||
const result = await withCurrentChatAppServerClient(host, async (client) => {
|
const result = await withCurrentChatAppServerClient(host, async (client) => {
|
||||||
await unsubscribeThread(client, threadId, { timeoutMs: 5_000 });
|
await unsubscribeThread(client, threadId, { timeoutMs: 5_000 });
|
||||||
|
|
@ -204,6 +195,34 @@ function createChatEphemeralThreadTransport(host: ChatAppServerTransportHost): E
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function forkEphemeralThreadResult(
|
||||||
|
client: AppServerRequestClient,
|
||||||
|
sourceThreadId: string,
|
||||||
|
vaultPath: string,
|
||||||
|
): Promise<EphemeralThreadForkResult> {
|
||||||
|
try {
|
||||||
|
const snapshot = await forkEphemeralThread(client, sourceThreadId, vaultPath);
|
||||||
|
return { kind: "ready", ...snapshot };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof EphemeralThreadCleanupRequiredError) {
|
||||||
|
return { kind: "cleanup-required", threadId: error.threadId };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChatThreadSubscriptionTransport(host: ChatAppServerTransportHost): ThreadSubscriptionTransport {
|
||||||
|
return {
|
||||||
|
unsubscribeThread: async (threadId) => {
|
||||||
|
const result = await withCurrentChatAppServerClient(host, async (client) => {
|
||||||
|
await unsubscribeThread(client, threadId, { timeoutMs: 5_000 });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return result ?? false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createChatThreadGoalReadTransport(host: CurrentChatAppServerClientHost): ThreadGoalReadTransport {
|
function createChatThreadGoalReadTransport(host: CurrentChatAppServerClientHost): ThreadGoalReadTransport {
|
||||||
return {
|
return {
|
||||||
readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId),
|
readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId),
|
||||||
|
|
@ -215,18 +234,12 @@ function createChatThreadGoalTransport(host: ConnectedChatAppServerClientHost):
|
||||||
ensureConnected: async () => (await host.connectedClient()) !== null,
|
ensureConnected: async () => (await host.connectedClient()) !== null,
|
||||||
readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId),
|
readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId),
|
||||||
setThreadGoal: async (threadId, params) => {
|
setThreadGoal: async (threadId, params) => {
|
||||||
const client = await host.connectedClient();
|
return runCurrentChatAppServerEffect(host, (client) => setThreadGoal(client, threadId, params));
|
||||||
if (!client) return undefined;
|
|
||||||
const goal = await setThreadGoal(client, threadId, params);
|
|
||||||
return chatAppServerClientIsStale(host, client) ? undefined : goal;
|
|
||||||
},
|
},
|
||||||
clearThreadGoal: async (threadId) => {
|
clearThreadGoal: (threadId) =>
|
||||||
const result = await withConnectedChatAppServerClient(host, async (client) => {
|
runCurrentChatAppServerEffect(host, async (client) => {
|
||||||
await clearThreadGoal(client, threadId);
|
await clearThreadGoal(client, threadId);
|
||||||
return true;
|
}),
|
||||||
});
|
|
||||||
return result ?? false;
|
|
||||||
},
|
|
||||||
recordThreadGoalUserMessage: async (threadId, objective) => {
|
recordThreadGoalUserMessage: async (threadId, objective) => {
|
||||||
const result = await withCurrentChatAppServerClient(host, async (client) => {
|
const result = await withCurrentChatAppServerClient(host, async (client) => {
|
||||||
await recordThreadGoalUserMessage(client, threadId, objective);
|
await recordThreadGoalUserMessage(client, threadId, objective);
|
||||||
|
|
@ -241,14 +254,16 @@ function chatAppServerClientIsStale(host: CurrentChatAppServerClientHost, client
|
||||||
return host.currentClient() !== client;
|
return host.currentClient() !== client;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withConnectedChatAppServerClient<T>(
|
function runCurrentChatAppServerEffect<T>(
|
||||||
host: ConnectedChatAppServerClientHost,
|
host: CurrentChatAppServerClientHost,
|
||||||
operation: (client: AppServerClient) => Promise<T>,
|
operation: (client: AppServerClient) => Promise<T>,
|
||||||
): Promise<T | null> {
|
): Promise<EffectOutcome<T>> {
|
||||||
const client = await host.connectedClient();
|
const client = host.currentClient();
|
||||||
if (!client) return null;
|
if (!client) return Promise.resolve({ kind: "not-started" });
|
||||||
const result = await operation(client);
|
const effect = operation(client);
|
||||||
return chatAppServerClientIsStale(host, client) ? null : result;
|
return effect.then((value) =>
|
||||||
|
chatAppServerClientIsStale(host, client) ? { kind: "completed-stale", value } : { kind: "completed-current", value },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withCurrentChatAppServerClient<T>(
|
async function withCurrentChatAppServerClient<T>(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import { type CodexInput, type CodexInputItem, codexTextInputWithAttachments, type RequestMention } from "../../../../domain/chat/input";
|
import {
|
||||||
|
type CodexInput,
|
||||||
|
type CodexInputItem,
|
||||||
|
codexTextInputWithAttachments,
|
||||||
|
type VaultFileReference,
|
||||||
|
} from "../../../../domain/chat/input";
|
||||||
|
|
||||||
type ComposerAttachmentKind = "image" | "file";
|
type ComposerAttachmentKind = "image" | "file";
|
||||||
|
|
||||||
|
|
@ -21,14 +26,14 @@ export function codexInputWithComposerAttachments(text: string, input: CodexInpu
|
||||||
}
|
}
|
||||||
|
|
||||||
function inputItemsForAttachments(input: readonly CodexInputItem[], attachments: readonly ComposerAttachment[]): CodexInputItem[] {
|
function inputItemsForAttachments(input: readonly CodexInputItem[], attachments: readonly ComposerAttachment[]): CodexInputItem[] {
|
||||||
const seenMentionPaths = new Set(input.flatMap((item) => (item.type === "mention" ? [item.path] : [])));
|
const seenFileReferencePaths = new Set(input.flatMap((item) => (item.type === "fileReference" ? [item.path] : [])));
|
||||||
const seenLocalImagePaths = new Set(input.flatMap((item) => (item.type === "localImage" ? [item.path] : [])));
|
const seenLocalImagePaths = new Set(input.flatMap((item) => (item.type === "localImage" ? [item.path] : [])));
|
||||||
const items: CodexInputItem[] = [];
|
const items: CodexInputItem[] = [];
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
if (!seenMentionPaths.has(attachment.path)) {
|
if (!seenFileReferencePaths.has(attachment.path)) {
|
||||||
seenMentionPaths.add(attachment.path);
|
seenFileReferencePaths.add(attachment.path);
|
||||||
items.push(mentionInputItem(attachment));
|
items.push(fileReferenceInputItem(attachment));
|
||||||
}
|
}
|
||||||
if (attachment.kind === "image" && !seenLocalImagePaths.has(attachment.path)) {
|
if (attachment.kind === "image" && !seenLocalImagePaths.has(attachment.path)) {
|
||||||
seenLocalImagePaths.add(attachment.path);
|
seenLocalImagePaths.add(attachment.path);
|
||||||
|
|
@ -38,9 +43,9 @@ function inputItemsForAttachments(input: readonly CodexInputItem[], attachments:
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mentionInputItem(attachment: ComposerAttachment): RequestMention & { type: "mention" } {
|
function fileReferenceInputItem(attachment: ComposerAttachment): VaultFileReference & { type: "fileReference" } {
|
||||||
return {
|
return {
|
||||||
type: "mention",
|
type: "fileReference",
|
||||||
name: attachment.name,
|
name: attachment.name,
|
||||||
path: attachment.path,
|
path: attachment.path,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,6 @@ export interface ComposerContextReferenceProvider {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyComposerContextReferences(): ComposerContextReferences {
|
|
||||||
return { activeNote: null, selection: null, activeNoteSnapshots: [], selectionSnapshots: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatComposerContextRange(range: ComposerContextRange): string {
|
export function formatComposerContextRange(range: ComposerContextRange): string {
|
||||||
return `${formatComposerContextPosition(range.from)}-${formatComposerContextPosition(range.to)}`;
|
return `${formatComposerContextPosition(range.from)}-${formatComposerContextPosition(range.to)}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
export interface DailyNoteReferenceCandidate {
|
|
||||||
keyword: "today" | "tomorrow" | "yesterday";
|
|
||||||
display: string;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
linktext: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { SkillMetadata } from "../../../../domain/catalog/metadata";
|
import type { SkillMetadata } from "../../../../domain/catalog/metadata";
|
||||||
import type { ComposerAttachment } from "./attachments";
|
import type { ComposerAttachment } from "./attachments";
|
||||||
import type { ActiveNoteContextReference, ComposerContextReferences, SelectionContextReference } from "./context-references";
|
import type { ActiveNoteContextReference, ComposerContextReferences, SelectionContextReference } from "./context-references";
|
||||||
|
import type { ThreadCommandTarget } from "./thread-title-argument";
|
||||||
|
|
||||||
export interface ComposerInputSnapshot {
|
export interface ComposerInputSnapshot {
|
||||||
readonly sourcePath: string;
|
readonly sourcePath: string;
|
||||||
|
|
@ -10,4 +11,5 @@ export interface ComposerInputSnapshot {
|
||||||
readonly activeNoteSnapshots: readonly ActiveNoteContextReference[];
|
readonly activeNoteSnapshots: readonly ActiveNoteContextReference[];
|
||||||
readonly selectionSnapshots: readonly SelectionContextReference[];
|
readonly selectionSnapshots: readonly SelectionContextReference[];
|
||||||
readonly attachments: readonly ComposerAttachment[];
|
readonly attachments: readonly ComposerAttachment[];
|
||||||
|
readonly threadCommandTarget?: ThreadCommandTarget;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,33 @@
|
||||||
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
|
import type { VaultFileReference } from "../../../../domain/chat/input";
|
||||||
import type { NoteCandidate } from "./suggestions";
|
|
||||||
|
|
||||||
export interface WikiLinkMention {
|
export interface DailyNoteReferenceCandidate {
|
||||||
|
keyword: "today" | "tomorrow" | "yesterday";
|
||||||
|
display: string;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
linktext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteHeadingCandidate {
|
||||||
|
heading: string;
|
||||||
|
linkHeading: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteCandidate {
|
||||||
|
basename: string;
|
||||||
|
displayName: string;
|
||||||
|
path: string;
|
||||||
|
mtime: number;
|
||||||
|
linktext: string;
|
||||||
|
headings: NoteHeadingCandidate[];
|
||||||
|
recentIndex: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoteCandidateProvider {
|
export interface NoteCandidateProvider {
|
||||||
candidates(sourcePath: string): readonly NoteCandidate[];
|
candidates(sourcePath: string): readonly NoteCandidate[];
|
||||||
dailyNoteReferences(sourcePath: string): readonly DailyNoteReferenceCandidate[];
|
dailyNoteReferences(sourcePath: string): readonly DailyNoteReferenceCandidate[];
|
||||||
tags(): readonly string[];
|
tags(): readonly string[];
|
||||||
resolveMention(target: string, sourcePath: string): WikiLinkMention | null;
|
resolveFileReference(target: string, sourcePath: string): VaultFileReference | null;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { ActivePanelOperation } from "../panel-operation-policy";
|
||||||
|
|
||||||
type SlashCommandArgsKind =
|
type SlashCommandArgsKind =
|
||||||
| "none"
|
| "none"
|
||||||
| "optionalThread"
|
| "optionalThread"
|
||||||
|
|
@ -33,7 +35,6 @@ export interface SlashCommandDefinitionShape {
|
||||||
argsKind: SlashCommandArgsKind;
|
argsKind: SlashCommandArgsKind;
|
||||||
surface: SlashCommandSurface;
|
surface: SlashCommandSurface;
|
||||||
detail: string;
|
detail: string;
|
||||||
sideChatAvailable?: false;
|
|
||||||
subcommands?: readonly SlashCommandSubcommandDefinition[];
|
subcommands?: readonly SlashCommandSubcommandDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,7 +80,6 @@ export const SLASH_COMMANDS = [
|
||||||
argsKind: "none",
|
argsKind: "none",
|
||||||
surface: "panelAction",
|
surface: "panelAction",
|
||||||
detail: "Fork the active Codex thread.",
|
detail: "Fork the active Codex thread.",
|
||||||
sideChatAvailable: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "/btw",
|
command: "/btw",
|
||||||
|
|
@ -87,7 +87,6 @@ export const SLASH_COMMANDS = [
|
||||||
argsKind: "none",
|
argsKind: "none",
|
||||||
surface: "panelAction",
|
surface: "panelAction",
|
||||||
detail: "Open a temporary side chat.",
|
detail: "Open a temporary side chat.",
|
||||||
sideChatAvailable: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "/rollback",
|
command: "/rollback",
|
||||||
|
|
@ -95,7 +94,6 @@ export const SLASH_COMMANDS = [
|
||||||
argsKind: "none",
|
argsKind: "none",
|
||||||
surface: "panelAction",
|
surface: "panelAction",
|
||||||
detail: "Roll back the latest turn and restore its prompt.",
|
detail: "Roll back the latest turn and restore its prompt.",
|
||||||
sideChatAvailable: false,
|
|
||||||
},
|
},
|
||||||
{ command: "/compact", usage: "/compact", argsKind: "none", surface: "panelAction", detail: "Compact the current thread context." },
|
{ command: "/compact", usage: "/compact", argsKind: "none", surface: "panelAction", detail: "Compact the current thread context." },
|
||||||
{
|
{
|
||||||
|
|
@ -219,9 +217,53 @@ export function slashCommandRequiresConnection(command: SlashCommandName): boole
|
||||||
return !CONNECTION_INDEPENDENT_SLASH_COMMANDS.has(command);
|
return !CONNECTION_INDEPENDENT_SLASH_COMMANDS.has(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function slashCommandAvailableInSideChat(command: SlashCommandName): boolean {
|
/** Maps a parsed command to the active-panel operation it performs, if any. */
|
||||||
const definition = slashCommandDefinition(command);
|
export function activePanelOperationForSlashCommand(command: SlashCommandName, args: string): ActivePanelOperation | null {
|
||||||
return !("sideChatAvailable" in definition);
|
switch (command) {
|
||||||
|
case "goal":
|
||||||
|
return args.trim() ? "goal-mutation" : "goal-read";
|
||||||
|
case "compact":
|
||||||
|
return "compact";
|
||||||
|
case "fork":
|
||||||
|
return "fork";
|
||||||
|
case "rollback":
|
||||||
|
return "rollback";
|
||||||
|
case "btw":
|
||||||
|
return "start-side-chat";
|
||||||
|
case "fast":
|
||||||
|
case "auto-review":
|
||||||
|
case "plan":
|
||||||
|
return "thread-settings";
|
||||||
|
case "permissions":
|
||||||
|
return args ? "permission-settings" : null;
|
||||||
|
case "model":
|
||||||
|
case "reasoning":
|
||||||
|
return args ? "thread-settings" : null;
|
||||||
|
case "status":
|
||||||
|
case "doctor":
|
||||||
|
case "tools":
|
||||||
|
case "help":
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return "submit";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a slash-completion to its mode-derived operation without assuming arguments. */
|
||||||
|
export function activePanelOperationForSlashCommandSuggestion(command: SlashCommandName): ActivePanelOperation | null {
|
||||||
|
switch (command) {
|
||||||
|
case "goal":
|
||||||
|
case "compact":
|
||||||
|
case "fork":
|
||||||
|
case "rollback":
|
||||||
|
case "btw":
|
||||||
|
case "fast":
|
||||||
|
case "auto-review":
|
||||||
|
case "plan":
|
||||||
|
return activePanelOperationForSlashCommand(command, "");
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function slashCommandDefinition(command: SlashCommandName): SlashCommandDefinition {
|
export function slashCommandDefinition(command: SlashCommandName): SlashCommandDefinition {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { RuntimePermissionProfileSummary } from "../../../../domain/runtime
|
||||||
import { shortThreadId } from "../../../../domain/threads/id";
|
import { shortThreadId } from "../../../../domain/threads/id";
|
||||||
import type { Thread } from "../../../../domain/threads/model";
|
import type { Thread } from "../../../../domain/threads/model";
|
||||||
import { compareThreadSearchMatches, threadSearchMatches } from "../../../../domain/threads/search";
|
import { compareThreadSearchMatches, threadSearchMatches } from "../../../../domain/threads/search";
|
||||||
|
import { threadCommandDisplayTitle } from "../../../../domain/threads/title";
|
||||||
import {
|
import {
|
||||||
type ActiveNoteContextReference,
|
type ActiveNoteContextReference,
|
||||||
activeNoteContextReferenceMarker,
|
activeNoteContextReferenceMarker,
|
||||||
|
|
@ -18,14 +19,15 @@ import {
|
||||||
type SelectionContextReference,
|
type SelectionContextReference,
|
||||||
selectionContextReferenceMarker,
|
selectionContextReferenceMarker,
|
||||||
} from "./context-references";
|
} from "./context-references";
|
||||||
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
|
import type { DailyNoteReferenceCandidate, NoteCandidate, NoteHeadingCandidate } from "./note-context";
|
||||||
|
import { isSlashCommandName, SLASH_COMMANDS, type SlashCommandName, slashCommandSubcommands } from "./slash-commands";
|
||||||
import {
|
import {
|
||||||
isSlashCommandName,
|
partialThreadTitleQuery,
|
||||||
SLASH_COMMANDS,
|
quotedThreadTitleArgument,
|
||||||
type SlashCommandName,
|
THREAD_TITLE_COMMANDS,
|
||||||
slashCommandAvailableInSideChat,
|
type ThreadCommandTarget,
|
||||||
slashCommandSubcommands,
|
type ThreadTitleCommand,
|
||||||
} from "./slash-commands";
|
} from "./thread-title-argument";
|
||||||
|
|
||||||
export interface ComposerSuggestion {
|
export interface ComposerSuggestion {
|
||||||
display: string;
|
display: string;
|
||||||
|
|
@ -37,34 +39,18 @@ export interface ComposerSuggestion {
|
||||||
suffixOnInsert?: string;
|
suffixOnInsert?: string;
|
||||||
activeNoteContext?: ActiveNoteContextReference;
|
activeNoteContext?: ActiveNoteContextReference;
|
||||||
selectionContext?: SelectionContextReference;
|
selectionContext?: SelectionContextReference;
|
||||||
|
threadCommandTarget?: ThreadCommandTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComposerSuggestionOptions {
|
export interface ComposerSuggestionOptions {
|
||||||
activeThreadId?: string | null;
|
activeThreadId?: string | null;
|
||||||
activeThreadEphemeral?: boolean;
|
slashCommandAvailable?: (command: SlashCommandName) => boolean;
|
||||||
activeThreadSubagent?: boolean;
|
|
||||||
contextReferences?: ComposerContextReferences;
|
contextReferences?: ComposerContextReferences;
|
||||||
dailyNoteReferences?: readonly DailyNoteReferenceCandidate[] | (() => readonly DailyNoteReferenceCandidate[]);
|
dailyNoteReferences?: readonly DailyNoteReferenceCandidate[] | (() => readonly DailyNoteReferenceCandidate[]);
|
||||||
permissionProfiles?: readonly RuntimePermissionProfileSummary[];
|
permissionProfiles?: readonly RuntimePermissionProfileSummary[];
|
||||||
tagCandidates?: readonly string[] | (() => readonly string[]);
|
tagCandidates?: readonly string[] | (() => readonly string[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoteCandidate {
|
|
||||||
basename: string;
|
|
||||||
displayName: string;
|
|
||||||
path: string;
|
|
||||||
mtime: number;
|
|
||||||
linktext: string;
|
|
||||||
headings: NoteHeadingCandidate[];
|
|
||||||
recentIndex: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NoteHeadingCandidate {
|
|
||||||
heading: string;
|
|
||||||
linkHeading: string;
|
|
||||||
level: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NoteCandidateMatch {
|
interface NoteCandidateMatch {
|
||||||
file: NoteCandidate;
|
file: NoteCandidate;
|
||||||
match: SearchResult;
|
match: SearchResult;
|
||||||
|
|
@ -73,16 +59,12 @@ interface NoteCandidateMatch {
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const THREAD_SUGGESTION_COMMANDS = ["resume", "refer", "archive", "rename"] as const;
|
|
||||||
|
|
||||||
type ThreadSuggestionCommand = (typeof THREAD_SUGGESTION_COMMANDS)[number];
|
|
||||||
|
|
||||||
interface ThreadCommandSuggestionPolicy {
|
interface ThreadCommandSuggestionPolicy {
|
||||||
excludeActiveThread: boolean;
|
excludeActiveThread: boolean;
|
||||||
prioritizeActiveThreadForEmptyQuery: boolean;
|
prioritizeActiveThreadForEmptyQuery: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const THREAD_COMMAND_SUGGESTION_POLICIES: Record<ThreadSuggestionCommand, ThreadCommandSuggestionPolicy> = {
|
const THREAD_COMMAND_SUGGESTION_POLICIES: Record<ThreadTitleCommand, ThreadCommandSuggestionPolicy> = {
|
||||||
resume: {
|
resume: {
|
||||||
excludeActiveThread: true,
|
excludeActiveThread: true,
|
||||||
prioritizeActiveThreadForEmptyQuery: false,
|
prioritizeActiveThreadForEmptyQuery: false,
|
||||||
|
|
@ -101,7 +83,7 @@ const THREAD_COMMAND_SUGGESTION_POLICIES: Record<ThreadSuggestionCommand, Thread
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const THREAD_SUGGESTION_COMMAND_PATTERN = new RegExp(`^/(${THREAD_SUGGESTION_COMMANDS.join("|")})\\s+([^\\s\\n]{0,120})$`);
|
const THREAD_SUGGESTION_COMMAND_PATTERN = new RegExp(`^/(${THREAD_TITLE_COMMANDS.join("|")})\\s+([^\\n]{0,120})$`);
|
||||||
const SELECTION_SUGGESTION_PREVIEW_LIMIT = 500;
|
const SELECTION_SUGGESTION_PREVIEW_LIMIT = 500;
|
||||||
|
|
||||||
export function parseSlashCommand(text: string): { command: SlashCommandName; args: string } | null {
|
export function parseSlashCommand(text: string): { command: SlashCommandName; args: string } | null {
|
||||||
|
|
@ -121,14 +103,16 @@ export function activeComposerSuggestions(
|
||||||
currentModel: string | null = null,
|
currentModel: string | null = null,
|
||||||
options: ComposerSuggestionOptions = {},
|
options: ComposerSuggestionOptions = {},
|
||||||
): ComposerSuggestion[] {
|
): ComposerSuggestion[] {
|
||||||
const slashSuggestions = options.activeThreadSubagent
|
const slashCommandAvailable = options.slashCommandAvailable ?? (() => true);
|
||||||
? null
|
const slashSuggestions = slashSuggestionsForActivePanel(
|
||||||
: (activeSlashSubcommandSuggestions(beforeCursor) ??
|
beforeCursor,
|
||||||
activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ??
|
threads,
|
||||||
modelOverrideSuggestions(beforeCursor, models) ??
|
models,
|
||||||
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
|
currentModel,
|
||||||
permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ??
|
options.activeThreadId ?? null,
|
||||||
activeSlashCommandSuggestions(beforeCursor, options.activeThreadEphemeral ?? false, false));
|
options.permissionProfiles ?? [],
|
||||||
|
slashCommandAvailable,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
activeWikiLinkSuggestions(beforeCursor, notes) ??
|
activeWikiLinkSuggestions(beforeCursor, notes) ??
|
||||||
activeContextReferenceSuggestions(beforeCursor, options.contextReferences, options.dailyNoteReferences) ??
|
activeContextReferenceSuggestions(beforeCursor, options.contextReferences, options.dailyNoteReferences) ??
|
||||||
|
|
@ -139,6 +123,27 @@ export function activeComposerSuggestions(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slashSuggestionsForActivePanel(
|
||||||
|
beforeCursor: string,
|
||||||
|
threads: readonly Thread[],
|
||||||
|
models: readonly ModelMetadata[],
|
||||||
|
currentModel: string | null,
|
||||||
|
activeThreadId: string | null,
|
||||||
|
permissionProfiles: readonly RuntimePermissionProfileSummary[],
|
||||||
|
slashCommandAvailable: (command: SlashCommandName) => boolean,
|
||||||
|
): ComposerSuggestion[] | null {
|
||||||
|
const command = /^\/([A-Za-z-]+)/.exec(beforeCursor)?.[1];
|
||||||
|
if (command && isSlashCommandName(command) && !slashCommandAvailable(command)) return [];
|
||||||
|
return (
|
||||||
|
activeSlashSubcommandSuggestions(beforeCursor, slashCommandAvailable) ??
|
||||||
|
activeThreadCommandSuggestions(beforeCursor, threads, activeThreadId) ??
|
||||||
|
modelOverrideSuggestions(beforeCursor, models) ??
|
||||||
|
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
|
||||||
|
permissionProfileOverrideSuggestions(beforeCursor, permissionProfiles) ??
|
||||||
|
activeSlashCommandSuggestions(beforeCursor, slashCommandAvailable)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function activeContextReferenceSuggestions(
|
function activeContextReferenceSuggestions(
|
||||||
beforeCursor: string,
|
beforeCursor: string,
|
||||||
references: ComposerContextReferences | undefined,
|
references: ComposerContextReferences | undefined,
|
||||||
|
|
@ -405,8 +410,7 @@ function compareWikiLinkSuggestionTiebreakers(a: NoteCandidateMatch, b: NoteCand
|
||||||
|
|
||||||
function activeSlashCommandSuggestions(
|
function activeSlashCommandSuggestions(
|
||||||
beforeCursor: string,
|
beforeCursor: string,
|
||||||
activeThreadEphemeral: boolean,
|
slashCommandAvailable: (command: SlashCommandName) => boolean,
|
||||||
activeThreadSubagent: boolean,
|
|
||||||
): ComposerSuggestion[] | null {
|
): ComposerSuggestion[] | null {
|
||||||
const match = /^(\/[A-Za-z-]*)$/.exec(beforeCursor);
|
const match = /^(\/[A-Za-z-]*)$/.exec(beforeCursor);
|
||||||
if (match?.index === undefined) return null;
|
if (match?.index === undefined) return null;
|
||||||
|
|
@ -414,13 +418,10 @@ function activeSlashCommandSuggestions(
|
||||||
const rawQuery = match[1];
|
const rawQuery = match[1];
|
||||||
if (rawQuery === undefined) return null;
|
if (rawQuery === undefined) return null;
|
||||||
const query = rawQuery.toLowerCase();
|
const query = rawQuery.toLowerCase();
|
||||||
if (activeThreadSubagent) return [];
|
|
||||||
if (SLASH_COMMANDS.some((item) => item.command.toLowerCase() === query)) return null;
|
if (SLASH_COMMANDS.some((item) => item.command.toLowerCase() === query)) return null;
|
||||||
const start = match.index + match[0].lastIndexOf("/");
|
const start = match.index + match[0].lastIndexOf("/");
|
||||||
return SLASH_COMMANDS.filter(
|
return SLASH_COMMANDS.filter(
|
||||||
(item) =>
|
(item) => item.command.toLowerCase().startsWith(query) && slashCommandAvailable(item.command.slice(1) as SlashCommandName),
|
||||||
item.command.toLowerCase().startsWith(query) &&
|
|
||||||
(!activeThreadEphemeral || slashCommandAvailableInSideChat(item.command.slice(1) as SlashCommandName)),
|
|
||||||
)
|
)
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
|
|
@ -432,13 +433,17 @@ function activeSlashCommandSuggestions(
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeSlashSubcommandSuggestions(beforeCursor: string): ComposerSuggestion[] | null {
|
function activeSlashSubcommandSuggestions(
|
||||||
|
beforeCursor: string,
|
||||||
|
slashCommandAvailable: (command: SlashCommandName) => boolean,
|
||||||
|
): ComposerSuggestion[] | null {
|
||||||
const match = /^\/([A-Za-z-]+)\s+([A-Za-z-]{0,120})$/.exec(beforeCursor);
|
const match = /^\/([A-Za-z-]+)\s+([A-Za-z-]{0,120})$/.exec(beforeCursor);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
const command = match[1];
|
const command = match[1];
|
||||||
const rawQuery = match[2];
|
const rawQuery = match[2];
|
||||||
if (!command || !isSlashCommandName(command) || rawQuery === undefined) return null;
|
if (!command || !isSlashCommandName(command) || rawQuery === undefined) return null;
|
||||||
|
if (!slashCommandAvailable(command)) return [];
|
||||||
|
|
||||||
const query = rawQuery.toLowerCase();
|
const query = rawQuery.toLowerCase();
|
||||||
const subcommands = slashCommandSubcommands(command);
|
const subcommands = slashCommandSubcommands(command);
|
||||||
|
|
@ -467,7 +472,6 @@ function activeThreadCommandSuggestions(
|
||||||
|
|
||||||
const { command, query, start } = completion;
|
const { command, query, start } = completion;
|
||||||
const policy = THREAD_COMMAND_SUGGESTION_POLICIES[command];
|
const policy = THREAD_COMMAND_SUGGESTION_POLICIES[command];
|
||||||
if (threads.some((thread) => thread.id.toLowerCase() === query)) return null;
|
|
||||||
const candidateThreads = threads.filter((thread) => !shouldExcludeActiveThreadSuggestion(policy, thread.id, activeThreadId));
|
const candidateThreads = threads.filter((thread) => !shouldExcludeActiveThreadSuggestion(policy, thread.id, activeThreadId));
|
||||||
|
|
||||||
return threadSearchMatches(candidateThreads, query)
|
return threadSearchMatches(candidateThreads, query)
|
||||||
|
|
@ -476,13 +480,17 @@ function activeThreadCommandSuggestions(
|
||||||
return { ...match, activePriority };
|
return { ...match, activePriority };
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.activePriority - b.activePriority || compareThreadSearchMatches(a, b))
|
.sort((a, b) => a.activePriority - b.activePriority || compareThreadSearchMatches(a, b))
|
||||||
.map(({ thread, title }) => ({
|
.map(({ thread }) => {
|
||||||
display: title,
|
const title = threadCommandDisplayTitle(thread);
|
||||||
detail: shortThreadId(thread.id),
|
return {
|
||||||
replacement: thread.id,
|
display: title,
|
||||||
start,
|
detail: shortThreadId(thread.id),
|
||||||
appendSpaceOnInsert: true,
|
replacement: quotedThreadTitleArgument(title),
|
||||||
}));
|
start,
|
||||||
|
appendSpaceOnInsert: true,
|
||||||
|
threadCommandTarget: { command, threadId: thread.id, title },
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldExcludeActiveThreadSuggestion(
|
function shouldExcludeActiveThreadSuggestion(
|
||||||
|
|
@ -502,22 +510,20 @@ function activeThreadSuggestionPriority(
|
||||||
return policy.prioritizeActiveThreadForEmptyQuery && query.length === 0 && activeThreadId !== null && threadId === activeThreadId ? 0 : 1;
|
return policy.prioritizeActiveThreadForEmptyQuery && query.length === 0 && activeThreadId !== null && threadId === activeThreadId ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeThreadCommandCompletionQuery(
|
function activeThreadCommandCompletionQuery(beforeCursor: string): { command: ThreadTitleCommand; query: string; start: number } | null {
|
||||||
beforeCursor: string,
|
|
||||||
): { command: ThreadSuggestionCommand; query: string; start: number } | null {
|
|
||||||
const match = THREAD_SUGGESTION_COMMAND_PATTERN.exec(beforeCursor);
|
const match = THREAD_SUGGESTION_COMMAND_PATTERN.exec(beforeCursor);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
const command = threadSuggestionCommand(match[1]);
|
const command = threadSuggestionCommand(match[1]);
|
||||||
const rawQuery = match[2];
|
const rawQuery = match[2];
|
||||||
if (!command || rawQuery === undefined) return null;
|
if (!command || rawQuery === undefined) return null;
|
||||||
const query = rawQuery.trim().toLowerCase();
|
const query = partialThreadTitleQuery(rawQuery);
|
||||||
if (query.length > 0 && /\s$/.test(rawQuery)) return null;
|
if (query === null) return null;
|
||||||
return { command, query, start: beforeCursor.length - rawQuery.length };
|
return { command, query, start: beforeCursor.length - rawQuery.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadSuggestionCommand(value: string | undefined): ThreadSuggestionCommand | null {
|
function threadSuggestionCommand(value: string | undefined): ThreadTitleCommand | null {
|
||||||
return THREAD_SUGGESTION_COMMANDS.find((command) => command === value) ?? null;
|
return THREAD_TITLE_COMMANDS.find((command) => command === value) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelOverrideSuggestions(beforeCursor: string, models: readonly ModelMetadata[]): ComposerSuggestion[] | null {
|
function modelOverrideSuggestions(beforeCursor: string, models: readonly ModelMetadata[]): ComposerSuggestion[] | null {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
export const THREAD_TITLE_COMMANDS = ["resume", "refer", "archive", "rename"] as const;
|
||||||
|
|
||||||
|
export type ThreadTitleCommand = (typeof THREAD_TITLE_COMMANDS)[number];
|
||||||
|
|
||||||
|
export interface ThreadCommandTarget {
|
||||||
|
readonly command: ThreadTitleCommand;
|
||||||
|
readonly threadId: string;
|
||||||
|
readonly title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedThreadTitleArgument {
|
||||||
|
readonly title: string;
|
||||||
|
readonly rest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function threadCommandTargetForDraft(draft: string, target: ThreadCommandTarget | null): ThreadCommandTarget | null {
|
||||||
|
if (!target) return null;
|
||||||
|
const match = /^\/([A-Za-z-]+)(?:\s+([\s\S]*))?$/.exec(draft.trim());
|
||||||
|
if (match?.[1] !== target.command) return null;
|
||||||
|
const parsed = parseThreadTitleArgument(match[2] ?? "");
|
||||||
|
return parsed?.title === target.title ? target : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quotedThreadTitleArgument(title: string): string {
|
||||||
|
return `"${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseThreadTitleArgument(input: string): ParsedThreadTitleArgument | null {
|
||||||
|
const source = input.trimStart();
|
||||||
|
if (!source) return null;
|
||||||
|
if (!source.startsWith('"')) {
|
||||||
|
const match = /^(\S+)([\s\S]*)$/.exec(source);
|
||||||
|
const title = match?.[1];
|
||||||
|
const rest = match?.[2];
|
||||||
|
return title === undefined || rest === undefined ? null : { title, rest: rest.trimStart() };
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = "";
|
||||||
|
for (let index = 1; index < source.length; index += 1) {
|
||||||
|
const character = source.charAt(index);
|
||||||
|
if (character === "\\") {
|
||||||
|
const escaped = source.charAt(index + 1);
|
||||||
|
if (!escaped) return null;
|
||||||
|
if (escaped !== "\\" && escaped !== '"') title += "\\";
|
||||||
|
title += escaped;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '"') {
|
||||||
|
const rest = source.slice(index + 1);
|
||||||
|
if (rest && !/^\s/.test(rest)) return null;
|
||||||
|
return { title, rest: rest.trimStart() };
|
||||||
|
}
|
||||||
|
title += character;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partialThreadTitleQuery(input: string): string | null {
|
||||||
|
if (!input.startsWith('"')) return /\s/.test(input) ? null : input;
|
||||||
|
|
||||||
|
let query = "";
|
||||||
|
for (let index = 1; index < input.length; index += 1) {
|
||||||
|
const character = input.charAt(index);
|
||||||
|
if (character === "\\") {
|
||||||
|
const escaped = input.charAt(index + 1);
|
||||||
|
if (!escaped) return query;
|
||||||
|
if (escaped !== "\\" && escaped !== '"') query += "\\";
|
||||||
|
query += escaped;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '"') return null;
|
||||||
|
query += character;
|
||||||
|
}
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { parseLinktext } from "obsidian";
|
import { parseLinktext } from "obsidian";
|
||||||
import type { SkillMetadata } from "../../../../domain/catalog/metadata";
|
import type { SkillMetadata } from "../../../../domain/catalog/metadata";
|
||||||
import {
|
import {
|
||||||
ACTIVE_FILE_MENTION_NAME,
|
ACTIVE_FILE_REFERENCE_NAME,
|
||||||
type CodexInput,
|
type CodexInput,
|
||||||
codexTextInputWithMentions,
|
codexTextInputWithReferences,
|
||||||
type RequestAdditionalContext,
|
type RequestAdditionalContext,
|
||||||
type RequestMention,
|
type SkillReference,
|
||||||
|
type VaultFileReference,
|
||||||
} from "../../../../domain/chat/input";
|
} from "../../../../domain/chat/input";
|
||||||
|
import { type MarkdownCodeRange, markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../domain/markdown/code-ranges";
|
||||||
import {
|
import {
|
||||||
type ActiveNoteContextReference,
|
type ActiveNoteContextReference,
|
||||||
type ComposerContextReferences,
|
type ComposerContextReferences,
|
||||||
formatComposerContextRange,
|
|
||||||
type SelectionContextReference,
|
type SelectionContextReference,
|
||||||
selectionContextReferenceMarker,
|
selectionContextReferenceMarker,
|
||||||
} from "./context-references";
|
} from "./context-references";
|
||||||
|
|
@ -22,7 +23,13 @@ interface ParsedWikiLink {
|
||||||
display: string;
|
display: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WikiLinkMentionResolver = (target: string) => { name: string; path: string } | null;
|
interface ObsidianReference {
|
||||||
|
marker: string;
|
||||||
|
path: string;
|
||||||
|
excerpt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WikiLinkFileReferenceResolver = (target: string) => VaultFileReference | null;
|
||||||
|
|
||||||
const OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY = "codex_panel_obsidian_context";
|
const OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY = "codex_panel_obsidian_context";
|
||||||
const WIKILINK_PATTERN = /\[\[([^\]\n]+?)\]\]/g;
|
const WIKILINK_PATTERN = /\[\[([^\]\n]+?)\]\]/g;
|
||||||
|
|
@ -37,10 +44,11 @@ interface PreparedComposerInputOptions {
|
||||||
referenceActiveNoteOnSend: boolean;
|
referenceActiveNoteOnSend: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsedWikiLinks(text: string): ParsedWikiLink[] {
|
function parsedWikiLinks(text: string, codeRanges: readonly MarkdownCodeRange[]): ParsedWikiLink[] {
|
||||||
const links: ParsedWikiLink[] = [];
|
const links: ParsedWikiLink[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const match of text.matchAll(WIKILINK_PATTERN)) {
|
for (const match of text.matchAll(WIKILINK_PATTERN)) {
|
||||||
|
if (markdownCodeRangeContainsOffset(codeRanges, match.index)) continue;
|
||||||
const rawValue = match[1];
|
const rawValue = match[1];
|
||||||
if (rawValue === undefined) continue;
|
if (rawValue === undefined) continue;
|
||||||
const raw = rawValue.trim();
|
const raw = rawValue.trim();
|
||||||
|
|
@ -54,41 +62,42 @@ function parsedWikiLinks(text: string): ParsedWikiLink[] {
|
||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function preparedUserInputWithWikiLinkMentionsSkillsAndContext(
|
export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
|
||||||
text: string,
|
text: string,
|
||||||
resolveMention: WikiLinkMentionResolver,
|
resolveFileReference: WikiLinkFileReferenceResolver,
|
||||||
skills: readonly SkillMetadata[],
|
skills: readonly SkillMetadata[],
|
||||||
contextReferences: ComposerContextReferences,
|
contextReferences: ComposerContextReferences,
|
||||||
options: PreparedComposerInputOptions,
|
options: PreparedComposerInputOptions,
|
||||||
): PreparedComposerInput {
|
): PreparedComposerInput {
|
||||||
const contextReplacement = textWithContextReferences(text, contextReferences);
|
const codeRanges = markdownReferenceCodeRanges(text);
|
||||||
|
const contextReplacement = textWithContextReferences(text, contextReferences, codeRanges);
|
||||||
const resolvedText = contextReplacement.text;
|
const resolvedText = contextReplacement.text;
|
||||||
const mentions: RequestMention[] = [];
|
const fileReferences: VaultFileReference[] = [];
|
||||||
const wikilinkMappings: string[] = [];
|
const wikilinkReferences: ObsidianReference[] = [];
|
||||||
const seenPaths = new Set<string>();
|
const seenPaths = new Set<string>();
|
||||||
const activeNoteSnapshots = contextReferences.activeNoteSnapshots ?? [];
|
const activeNoteSnapshots = contextReferences.activeNoteSnapshots ?? [];
|
||||||
|
|
||||||
for (const link of parsedWikiLinks(resolvedText)) {
|
for (const link of parsedWikiLinks(resolvedText, codeRanges)) {
|
||||||
const mention = activeNoteMentionForLink(link, activeNoteSnapshots) ?? resolveMention(link.target);
|
const fileReference = activeNoteFileReferenceForLink(link, activeNoteSnapshots) ?? resolveFileReference(link.target);
|
||||||
if (!mention || seenPaths.has(mention.path)) continue;
|
if (!fileReference || seenPaths.has(fileReference.path)) continue;
|
||||||
seenPaths.add(mention.path);
|
seenPaths.add(fileReference.path);
|
||||||
mentions.push(mention);
|
fileReferences.push(fileReference);
|
||||||
wikilinkMappings.push(`- [[${link.raw}]] -> ${mention.path}`);
|
wikilinkReferences.push({ marker: `[[${link.raw}]]`, path: fileReference.path });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const selection of contextReplacement.selections) {
|
for (const selection of contextReplacement.selections) {
|
||||||
if (seenPaths.has(selection.path)) continue;
|
if (seenPaths.has(selection.path)) continue;
|
||||||
seenPaths.add(selection.path);
|
seenPaths.add(selection.path);
|
||||||
mentions.push({ name: selection.name, path: selection.path });
|
fileReferences.push({ name: selection.name, path: selection.path });
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachedActiveNote = options.referenceActiveNoteOnSend ? contextReferences.activeNote : null;
|
const attachedActiveNote = options.referenceActiveNoteOnSend ? contextReferences.activeNote : null;
|
||||||
if (attachedActiveNote) mentions.push({ name: ACTIVE_FILE_MENTION_NAME, path: attachedActiveNote.path });
|
if (attachedActiveNote) fileReferences.push({ name: ACTIVE_FILE_REFERENCE_NAME, path: attachedActiveNote.path });
|
||||||
|
|
||||||
const skillByName = firstEnabledSkillByName(skills);
|
const skillByName = firstEnabledSkillByName(skills);
|
||||||
const resolvedSkills: RequestMention[] = [];
|
const resolvedSkills: SkillReference[] = [];
|
||||||
const seenSkillPaths = new Set<string>();
|
const seenSkillPaths = new Set<string>();
|
||||||
for (const reference of parsedSkillReferences(resolvedText)) {
|
for (const reference of parsedSkillReferences(resolvedText, codeRanges)) {
|
||||||
const skill = skillByName.get(reference.toLowerCase());
|
const skill = skillByName.get(reference.toLowerCase());
|
||||||
if (!skill || seenSkillPaths.has(skill.path)) continue;
|
if (!skill || seenSkillPaths.has(skill.path)) continue;
|
||||||
seenSkillPaths.add(skill.path);
|
seenSkillPaths.add(skill.path);
|
||||||
|
|
@ -97,16 +106,16 @@ export function preparedUserInputWithWikiLinkMentionsSkillsAndContext(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: resolvedText,
|
text: resolvedText,
|
||||||
input: codexTextInputWithMentions(
|
input: codexTextInputWithReferences(
|
||||||
resolvedText,
|
resolvedText,
|
||||||
mentions,
|
fileReferences,
|
||||||
resolvedSkills,
|
resolvedSkills,
|
||||||
additionalContext(wikilinkMappings, contextReplacement.selections, attachedActiveNote),
|
additionalContext(wikilinkReferences, contextReplacement.selections, attachedActiveNote),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeNoteMentionForLink(link: ParsedWikiLink, snapshots: readonly ActiveNoteContextReference[]): RequestMention | null {
|
function activeNoteFileReferenceForLink(link: ParsedWikiLink, snapshots: readonly ActiveNoteContextReference[]): VaultFileReference | null {
|
||||||
const snapshot = snapshots.find((item) => link.raw.trim() === item.linktext && link.target === item.linktext);
|
const snapshot = snapshots.find((item) => link.raw.trim() === item.linktext && link.target === item.linktext);
|
||||||
return snapshot ? { name: snapshot.name, path: snapshot.path } : null;
|
return snapshot ? { name: snapshot.name, path: snapshot.path } : null;
|
||||||
}
|
}
|
||||||
|
|
@ -114,19 +123,24 @@ function activeNoteMentionForLink(link: ParsedWikiLink, snapshots: readonly Acti
|
||||||
function textWithContextReferences(
|
function textWithContextReferences(
|
||||||
text: string,
|
text: string,
|
||||||
contextReferences: ComposerContextReferences,
|
contextReferences: ComposerContextReferences,
|
||||||
|
codeRanges: readonly MarkdownCodeRange[],
|
||||||
): { text: string; selections: SelectionContextReference[] } {
|
): { text: string; selections: SelectionContextReference[] } {
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? []),
|
selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? [], codeRanges),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectionsReferencedByText(text: string, snapshots: readonly SelectionContextReference[]): SelectionContextReference[] {
|
function selectionsReferencedByText(
|
||||||
|
text: string,
|
||||||
|
snapshots: readonly SelectionContextReference[],
|
||||||
|
codeRanges: readonly MarkdownCodeRange[],
|
||||||
|
): SelectionContextReference[] {
|
||||||
const selections: SelectionContextReference[] = [];
|
const selections: SelectionContextReference[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const snapshot of snapshots) {
|
for (const snapshot of snapshots) {
|
||||||
const marker = selectionContextReferenceMarker(snapshot);
|
const marker = selectionContextReferenceMarker(snapshot);
|
||||||
if (!text.includes(marker) || seen.has(marker)) continue;
|
if (!includesOutsideMarkdownCode(text, marker, codeRanges) || seen.has(marker)) continue;
|
||||||
seen.add(marker);
|
seen.add(marker);
|
||||||
selections.push(snapshot);
|
selections.push(snapshot);
|
||||||
}
|
}
|
||||||
|
|
@ -134,67 +148,72 @@ function selectionsReferencedByText(text: string, snapshots: readonly SelectionC
|
||||||
}
|
}
|
||||||
|
|
||||||
function additionalContext(
|
function additionalContext(
|
||||||
wikilinkMappings: readonly string[],
|
wikilinkReferences: readonly ObsidianReference[],
|
||||||
selections: readonly SelectionContextReference[],
|
selections: readonly SelectionContextReference[],
|
||||||
activeNote: ActiveNoteContextReference | null,
|
activeNote: ActiveNoteContextReference | null,
|
||||||
): RequestAdditionalContext[] {
|
): RequestAdditionalContext[] {
|
||||||
return obsidianContextAdditionalContext(wikilinkMappings, selections, activeNote);
|
return obsidianContextAdditionalContext(obsidianReferences(wikilinkReferences, selections, activeNote));
|
||||||
}
|
}
|
||||||
|
|
||||||
function obsidianContextAdditionalContext(
|
function obsidianReferences(
|
||||||
wikilinkMappings: readonly string[],
|
wikilinkReferences: readonly ObsidianReference[],
|
||||||
selections: readonly SelectionContextReference[],
|
selections: readonly SelectionContextReference[],
|
||||||
activeNote: ActiveNoteContextReference | null,
|
activeNote: ActiveNoteContextReference | null,
|
||||||
): RequestAdditionalContext[] {
|
): ObsidianReference[] {
|
||||||
if (wikilinkMappings.length === 0 && selections.length === 0 && !activeNote) return [];
|
const selectionReferences = selections.map((selection) => ({
|
||||||
|
marker: selectionContextReferenceMarker(selection),
|
||||||
|
path: selection.path,
|
||||||
|
excerpt: selection.text,
|
||||||
|
}));
|
||||||
|
const selectedWikilinks = new Set(selections.map((selection) => referenceKey(`[[${selection.linktext}]]`, selection.path)));
|
||||||
|
return [
|
||||||
|
...wikilinkReferences.filter((reference) => !selectedWikilinks.has(referenceKey(reference.marker, reference.path))),
|
||||||
|
...selectionReferences,
|
||||||
|
...(activeNote ? [{ marker: ACTIVE_FILE_REFERENCE_NAME, path: activeNote.path }] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceKey(marker: string, path: string): string {
|
||||||
|
return `${marker}\u0000${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function obsidianContextAdditionalContext(references: readonly ObsidianReference[]): RequestAdditionalContext[] {
|
||||||
|
if (references.length === 0) return [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY,
|
key: OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY,
|
||||||
kind: "untrusted",
|
kind: "untrusted",
|
||||||
value: obsidianContextValue(wikilinkMappings, selections, activeNote),
|
value: obsidianContextValue(references),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function obsidianContextValue(
|
function obsidianContextValue(references: readonly ObsidianReference[]): string {
|
||||||
wikilinkMappings: readonly string[],
|
const excerpts = references.filter((reference): reference is ObsidianReference & { excerpt: string } => reference.excerpt !== undefined);
|
||||||
selections: readonly SelectionContextReference[],
|
|
||||||
activeNote: ActiveNoteContextReference | null,
|
|
||||||
): string {
|
|
||||||
const sections: string[][] = [];
|
|
||||||
if (wikilinkMappings.length > 0) sections.push(["Resolved wikilinks:", ...wikilinkMappings]);
|
|
||||||
if (selections.length > 0) {
|
|
||||||
sections.push(["Referenced selections:", ...selectionReferenceLines(selections), "", ...selectionBodyLines(selections)]);
|
|
||||||
}
|
|
||||||
if (activeNote) sections.push(activeNoteContextLines(activeNote));
|
|
||||||
return [
|
return [
|
||||||
"Obsidian context for the current user input:",
|
"Obsidian references for the current user input:",
|
||||||
...sections.flatMap((section, index) => (index === 0 ? section : ["", ...section])),
|
...references.map(referenceLine),
|
||||||
|
...(excerpts.length > 0 ? ["", "Inline excerpts:", ...excerptLines(excerpts)] : []),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectionReferenceLines(selections: readonly SelectionContextReference[]): string[] {
|
function referenceLine(reference: ObsidianReference): string {
|
||||||
return selections.map((selection) => {
|
return `- ${reference.marker} -> ${reference.path}${reference.excerpt === undefined ? "" : " (inline excerpt below)"}`;
|
||||||
const location = `${selection.path} ${formatComposerContextRange(selection.range)}`;
|
|
||||||
return `- ${selectionContextReferenceMarker(selection)} -> ${location}`;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectionBodyLines(selections: readonly SelectionContextReference[]): string[] {
|
function excerptLines(references: readonly (ObsidianReference & { excerpt: string })[]): string[] {
|
||||||
return selections.flatMap((selection, index) => {
|
return references.flatMap((reference, index) => {
|
||||||
const prefix = index === 0 ? [] : [""];
|
const prefix = index === 0 ? [] : [""];
|
||||||
return [...prefix, `${selectionContextReferenceMarker(selection)}:`, selection.text];
|
return [...prefix, `${reference.marker}:`, reference.excerpt];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeNoteContextLines(activeNote: ActiveNoteContextReference): string[] {
|
function parsedSkillReferences(text: string, codeRanges: readonly MarkdownCodeRange[]): string[] {
|
||||||
return ["Referenced active file:", `- ${ACTIVE_FILE_MENTION_NAME} -> ${activeNote.path}`];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsedSkillReferences(text: string): string[] {
|
|
||||||
const references: string[] = [];
|
const references: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const match of text.matchAll(SKILL_REFERENCE_PATTERN)) {
|
for (const match of text.matchAll(SKILL_REFERENCE_PATTERN)) {
|
||||||
|
const prefix = match[1] ?? "";
|
||||||
|
if (markdownCodeRangeContainsOffset(codeRanges, match.index + prefix.length)) continue;
|
||||||
const name = match[2];
|
const name = match[2];
|
||||||
if (name === undefined) continue;
|
if (name === undefined) continue;
|
||||||
const key = name.toLowerCase();
|
const key = name.toLowerCase();
|
||||||
|
|
@ -205,6 +224,19 @@ function parsedSkillReferences(text: string): string[] {
|
||||||
return references;
|
return references;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markdownReferenceCodeRanges(text: string): MarkdownCodeRange[] {
|
||||||
|
return text.includes("[[") || text.includes("$") ? markdownCodeRanges(text) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesOutsideMarkdownCode(text: string, value: string, codeRanges: readonly MarkdownCodeRange[]): boolean {
|
||||||
|
let index = text.indexOf(value);
|
||||||
|
while (index >= 0) {
|
||||||
|
if (!markdownCodeRangeContainsOffset(codeRanges, index)) return true;
|
||||||
|
index = text.indexOf(value, index + value.length);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function parseWikiLink(raw: string): ParsedWikiLink | null {
|
function parseWikiLink(raw: string): ParsedWikiLink | null {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,23 @@ const STATUS_CONNECTION_STARTING = "Starting Codex app-server...";
|
||||||
const STATUS_CONNECTED = "Connected.";
|
const STATUS_CONNECTED = "Connected.";
|
||||||
const STATUS_CONNECTION_FAILED = "Connection failed.";
|
const STATUS_CONNECTION_FAILED = "Connection failed.";
|
||||||
|
|
||||||
export interface ChatConnectionAdapter {
|
interface ChatConnectionAdapter {
|
||||||
connect(): Promise<ServerInitialization>;
|
connect(): Promise<ServerInitialization>;
|
||||||
isConnected(): boolean;
|
isConnected(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatConnectionMetadataActions {
|
interface ChatConnectionMetadataActions {
|
||||||
refreshAppServerMetadata: () => Promise<unknown>;
|
refreshAppServerMetadata: () => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatConnectionDiagnosticsActions {
|
interface ChatConnectionDiagnosticsActions {
|
||||||
refreshServerDiagnostics: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise<void>;
|
refreshServerDiagnostics: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatConnectionActionsHost {
|
export interface ChatConnectionActionsHost {
|
||||||
stateStore: ChatStateStore;
|
stateStore: ChatStateStore;
|
||||||
connection: ChatConnectionAdapter;
|
connection: ChatConnectionAdapter;
|
||||||
|
canConnect: () => boolean;
|
||||||
metadata: ChatConnectionMetadataActions;
|
metadata: ChatConnectionMetadataActions;
|
||||||
diagnostics: ChatConnectionDiagnosticsActions;
|
diagnostics: ChatConnectionDiagnosticsActions;
|
||||||
invalidateThreadWork: () => void;
|
invalidateThreadWork: () => void;
|
||||||
|
|
@ -34,15 +35,14 @@ export interface ChatConnectionActionsHost {
|
||||||
setStatus: (statusText: string, phase?: ChatConnectionPhase) => void;
|
setStatus: (statusText: string, phase?: ChatConnectionPhase) => void;
|
||||||
addSystemMessage: (text: string) => void;
|
addSystemMessage: (text: string) => void;
|
||||||
configuredCommand: () => string;
|
configuredCommand: () => string;
|
||||||
refreshLiveState: () => void;
|
|
||||||
isStaleConnectionError: (error: unknown) => boolean;
|
isStaleConnectionError: (error: unknown) => boolean;
|
||||||
isStaleResourceContextError: (error: unknown) => boolean;
|
isStaleRuntimeError: (error: unknown) => boolean;
|
||||||
notifyConnectionFailed: () => void;
|
notifyConnectionFailed: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatConnectionExitHost = Pick<
|
type ChatConnectionExitHost = Pick<
|
||||||
ChatConnectionActionsHost,
|
ChatConnectionActionsHost,
|
||||||
"invalidateThreadWork" | "setStatus" | "stateStore" | "resetThreadTurnPresence" | "refreshLiveState"
|
"invalidateThreadWork" | "setStatus" | "stateStore" | "resetThreadTurnPresence"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function handleChatConnectionExit(host: ChatConnectionExitHost): void {
|
function handleChatConnectionExit(host: ChatConnectionExitHost): void {
|
||||||
|
|
@ -50,10 +50,10 @@ function handleChatConnectionExit(host: ChatConnectionExitHost): void {
|
||||||
host.setStatus(STATUS_CONNECTION_STOPPED, { kind: "disconnected", message: STATUS_CONNECTION_STOPPED });
|
host.setStatus(STATUS_CONNECTION_STOPPED, { kind: "disconnected", message: STATUS_CONNECTION_STOPPED });
|
||||||
host.stateStore.dispatch({ type: "connection/scoped-cleared" });
|
host.stateStore.dispatch({ type: "connection/scoped-cleared" });
|
||||||
host.resetThreadTurnPresence(false);
|
host.resetThreadTurnPresence(false);
|
||||||
host.refreshLiveState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatConnectionActions {
|
export interface ChatConnectionActions {
|
||||||
|
ensureInitialized(): Promise<void>;
|
||||||
ensureConnected(): Promise<void>;
|
ensureConnected(): Promise<void>;
|
||||||
invalidate(): void;
|
invalidate(): void;
|
||||||
handleExit(): void;
|
handleExit(): void;
|
||||||
|
|
@ -64,28 +64,40 @@ export interface ChatConnectionActions {
|
||||||
|
|
||||||
export function createChatConnectionActions(host: ChatConnectionActionsHost): ChatConnectionActions {
|
export function createChatConnectionActions(host: ChatConnectionActionsHost): ChatConnectionActions {
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
let activeConnection: { generation: number; promise: Promise<void> } | null = null;
|
let activeConnection: { generation: number; initialization: Promise<void>; hydration: Promise<void> } | null = null;
|
||||||
const invalidate = (): void => {
|
const invalidate = (): void => {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
activeConnection = null;
|
activeConnection = null;
|
||||||
};
|
};
|
||||||
const isStale = (candidateGeneration: number): boolean => candidateGeneration !== generation;
|
const isStale = (candidateGeneration: number): boolean => candidateGeneration !== generation;
|
||||||
|
const startConnection = (): NonNullable<typeof activeConnection> => {
|
||||||
|
const connectionGeneration = generation;
|
||||||
|
const connectionIsStale = (): boolean => isStale(connectionGeneration);
|
||||||
|
const initialization = initializeConnection(host, connectionIsStale);
|
||||||
|
const hydration = initialization.then(async () => {
|
||||||
|
if (connectionIsStale() || !host.connection.isConnected()) return;
|
||||||
|
await hydrateConnectedResources(host, connectionIsStale);
|
||||||
|
});
|
||||||
|
const active = { generation: connectionGeneration, initialization, hydration };
|
||||||
|
activeConnection = active;
|
||||||
|
const clear = (): void => {
|
||||||
|
if (activeConnection === active) activeConnection = null;
|
||||||
|
};
|
||||||
|
void hydration.then(clear, clear);
|
||||||
|
return active;
|
||||||
|
};
|
||||||
const actions: ChatConnectionActions = {
|
const actions: ChatConnectionActions = {
|
||||||
ensureConnected: async () => {
|
ensureInitialized: async () => {
|
||||||
if (activeConnection) return activeConnection.promise;
|
if (!host.canConnect()) return;
|
||||||
|
if (activeConnection) return activeConnection.initialization;
|
||||||
if (host.connection.isConnected()) return;
|
if (host.connection.isConnected()) return;
|
||||||
|
await startConnection().initialization;
|
||||||
const connectionGeneration = generation;
|
},
|
||||||
const promise = initializeConnection(host, () => isStale(connectionGeneration));
|
ensureConnected: async () => {
|
||||||
const active = { generation: connectionGeneration, promise };
|
if (!host.canConnect()) return;
|
||||||
activeConnection = active;
|
if (activeConnection) return activeConnection.hydration;
|
||||||
try {
|
if (host.connection.isConnected()) return;
|
||||||
await promise;
|
await startConnection().hydration;
|
||||||
} finally {
|
|
||||||
if (activeConnection === active) {
|
|
||||||
activeConnection = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
invalidate,
|
invalidate,
|
||||||
handleExit: () => {
|
handleExit: () => {
|
||||||
|
|
@ -105,7 +117,7 @@ async function refreshActiveThreads(host: ChatConnectionActionsHost): Promise<vo
|
||||||
await host.refreshSharedThreads();
|
await host.refreshSharedThreads();
|
||||||
host.refreshTabHeader();
|
host.refreshTabHeader();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (host.isStaleResourceContextError(error)) return;
|
if (host.isStaleRuntimeError(error)) return;
|
||||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,22 +155,20 @@ async function initializeConnection(host: ChatConnectionActionsHost, isStale: ()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isStale()) return;
|
if (isStale()) return;
|
||||||
if (host.isStaleConnectionError(error)) return;
|
if (host.isStaleConnectionError(error)) return;
|
||||||
if (host.isStaleResourceContextError(error)) return;
|
if (host.isStaleRuntimeError(error)) return;
|
||||||
const message = connectionErrorMessage(error, host.configuredCommand());
|
const message = connectionErrorMessage(error, host.configuredCommand());
|
||||||
host.setStatus(STATUS_CONNECTION_FAILED, { kind: "failed", message });
|
host.setStatus(STATUS_CONNECTION_FAILED, { kind: "failed", message });
|
||||||
host.addSystemMessage(message);
|
host.addSystemMessage(message);
|
||||||
host.notifyConnectionFailed();
|
host.notifyConnectionFailed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await hydrateConnectedResources(host, isStale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStale: () => boolean): Promise<void> {
|
async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStale: () => boolean): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await host.metadata.refreshAppServerMetadata();
|
await host.metadata.refreshAppServerMetadata();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isStale() || host.isStaleResourceContextError(error)) return;
|
if (isStale() || host.isStaleRuntimeError(error)) return;
|
||||||
host.addSystemMessage(`Could not refresh Codex metadata: ${errorMessage(error)}`);
|
host.addSystemMessage(`Could not refresh Codex metadata: ${errorMessage(error)}`);
|
||||||
}
|
}
|
||||||
if (isStale()) return;
|
if (isStale()) return;
|
||||||
|
|
@ -166,7 +176,7 @@ async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStal
|
||||||
try {
|
try {
|
||||||
await host.refreshSharedThreads();
|
await host.refreshSharedThreads();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isStale() || host.isStaleResourceContextError(error)) return;
|
if (isStale() || host.isStaleRuntimeError(error)) return;
|
||||||
host.addSystemMessage(`Could not refresh Codex threads: ${errorMessage(error)}`);
|
host.addSystemMessage(`Could not refresh Codex threads: ${errorMessage(error)}`);
|
||||||
}
|
}
|
||||||
if (isStale()) return;
|
if (isStale()) return;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { capturePanelTargetLease, panelTargetLeaseIsCurrent } from "../state/panel-target";
|
||||||
import { activeThreadId, activeThreadState, type ChatConnectionPhase, panelThreadId } from "../state/root-reducer";
|
import { activeThreadId, activeThreadState, type ChatConnectionPhase, panelThreadId } from "../state/root-reducer";
|
||||||
import type { ChatStateStore } from "../state/store";
|
import type { ChatStateStore } from "../state/store";
|
||||||
|
|
||||||
|
|
@ -5,42 +6,43 @@ const STATUS_RECONNECTING = "Reconnecting...";
|
||||||
|
|
||||||
export interface ChatReconnectActionsHost {
|
export interface ChatReconnectActionsHost {
|
||||||
stateStore: ChatStateStore;
|
stateStore: ChatStateStore;
|
||||||
invalidateConnectionWork: () => void;
|
resetConnectionScope: () => void;
|
||||||
invalidateThreadWork: () => void;
|
|
||||||
clearDeferredDiagnostics: () => void;
|
|
||||||
resetConnection: () => void;
|
|
||||||
setStatus: (statusText: string, phase?: ChatConnectionPhase) => void;
|
setStatus: (statusText: string, phase?: ChatConnectionPhase) => void;
|
||||||
ensureConnected: () => Promise<void>;
|
ensureConnected: () => Promise<void>;
|
||||||
isConnected: () => boolean;
|
isConnected: () => boolean;
|
||||||
resumeThread: (threadId: string) => Promise<void>;
|
resumeThread: (threadId: string) => Promise<boolean>;
|
||||||
addSystemMessage: (text: string) => void;
|
addSystemMessage: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reconnectPanel(
|
export function createReconnectPanelAction(host: ChatReconnectActionsHost): () => Promise<boolean> {
|
||||||
host: ChatReconnectActionsHost,
|
let activeReconnect: Promise<boolean> | null = null;
|
||||||
target: { resumeThreadId: string | null; isCurrent?: () => boolean } | null = null,
|
return async () => {
|
||||||
): Promise<boolean> {
|
if (activeReconnect) return activeReconnect;
|
||||||
|
const operation = reconnectPanel(host);
|
||||||
|
activeReconnect = operation;
|
||||||
|
try {
|
||||||
|
return await operation;
|
||||||
|
} finally {
|
||||||
|
if (activeReconnect === operation) activeReconnect = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconnectPanel(host: ChatReconnectActionsHost): Promise<boolean> {
|
||||||
const currentState = host.stateStore.getState();
|
const currentState = host.stateStore.getState();
|
||||||
const threadId = target
|
const panelTarget = capturePanelTargetLease(currentState);
|
||||||
? target.resumeThreadId
|
const threadId = activeThreadState(currentState)?.lifetime?.kind === "ephemeral" ? null : panelThreadId(currentState);
|
||||||
: activeThreadState(currentState)?.lifetime?.kind === "ephemeral"
|
|
||||||
? null
|
|
||||||
: panelThreadId(currentState);
|
|
||||||
const isCurrent = target?.isCurrent ?? (() => true);
|
|
||||||
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||||
host.invalidateConnectionWork();
|
host.resetConnectionScope();
|
||||||
host.invalidateThreadWork();
|
|
||||||
host.clearDeferredDiagnostics();
|
|
||||||
host.resetConnection();
|
|
||||||
host.stateStore.dispatch({ type: "connection/scoped-cleared" });
|
host.stateStore.dispatch({ type: "connection/scoped-cleared" });
|
||||||
host.setStatus(STATUS_RECONNECTING, { kind: "connecting" });
|
host.setStatus(STATUS_RECONNECTING, { kind: "connecting" });
|
||||||
|
|
||||||
await host.ensureConnected();
|
await host.ensureConnected();
|
||||||
if (!isCurrent() || !host.isConnected()) return false;
|
if (!host.isConnected() || !panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
|
||||||
if (!threadId) return true;
|
if (!threadId) return true;
|
||||||
try {
|
try {
|
||||||
await host.resumeThread(threadId);
|
if (!(await host.resumeThread(threadId))) return false;
|
||||||
if (!isCurrent()) return false;
|
if (!panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
|
||||||
return activeThreadId(host.stateStore.getState()) === threadId;
|
return activeThreadId(host.stateStore.getState()) === threadId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import {
|
import {
|
||||||
cloneServerDiagnostics,
|
cloneServerDiagnostics,
|
||||||
diagnosticsWithMetadataResourceProbes,
|
diagnosticsWithMetadataResourceProbes,
|
||||||
|
diagnosticsWithProbe,
|
||||||
upsertMcpServerDiagnostic,
|
upsertMcpServerDiagnostic,
|
||||||
} from "../../../../domain/server/diagnostics";
|
} from "../../../../domain/server/diagnostics";
|
||||||
import type { McpServerStartupStatus } from "../../../../domain/server/mcp-status";
|
import type { McpServerStartupStatus } from "../../../../domain/server/mcp-status";
|
||||||
import type { SharedServerMetadata } from "../../../../domain/server/metadata";
|
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../../../domain/server/metadata";
|
||||||
import type { ChatStateStore } from "../state/store";
|
import type { ChatStateStore } from "../state/store";
|
||||||
|
|
||||||
export type AppServerResourceEvent =
|
export type AppServerResourceEvent =
|
||||||
|
|
@ -15,22 +16,22 @@ export type AppServerResourceEvent =
|
||||||
export interface ServerMetadataActionsHost {
|
export interface ServerMetadataActionsHost {
|
||||||
stateStore: ChatStateStore;
|
stateStore: ChatStateStore;
|
||||||
appServerMetadataSnapshot: () => SharedServerMetadata | null;
|
appServerMetadataSnapshot: () => SharedServerMetadata | null;
|
||||||
refreshAppServerMetadata: () => Promise<SharedServerMetadata | null>;
|
refreshAppServerMetadata: () => Promise<void>;
|
||||||
refreshSkills: () => Promise<SharedServerMetadata | null>;
|
refreshSkills: () => Promise<void>;
|
||||||
refreshRateLimits: () => Promise<SharedServerMetadata | null>;
|
refreshRateLimits: () => Promise<void>;
|
||||||
isStaleResourceContextError: (error: unknown) => boolean;
|
isStaleRuntimeError: (error: unknown) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerMetadataActions {
|
export interface ServerMetadataActions {
|
||||||
applyAppServerMetadata: (metadata: SharedServerMetadata) => void;
|
applyAppServerMetadataResource: (resource: SharedServerMetadataResource) => void;
|
||||||
refreshAppServerMetadata: () => Promise<SharedServerMetadata | null>;
|
refreshAppServerMetadata: () => Promise<void>;
|
||||||
applyAppServerResourceEvent: (event: AppServerResourceEvent) => Promise<void>;
|
applyAppServerResourceEvent: (event: AppServerResourceEvent) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerMetadataActions(host: ServerMetadataActionsHost): ServerMetadataActions {
|
export function createServerMetadataActions(host: ServerMetadataActionsHost): ServerMetadataActions {
|
||||||
return {
|
return {
|
||||||
applyAppServerMetadata: (metadata) => {
|
applyAppServerMetadataResource: (resource) => {
|
||||||
applyAppServerMetadata(host, metadata);
|
applyAppServerMetadataResource(host, resource);
|
||||||
},
|
},
|
||||||
refreshAppServerMetadata: () => refreshAppServerMetadata(host),
|
refreshAppServerMetadata: () => refreshAppServerMetadata(host),
|
||||||
applyAppServerResourceEvent: async (event) => {
|
applyAppServerResourceEvent: async (event) => {
|
||||||
|
|
@ -56,53 +57,65 @@ async function applyAppServerResourceEvent(host: ServerMetadataActionsHost, even
|
||||||
if (event.name.length > 0) {
|
if (event.name.length > 0) {
|
||||||
applyMcpStartupStatusEvent(host, event.name, event.status, event.message);
|
applyMcpStartupStatusEvent(host, event.name, event.status, event.message);
|
||||||
}
|
}
|
||||||
applyCurrentAppServerMetadataSnapshot(host);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAppServerMetadata(host: ServerMetadataActionsHost, metadata: SharedServerMetadata): void {
|
function applyAppServerMetadataResource(host: ServerMetadataActionsHost, resource: SharedServerMetadataResource): void {
|
||||||
const serverDiagnostics = diagnosticsWithMetadataResourceProbes(
|
if (resource.id === "runtimeConfig") {
|
||||||
|
if (resource.value) host.stateStore.dispatch({ type: "connection/metadata-applied", runtimeConfig: resource.value });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serverDiagnostics = diagnosticsWithProbe(
|
||||||
cloneServerDiagnostics(host.stateStore.getState().connection.serverDiagnostics),
|
cloneServerDiagnostics(host.stateStore.getState().connection.serverDiagnostics),
|
||||||
metadata.serverDiagnostics,
|
resource.probe,
|
||||||
);
|
);
|
||||||
host.stateStore.dispatch({
|
switch (resource.id) {
|
||||||
type: "connection/metadata-applied",
|
case "models":
|
||||||
runtimeConfig: metadata.runtimeConfig,
|
host.stateStore.dispatch({
|
||||||
availableSkills: metadata.availableSkills,
|
type: "connection/metadata-applied",
|
||||||
availablePermissionProfiles: metadata.availablePermissionProfiles,
|
...(resource.value ? { availableModels: resource.value } : {}),
|
||||||
rateLimit: metadata.rateLimit,
|
serverDiagnostics,
|
||||||
serverDiagnostics,
|
});
|
||||||
});
|
return;
|
||||||
|
case "skills":
|
||||||
|
host.stateStore.dispatch({
|
||||||
|
type: "connection/metadata-applied",
|
||||||
|
...(resource.value ? { availableSkills: resource.value } : {}),
|
||||||
|
serverDiagnostics,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "permissionProfiles":
|
||||||
|
host.stateStore.dispatch({
|
||||||
|
type: "connection/metadata-applied",
|
||||||
|
...(resource.value ? { availablePermissionProfiles: resource.value } : {}),
|
||||||
|
serverDiagnostics,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "rateLimits":
|
||||||
|
host.stateStore.dispatch({
|
||||||
|
type: "connection/metadata-applied",
|
||||||
|
...(resource.value !== undefined ? { rateLimit: resource.value } : {}),
|
||||||
|
serverDiagnostics,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promise<SharedServerMetadata | null> {
|
async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promise<void> {
|
||||||
let metadata: SharedServerMetadata | null;
|
|
||||||
try {
|
try {
|
||||||
metadata = await host.refreshAppServerMetadata();
|
await host.refreshAppServerMetadata();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (host.isStaleResourceContextError(error)) return null;
|
if (host.isStaleRuntimeError(error)) return;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (!metadata) return null;
|
|
||||||
applyAppServerMetadata(host, metadata);
|
|
||||||
return metadata;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCurrentAppServerMetadataSnapshot(host: ServerMetadataActionsHost): void {
|
async function refreshMetadataResource(host: ServerMetadataActionsHost, refresh: () => Promise<void>): Promise<void> {
|
||||||
const metadata = host.appServerMetadataSnapshot();
|
|
||||||
if (metadata) applyAppServerMetadata(host, metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshMetadataResource(
|
|
||||||
host: ServerMetadataActionsHost,
|
|
||||||
refresh: () => Promise<SharedServerMetadata | null>,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const metadata = await refresh();
|
await refresh();
|
||||||
if (metadata) applyAppServerMetadata(host, metadata);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!host.isStaleResourceContextError(error)) throw error;
|
if (!host.isStaleRuntimeError(error)) throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
14
src/features/chat/application/effect-outcome.ts
Normal file
14
src/features/chat/application/effect-outcome.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
export type EffectOutcome<T> =
|
||||||
|
| { readonly kind: "not-started" }
|
||||||
|
| { readonly kind: "completed-current"; readonly value: T }
|
||||||
|
| { readonly kind: "completed-stale"; readonly value: T };
|
||||||
|
|
||||||
|
export function effectCompleted<T>(outcome: EffectOutcome<T>): outcome is Exclude<EffectOutcome<T>, { kind: "not-started" }> {
|
||||||
|
return outcome.kind !== "not-started";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectCompletedInCurrentContext<T>(
|
||||||
|
outcome: EffectOutcome<T>,
|
||||||
|
): outcome is Extract<EffectOutcome<T>, { kind: "completed-current" }> {
|
||||||
|
return outcome.kind === "completed-current";
|
||||||
|
}
|
||||||
135
src/features/chat/application/panel-operation-policy.ts
Normal file
135
src/features/chat/application/panel-operation-policy.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import type { ChatState } from "./state/root-reducer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations whose availability derives from the panel's current thread mode.
|
||||||
|
*
|
||||||
|
* This intentionally excludes connection, turn-busy, and operations aimed at a
|
||||||
|
* different listed thread. Those are workflow concerns rather than properties
|
||||||
|
* of the active panel thread.
|
||||||
|
*/
|
||||||
|
export type ActivePanelOperation =
|
||||||
|
| "submit"
|
||||||
|
| "start-side-chat"
|
||||||
|
| "compact"
|
||||||
|
| "fork"
|
||||||
|
| "rollback"
|
||||||
|
| "thread-settings"
|
||||||
|
| "permission-settings"
|
||||||
|
| "goal-read"
|
||||||
|
| "goal-mutation"
|
||||||
|
| "implement-plan";
|
||||||
|
|
||||||
|
export type ActivePanelOperationDecision =
|
||||||
|
| { readonly kind: "allowed" }
|
||||||
|
| { readonly kind: "blocked"; readonly message: string }
|
||||||
|
| { readonly kind: "resume-required" };
|
||||||
|
|
||||||
|
type ActivePanelThreadFacts =
|
||||||
|
| { readonly phase: "empty" }
|
||||||
|
| { readonly phase: "awaiting-resume"; readonly provenance: "interactive" | "subagent" | null }
|
||||||
|
| {
|
||||||
|
readonly phase: "active";
|
||||||
|
readonly lifetime: "persistent" | "ephemeral";
|
||||||
|
readonly canAcceptDirectInput: boolean | null;
|
||||||
|
readonly provenance: "interactive" | "subagent" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOWED: ActivePanelOperationDecision = { kind: "allowed" };
|
||||||
|
|
||||||
|
export function activePanelOperationDecision(state: ChatState, operation: ActivePanelOperation): ActivePanelOperationDecision {
|
||||||
|
return activePanelOperationDecisionForFacts(activePanelThreadFacts(state), operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activePanelOperationDecisionForFacts(
|
||||||
|
facts: ActivePanelThreadFacts,
|
||||||
|
operation: ActivePanelOperation,
|
||||||
|
): ActivePanelOperationDecision {
|
||||||
|
if (facts.phase === "awaiting-resume") {
|
||||||
|
if (facts.provenance === "subagent") return agentThreadBlocked(operation);
|
||||||
|
return operation === "goal-mutation" ? { kind: "resume-required" } : ALLOWED;
|
||||||
|
}
|
||||||
|
if (facts.phase === "empty") return ALLOWED;
|
||||||
|
|
||||||
|
if (operation === "submit") {
|
||||||
|
if (facts.canAcceptDirectInput !== null) return facts.canAcceptDirectInput ? ALLOWED : directInputBlocked();
|
||||||
|
if (facts.provenance === "subagent") return agentThreadBlocked(operation);
|
||||||
|
if (facts.lifetime === "ephemeral") return sideChatDecision(operation);
|
||||||
|
return ALLOWED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the stricter interpretation if a malformed or future state contains
|
||||||
|
// both mode facts. This makes mode restrictions monotonically safe.
|
||||||
|
if (facts.provenance === "subagent" && facts.lifetime === "ephemeral") {
|
||||||
|
return operation === "goal-read" ? sideChatDecision(operation) : agentThreadBlocked(operation);
|
||||||
|
}
|
||||||
|
if (facts.provenance === "subagent") return agentThreadBlocked(operation);
|
||||||
|
if (facts.lifetime === "ephemeral") return sideChatDecision(operation);
|
||||||
|
return ALLOWED;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activePanelThreadFacts(state: ChatState): ActivePanelThreadFacts {
|
||||||
|
const panelThread = state.panelThread;
|
||||||
|
if (panelThread.kind === "empty") return { phase: "empty" };
|
||||||
|
if (panelThread.kind === "awaiting-resume") {
|
||||||
|
return { phase: "awaiting-resume", provenance: panelThread.provenance?.kind ?? null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
phase: "active",
|
||||||
|
lifetime: panelThread.thread.lifetime?.kind ?? "persistent",
|
||||||
|
canAcceptDirectInput: panelThread.thread.canAcceptDirectInput,
|
||||||
|
provenance: panelThread.thread.provenance?.kind ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function directInputBlocked(): ActivePanelOperationDecision {
|
||||||
|
return { kind: "blocked", message: "This thread cannot accept messages." };
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentThreadBlocked(operation: ActivePanelOperation): ActivePanelOperationDecision {
|
||||||
|
switch (operation) {
|
||||||
|
case "submit":
|
||||||
|
return { kind: "blocked", message: "Messages are unavailable in agent threads. Start a new chat to continue." };
|
||||||
|
case "goal-mutation":
|
||||||
|
return { kind: "blocked", message: "Goals are read-only in agent threads." };
|
||||||
|
case "goal-read":
|
||||||
|
return ALLOWED;
|
||||||
|
case "implement-plan":
|
||||||
|
return { kind: "blocked", message: "Plans cannot be implemented from agent threads." };
|
||||||
|
case "compact":
|
||||||
|
return { kind: "blocked", message: "Agent threads cannot be compacted." };
|
||||||
|
case "fork":
|
||||||
|
return { kind: "blocked", message: "Agent threads cannot be forked." };
|
||||||
|
case "rollback":
|
||||||
|
return { kind: "blocked", message: "Agent threads cannot be rolled back." };
|
||||||
|
case "start-side-chat":
|
||||||
|
return { kind: "blocked", message: "Side chats cannot be started from agent threads." };
|
||||||
|
case "thread-settings":
|
||||||
|
case "permission-settings":
|
||||||
|
return { kind: "blocked", message: "Thread settings are unavailable in agent threads." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sideChatDecision(operation: ActivePanelOperation): ActivePanelOperationDecision {
|
||||||
|
switch (operation) {
|
||||||
|
case "goal-mutation":
|
||||||
|
return { kind: "blocked", message: "Goals are unavailable in side chats." };
|
||||||
|
case "goal-read":
|
||||||
|
return { kind: "blocked", message: "Goals are unavailable in side chats." };
|
||||||
|
case "implement-plan":
|
||||||
|
return { kind: "blocked", message: "Plans cannot be implemented from side chats." };
|
||||||
|
case "compact":
|
||||||
|
return ALLOWED;
|
||||||
|
case "fork":
|
||||||
|
return { kind: "blocked", message: "Side chats cannot be forked." };
|
||||||
|
case "rollback":
|
||||||
|
return { kind: "blocked", message: "Side chats cannot be rolled back." };
|
||||||
|
case "start-side-chat":
|
||||||
|
return { kind: "blocked", message: "Side chats cannot be started from another side chat." };
|
||||||
|
case "permission-settings":
|
||||||
|
return { kind: "blocked", message: "Permission changes are unavailable in side chats." };
|
||||||
|
case "submit":
|
||||||
|
return ALLOWED;
|
||||||
|
case "thread-settings":
|
||||||
|
return { kind: "blocked", message: "Thread settings are unavailable in side chats." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,6 @@ export interface PendingRequestActionsHost {
|
||||||
responder: PendingRequestResponder;
|
responder: PendingRequestResponder;
|
||||||
composerHasFocus: () => boolean;
|
composerHasFocus: () => boolean;
|
||||||
focusComposer: () => void;
|
focusComposer: () => void;
|
||||||
refreshLiveState: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingRequestActions {
|
export interface PendingRequestActions {
|
||||||
|
|
@ -121,6 +120,5 @@ function pendingUserInput(host: PendingRequestActionsHost, requestId: PendingReq
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitRequestAction(host: PendingRequestActionsHost): void {
|
function commitRequestAction(host: PendingRequestActionsHost): void {
|
||||||
host.refreshLiveState();
|
|
||||||
host.focusComposer();
|
host.focusComposer();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
|
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
|
||||||
import { type RuntimeConfigSnapshot, runtimeConfigOrDefault } from "../../../../domain/runtime/config";
|
import { type RuntimeConfigSnapshot, runtimeConfigOrDefault } from "../../../../domain/runtime/config";
|
||||||
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
|
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
|
||||||
|
import { createKeyedOperationQueue, type KeyedOperationQueue } from "../../../../shared/runtime/keyed-operation-queue";
|
||||||
import { type CollaborationModeSelection, nextCollaborationMode, type RequestedFastMode } from "../../domain/runtime/intent";
|
import { type CollaborationModeSelection, nextCollaborationMode, type RequestedFastMode } from "../../domain/runtime/intent";
|
||||||
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels";
|
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels";
|
||||||
import { resolveRuntimeControls } from "../../domain/runtime/resolution";
|
import { resolveRuntimeControls } from "../../domain/runtime/resolution";
|
||||||
|
|
@ -9,7 +10,9 @@ import {
|
||||||
pendingRuntimeSettingsPatch as buildPendingRuntimeSettingsPatch,
|
pendingRuntimeSettingsPatch as buildPendingRuntimeSettingsPatch,
|
||||||
type PendingRuntimeSettingsPatch,
|
type PendingRuntimeSettingsPatch,
|
||||||
} from "../../domain/runtime/thread-settings-patch";
|
} from "../../domain/runtime/thread-settings-patch";
|
||||||
import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../state/root-reducer";
|
import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy";
|
||||||
|
import { capturePanelTargetLease, panelTargetLeaseIsCurrent } from "../state/panel-target";
|
||||||
|
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||||
import type { ChatStateStore } from "../state/store";
|
import type { ChatStateStore } from "../state/store";
|
||||||
import type { RuntimeSettingsTransport } from "./settings-transport";
|
import type { RuntimeSettingsTransport } from "./settings-transport";
|
||||||
|
|
||||||
|
|
@ -29,6 +32,15 @@ export interface RuntimeSettingsActionsHost {
|
||||||
addSystemMessage: (text: string) => void;
|
addSystemMessage: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RuntimeSettingsActionsContext extends RuntimeSettingsActionsHost {
|
||||||
|
threadCommits: KeyedOperationQueue<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeSettingsCommandScope {
|
||||||
|
readonly threadId: string;
|
||||||
|
readonly panelTargetRevision: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatRuntimeSettingsActions {
|
export interface ChatRuntimeSettingsActions {
|
||||||
applyPendingThreadSettings: () => Promise<boolean>;
|
applyPendingThreadSettings: () => Promise<boolean>;
|
||||||
requestModel: (model: string) => Promise<boolean>;
|
requestModel: (model: string) => Promise<boolean>;
|
||||||
|
|
@ -51,152 +63,215 @@ export interface ChatRuntimeSettingsActions {
|
||||||
toggleAutoReview: () => Promise<void>;
|
toggleAutoReview: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createChatRuntimeSettingsActions(host: RuntimeSettingsActionsHost): ChatRuntimeSettingsActions {
|
export function createChatRuntimeSettingsActions(
|
||||||
|
host: RuntimeSettingsActionsHost,
|
||||||
|
threadCommits: KeyedOperationQueue<string> = createKeyedOperationQueue(),
|
||||||
|
): ChatRuntimeSettingsActions {
|
||||||
|
const context: RuntimeSettingsActionsContext = { ...host, threadCommits };
|
||||||
return {
|
return {
|
||||||
applyPendingThreadSettings: () => applyPendingThreadSettings(host),
|
applyPendingThreadSettings: () => applyPendingThreadSettings(context),
|
||||||
requestModel: (model) => requestModel(host, model),
|
requestModel: (model) => requestModel(context, model),
|
||||||
resetModelToConfig: () => resetModelToConfig(host),
|
resetModelToConfig: () => resetModelToConfig(context),
|
||||||
requestModelFromUi: (model) => requestModelFromUi(host, model),
|
requestModelFromUi: (model) => requestModelFromUi(context, model),
|
||||||
requestReasoningEffort: (effort) => requestReasoningEffort(host, effort),
|
requestReasoningEffort: (effort) => requestReasoningEffort(context, effort),
|
||||||
resetReasoningEffortToConfig: () => resetReasoningEffortToConfig(host),
|
resetReasoningEffortToConfig: () => resetReasoningEffortToConfig(context),
|
||||||
requestReasoningEffortFromUi: (effort) => requestReasoningEffortFromUi(host, effort),
|
requestReasoningEffortFromUi: (effort) => requestReasoningEffortFromUi(context, effort),
|
||||||
resetReasoningEffortToConfigFromUi: () => resetReasoningEffortToConfigFromUi(host),
|
resetReasoningEffortToConfigFromUi: () => resetReasoningEffortToConfigFromUi(context),
|
||||||
requestPermissionProfile: (permissionProfile) => requestPermissionProfile(host, permissionProfile),
|
requestPermissionProfile: (permissionProfile) => requestPermissionProfile(context, permissionProfile),
|
||||||
resetPermissionProfileToConfig: () => resetPermissionProfileToConfig(host),
|
resetPermissionProfileToConfig: () => resetPermissionProfileToConfig(context),
|
||||||
enableFastMode: () => setFastMode(host, "enabled"),
|
enableFastMode: () => setFastMode(context, "enabled"),
|
||||||
disableFastMode: () => setFastMode(host, "disabled"),
|
disableFastMode: () => setFastMode(context, "disabled"),
|
||||||
toggleFastMode: () => toggleFastMode(host),
|
toggleFastMode: () => toggleFastMode(context),
|
||||||
toggleCollaborationMode: () => toggleCollaborationMode(host),
|
toggleCollaborationMode: () => toggleCollaborationMode(context),
|
||||||
setCollaborationMode: (collaborationMode) => setCollaborationMode(host, collaborationMode),
|
setCollaborationMode: (collaborationMode) => setCollaborationMode(context, collaborationMode),
|
||||||
requestDefaultCollaborationModeForNextTurn: () => {
|
requestDefaultCollaborationModeForNextTurn: () => {
|
||||||
requestDefaultCollaborationModeForNextTurn(host);
|
requestDefaultCollaborationModeForNextTurn(context);
|
||||||
},
|
},
|
||||||
enableAutoReview: () => setAutoReview(host, "enabled"),
|
enableAutoReview: () => setAutoReview(context, "enabled"),
|
||||||
disableAutoReview: () => setAutoReview(host, "disabled"),
|
disableAutoReview: () => setAutoReview(context, "disabled"),
|
||||||
toggleAutoReview: () => toggleAutoReview(host),
|
toggleAutoReview: () => toggleAutoReview(context),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyPendingThreadSettings(host: RuntimeSettingsActionsHost): Promise<boolean> {
|
async function applyPendingThreadSettings(
|
||||||
return (await commitPendingThreadSettings(host)).ok;
|
host: RuntimeSettingsActionsContext,
|
||||||
|
fields?: readonly (keyof RuntimeSettingsPatch)[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
return (await commitPendingThreadSettings(host, fields)).ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Promise<RuntimeSettingsCommitResult> {
|
async function commitPendingThreadSettings(
|
||||||
|
host: RuntimeSettingsActionsContext,
|
||||||
|
fields?: readonly (keyof RuntimeSettingsPatch)[],
|
||||||
|
): Promise<RuntimeSettingsCommitResult> {
|
||||||
const threadId = activeThreadId(state(host));
|
const threadId = activeThreadId(state(host));
|
||||||
if (!threadId) return { ok: true, collaborationModeApplied: true };
|
if (!threadId) return { ok: true, collaborationModeApplied: true };
|
||||||
|
const panelTarget = capturePanelTargetLease(state(host));
|
||||||
|
|
||||||
const { update, collaborationModeWarning } = pendingRuntimeSettingsPatch(host);
|
const { update, collaborationModeWarning } = pendingRuntimeSettingsPatch(host);
|
||||||
if (collaborationModeWarning) reportCollaborationModeWarning(host, collaborationModeWarning);
|
if (collaborationModeWarning) reportCollaborationModeWarning(host, collaborationModeWarning);
|
||||||
const collaborationModeApplied = !collaborationModeWarning && "collaborationMode" in update;
|
const collaborationModeApplied = !collaborationModeWarning && "collaborationMode" in update;
|
||||||
if (Object.keys(update).length === 0) return { ok: true, collaborationModeApplied };
|
if (Object.keys(update).length === 0) return { ok: true, collaborationModeApplied };
|
||||||
|
|
||||||
|
if (activePanelOperationBlocked(host, "thread-settings")) return { ok: false, collaborationModeApplied: false };
|
||||||
|
const scope = { threadId, panelTargetRevision: panelTarget.revision };
|
||||||
|
const ok = fields
|
||||||
|
? await commitRuntimeSettingsFields(host, scope, pickRuntimeSettingsFields(update, fields))
|
||||||
|
: await settleRuntimeSettings(host, scope);
|
||||||
|
return { ok, collaborationModeApplied: ok && collaborationModeApplied };
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRuntimeSettingsFields(
|
||||||
|
host: RuntimeSettingsActionsContext,
|
||||||
|
scope: RuntimeSettingsCommandScope,
|
||||||
|
update: RuntimeSettingsPatch,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (patchEmpty(update)) return Promise.resolve(true);
|
||||||
|
const command = { ...update };
|
||||||
|
return host.threadCommits.run(scope.threadId, async () => {
|
||||||
|
if (!runtimeSettingsScopeIsCurrent(host, scope)) return false;
|
||||||
|
if (!patchEqual(matchingPendingPatch(currentPendingRuntimeSettingsPatch(host), command), command)) return false;
|
||||||
|
|
||||||
|
const updated = await updateRuntimeSettings(host, scope, command);
|
||||||
|
if (!updated || !runtimeSettingsScopeIsCurrent(host, scope)) return false;
|
||||||
|
|
||||||
|
const committed = matchingPendingPatch(currentPendingRuntimeSettingsPatch(host), command);
|
||||||
|
if (!patchEmpty(committed)) commitRuntimeSettingsPatch(host, committed);
|
||||||
|
return patchEqual(committed, command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleRuntimeSettings(host: RuntimeSettingsActionsContext, scope: RuntimeSettingsCommandScope): Promise<boolean> {
|
||||||
|
while (runtimeSettingsScopeIsCurrent(host, scope)) {
|
||||||
|
const result = await host.threadCommits.run(scope.threadId, async (): Promise<"continue" | "failed" | "settled"> => {
|
||||||
|
if (!runtimeSettingsScopeIsCurrent(host, scope)) return "failed";
|
||||||
|
const update = currentPendingRuntimeSettingsPatch(host);
|
||||||
|
if (patchEmpty(update)) return "settled";
|
||||||
|
|
||||||
|
if (!(await updateRuntimeSettings(host, scope, update)) || !runtimeSettingsScopeIsCurrent(host, scope)) return "failed";
|
||||||
|
|
||||||
|
const committed = matchingPendingPatch(currentPendingRuntimeSettingsPatch(host), update);
|
||||||
|
if (!patchEmpty(committed)) commitRuntimeSettingsPatch(host, committed);
|
||||||
|
return patchEmpty(currentPendingRuntimeSettingsPatch(host)) ? "settled" : "continue";
|
||||||
|
});
|
||||||
|
if (result !== "continue") return result === "settled";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRuntimeSettings(
|
||||||
|
host: RuntimeSettingsActionsContext,
|
||||||
|
scope: RuntimeSettingsCommandScope,
|
||||||
|
update: RuntimeSettingsPatch,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (!(await host.runtimeTransport.updateThreadSettings(threadId, update))) {
|
return await host.runtimeTransport.updateThreadSettings(scope.threadId, update);
|
||||||
return { ok: false, collaborationModeApplied: false };
|
|
||||||
}
|
|
||||||
if (activeThreadId(state(host)) !== threadId) return { ok: false, collaborationModeApplied: false };
|
|
||||||
if (!runtimeSettingsPatchStillPending(currentPendingRuntimeSettingsPatch(host), update)) {
|
|
||||||
return { ok: false, collaborationModeApplied: false };
|
|
||||||
}
|
|
||||||
dispatch(host, { type: "runtime/pending-thread-settings-committed", update });
|
|
||||||
return { ok: true, collaborationModeApplied };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (activeThreadId(state(host)) !== threadId) return { ok: false, collaborationModeApplied: false };
|
if (runtimeSettingsScopeIsCurrent(host, scope)) {
|
||||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||||
return { ok: false, collaborationModeApplied: false };
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestModel(host: RuntimeSettingsActionsHost, model: string): Promise<boolean> {
|
function runtimeSettingsScopeIsCurrent(host: RuntimeSettingsActionsHost, scope: RuntimeSettingsCommandScope): boolean {
|
||||||
if (agentThreadSettingsBlocked(host)) return false;
|
const currentState = state(host);
|
||||||
|
return (
|
||||||
|
activeThreadId(currentState) === scope.threadId &&
|
||||||
|
panelTargetLeaseIsCurrent(currentState, {
|
||||||
|
revision: scope.panelTargetRevision,
|
||||||
|
target: { kind: "thread", threadId: scope.threadId },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRuntimeSettingsPatch(host: RuntimeSettingsActionsHost, update: RuntimeSettingsPatch): void {
|
||||||
|
dispatch(host, { type: "runtime/pending-thread-settings-committed", update });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestModel(host: RuntimeSettingsActionsContext, model: string): Promise<boolean> {
|
||||||
|
if (activePanelOperationBlocked(host, "thread-settings")) return false;
|
||||||
dispatch(host, { type: "runtime/model-requested", model });
|
dispatch(host, { type: "runtime/model-requested", model });
|
||||||
return applyPendingThreadSettings(host);
|
return applyPendingThreadSettings(host, ["model"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetModelToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
|
async function resetModelToConfig(host: RuntimeSettingsActionsContext): Promise<boolean> {
|
||||||
if (agentThreadSettingsBlocked(host)) return false;
|
if (activePanelOperationBlocked(host, "thread-settings")) return false;
|
||||||
dispatch(host, { type: "runtime/model-reset-to-config" });
|
dispatch(host, { type: "runtime/model-reset-to-config" });
|
||||||
return applyPendingThreadSettings(host);
|
return applyPendingThreadSettings(host, ["model"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestModelFromUi(host: RuntimeSettingsActionsHost, model: string): Promise<void> {
|
async function requestModelFromUi(host: RuntimeSettingsActionsContext, model: string): Promise<void> {
|
||||||
await runRuntimeUiCommand(host, () => requestModel(host, model), modelOverrideMessage(model));
|
await runRuntimeUiCommand(host, () => requestModel(host, model), modelOverrideMessage(model));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestReasoningEffort(host: RuntimeSettingsActionsHost, effort: ReasoningEffort): Promise<boolean> {
|
async function requestReasoningEffort(host: RuntimeSettingsActionsContext, effort: ReasoningEffort): Promise<boolean> {
|
||||||
if (agentThreadSettingsBlocked(host)) return false;
|
if (activePanelOperationBlocked(host, "thread-settings")) return false;
|
||||||
dispatch(host, { type: "runtime/reasoning-effort-requested", effort });
|
dispatch(host, { type: "runtime/reasoning-effort-requested", effort });
|
||||||
return applyPendingThreadSettings(host);
|
return applyPendingThreadSettings(host, ["effort"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetReasoningEffortToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
|
async function resetReasoningEffortToConfig(host: RuntimeSettingsActionsContext): Promise<boolean> {
|
||||||
if (agentThreadSettingsBlocked(host)) return false;
|
if (activePanelOperationBlocked(host, "thread-settings")) return false;
|
||||||
dispatch(host, { type: "runtime/reasoning-effort-reset-to-config" });
|
dispatch(host, { type: "runtime/reasoning-effort-reset-to-config" });
|
||||||
return applyPendingThreadSettings(host);
|
return applyPendingThreadSettings(host, ["effort"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestReasoningEffortFromUi(host: RuntimeSettingsActionsHost, effort: ReasoningEffort): Promise<void> {
|
async function requestReasoningEffortFromUi(host: RuntimeSettingsActionsContext, effort: ReasoningEffort): Promise<void> {
|
||||||
await runRuntimeUiCommand(host, () => requestReasoningEffort(host, effort), reasoningEffortOverrideMessage(effort));
|
await runRuntimeUiCommand(host, () => requestReasoningEffort(host, effort), reasoningEffortOverrideMessage(effort));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHost): Promise<void> {
|
async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsContext): Promise<void> {
|
||||||
await runRuntimeUiCommand(host, () => resetReasoningEffortToConfig(host), reasoningEffortOverrideMessage(null));
|
await runRuntimeUiCommand(host, () => resetReasoningEffortToConfig(host), reasoningEffortOverrideMessage(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise<boolean> {
|
async function requestPermissionProfile(host: RuntimeSettingsActionsContext, permissionProfile: string): Promise<boolean> {
|
||||||
if (agentThreadSettingsBlocked(host)) return false;
|
if (activePanelOperationBlocked(host, "permission-settings")) return false;
|
||||||
if (permissionChangesBlocked(host)) return false;
|
|
||||||
dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile });
|
dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile });
|
||||||
return applyPendingThreadSettings(host);
|
return applyPendingThreadSettings(host, ["permissions"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
|
async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsContext): Promise<boolean> {
|
||||||
if (agentThreadSettingsBlocked(host)) return false;
|
if (activePanelOperationBlocked(host, "permission-settings")) return false;
|
||||||
if (permissionChangesBlocked(host)) return false;
|
|
||||||
dispatch(host, { type: "runtime/permission-profile-reset-to-config" });
|
dispatch(host, { type: "runtime/permission-profile-reset-to-config" });
|
||||||
return applyPendingThreadSettings(host);
|
return applyPendingThreadSettings(host, ["permissions"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function permissionChangesBlocked(host: RuntimeSettingsActionsHost): boolean {
|
function activePanelOperationBlocked(host: RuntimeSettingsActionsHost, operation: ActivePanelOperation): boolean {
|
||||||
if (activeThreadState(state(host))?.lifetime?.kind !== "ephemeral") return false;
|
const decision = activePanelOperationDecision(state(host), operation);
|
||||||
host.addSystemMessage("Permission changes are unavailable in side chats.");
|
if (decision.kind !== "blocked") return false;
|
||||||
|
host.addSystemMessage(decision.message);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentThreadSettingsBlocked(host: RuntimeSettingsActionsHost): boolean {
|
async function toggleFastMode(host: RuntimeSettingsActionsContext): Promise<void> {
|
||||||
if (activeThreadState(state(host))?.provenance?.kind !== "subagent") return false;
|
|
||||||
host.addSystemMessage("Thread settings are unavailable in agent threads.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise<void> {
|
|
||||||
const { snapshot, config } = runtimeProjection(host);
|
const { snapshot, config } = runtimeProjection(host);
|
||||||
await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled");
|
await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setFastMode(host: RuntimeSettingsActionsHost, mode: FastModeState): Promise<void> {
|
async function setFastMode(host: RuntimeSettingsActionsContext, mode: FastModeState): Promise<void> {
|
||||||
if (agentThreadSettingsBlocked(host)) return;
|
if (activePanelOperationBlocked(host, "thread-settings")) return;
|
||||||
const fastMode: RequestedFastMode = mode;
|
const fastMode: RequestedFastMode = mode;
|
||||||
await runRuntimeUiCommand(
|
await runRuntimeUiCommand(
|
||||||
host,
|
host,
|
||||||
async () => {
|
async () => {
|
||||||
dispatch(host, { type: "runtime/fast-mode-requested", fastMode });
|
dispatch(host, { type: "runtime/fast-mode-requested", fastMode });
|
||||||
return applyPendingThreadSettings(host);
|
return applyPendingThreadSettings(host, ["serviceTier"]);
|
||||||
},
|
},
|
||||||
mode === "enabled" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns.",
|
mode === "enabled" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promise<void> {
|
async function toggleCollaborationMode(host: RuntimeSettingsActionsContext): Promise<void> {
|
||||||
const { snapshot, config } = runtimeProjection(host);
|
const { snapshot, config } = runtimeProjection(host);
|
||||||
const next = nextCollaborationMode(resolveRuntimeControls(snapshot, config).collaborationMode.effective);
|
const next = nextCollaborationMode(resolveRuntimeControls(snapshot, config).collaborationMode.effective);
|
||||||
await setCollaborationMode(host, next);
|
await setCollaborationMode(host, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborationMode: CollaborationModeSelection): Promise<boolean> {
|
async function setCollaborationMode(host: RuntimeSettingsActionsContext, collaborationMode: CollaborationModeSelection): Promise<boolean> {
|
||||||
if (agentThreadSettingsBlocked(host)) return false;
|
if (activePanelOperationBlocked(host, "thread-settings")) return false;
|
||||||
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode });
|
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode });
|
||||||
const result = await commitPendingThreadSettings(host);
|
const result = await commitPendingThreadSettings(host, ["collaborationMode"]);
|
||||||
if (result.ok) closeRuntimePanel(host);
|
if (result.ok) closeRuntimePanel(host);
|
||||||
if (result.ok && result.collaborationModeApplied) {
|
if (result.ok && result.collaborationModeApplied) {
|
||||||
host.addSystemMessage(collaborationMode === "plan" ? "Plan mode on for subsequent turns." : "Plan mode off for subsequent turns.");
|
host.addSystemMessage(collaborationMode === "plan" ? "Plan mode on for subsequent turns." : "Plan mode off for subsequent turns.");
|
||||||
|
|
@ -205,23 +280,23 @@ async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborat
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestDefaultCollaborationModeForNextTurn(host: RuntimeSettingsActionsHost): void {
|
function requestDefaultCollaborationModeForNextTurn(host: RuntimeSettingsActionsHost): void {
|
||||||
if (agentThreadSettingsBlocked(host)) return;
|
if (activePanelOperationBlocked(host, "thread-settings")) return;
|
||||||
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
|
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleAutoReview(host: RuntimeSettingsActionsHost): Promise<void> {
|
async function toggleAutoReview(host: RuntimeSettingsActionsContext): Promise<void> {
|
||||||
const { snapshot, config } = runtimeProjection(host);
|
const { snapshot, config } = runtimeProjection(host);
|
||||||
const nextState = resolveRuntimeControls(snapshot, config).autoReview.active ? "disabled" : "enabled";
|
const nextState = resolveRuntimeControls(snapshot, config).autoReview.active ? "disabled" : "enabled";
|
||||||
await setAutoReview(host, nextState);
|
await setAutoReview(host, nextState);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setAutoReview(host: RuntimeSettingsActionsHost, mode: AutoReviewState): Promise<void> {
|
async function setAutoReview(host: RuntimeSettingsActionsContext, mode: AutoReviewState): Promise<void> {
|
||||||
if (agentThreadSettingsBlocked(host)) return;
|
if (activePanelOperationBlocked(host, "thread-settings")) return;
|
||||||
await runRuntimeUiCommand(
|
await runRuntimeUiCommand(
|
||||||
host,
|
host,
|
||||||
async () => {
|
async () => {
|
||||||
dispatch(host, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: mode === "enabled" ? "auto_review" : "user" });
|
dispatch(host, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: mode === "enabled" ? "auto_review" : "user" });
|
||||||
return applyPendingThreadSettings(host);
|
return applyPendingThreadSettings(host, ["approvalsReviewer"]);
|
||||||
},
|
},
|
||||||
mode === "enabled" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns.",
|
mode === "enabled" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns.",
|
||||||
);
|
);
|
||||||
|
|
@ -261,10 +336,36 @@ function currentPendingRuntimeSettingsPatch(host: RuntimeSettingsActionsHost): R
|
||||||
return buildPendingRuntimeSettingsPatch(snapshot, config).update;
|
return buildPendingRuntimeSettingsPatch(snapshot, config).update;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeSettingsPatchStillPending(current: RuntimeSettingsPatch, applied: RuntimeSettingsPatch): boolean {
|
function pickRuntimeSettingsFields(update: RuntimeSettingsPatch, fields: readonly (keyof RuntimeSettingsPatch)[]): RuntimeSettingsPatch {
|
||||||
return (Object.keys(applied) as (keyof RuntimeSettingsPatch)[]).every((key) => {
|
const targetedUpdate: RuntimeSettingsPatch = {};
|
||||||
return key in current && threadSettingsValueEqual(current[key], applied[key]);
|
for (const key of fields) {
|
||||||
});
|
if (key in update) Object.assign(targetedUpdate, { [key]: update[key] });
|
||||||
|
}
|
||||||
|
return targetedUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchingPendingPatch(current: RuntimeSettingsPatch, sent: RuntimeSettingsPatch): RuntimeSettingsPatch {
|
||||||
|
const matching: RuntimeSettingsPatch = {};
|
||||||
|
for (const key of patchKeys(sent)) {
|
||||||
|
if (key in current && threadSettingsValueEqual(current[key], sent[key])) {
|
||||||
|
Object.assign(matching, { [key]: sent[key] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matching;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchEqual(left: RuntimeSettingsPatch, right: RuntimeSettingsPatch): boolean {
|
||||||
|
const leftKeys = patchKeys(left);
|
||||||
|
const rightKeys = patchKeys(right);
|
||||||
|
return leftKeys.length === rightKeys.length && leftKeys.every((key) => key in right && threadSettingsValueEqual(left[key], right[key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchEmpty(patch: RuntimeSettingsPatch): boolean {
|
||||||
|
return patchKeys(patch).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchKeys(patch: RuntimeSettingsPatch): (keyof RuntimeSettingsPatch)[] {
|
||||||
|
return Object.keys(patch) as (keyof RuntimeSettingsPatch)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadSettingsValueEqual(left: unknown, right: unknown): boolean {
|
function threadSettingsValueEqual(left: unknown, right: unknown): boolean {
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ interface ResumedThreadActionParams {
|
||||||
preserveRequestedRuntimeSettings?: boolean;
|
preserveRequestedRuntimeSettings?: boolean;
|
||||||
serviceTierKnown?: boolean;
|
serviceTierKnown?: boolean;
|
||||||
preservePendingSubmissionId?: string;
|
preservePendingSubmissionId?: string;
|
||||||
|
expectedPanelTargetRevision?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResumedThreadFromActiveRuntimeParams {
|
interface ResumedThreadFromActiveRuntimeParams {
|
||||||
thread: Thread;
|
thread: Thread;
|
||||||
cwd: string;
|
|
||||||
runtime: Pick<
|
runtime: Pick<
|
||||||
ActiveThreadRuntimeState,
|
ActiveThreadRuntimeState,
|
||||||
| "model"
|
| "model"
|
||||||
|
|
@ -41,12 +41,12 @@ interface ResumedThreadFromActiveRuntimeParams {
|
||||||
>;
|
>;
|
||||||
listedThreads?: readonly Thread[];
|
listedThreads?: readonly Thread[];
|
||||||
items?: readonly ThreadStreamItem[];
|
items?: readonly ThreadStreamItem[];
|
||||||
|
expectedPanelTargetRevision?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveThreadResumedAction extends RuntimePermissionState, RuntimePermissionKnownState {
|
export interface ActiveThreadResumedAction extends RuntimePermissionState, RuntimePermissionKnownState {
|
||||||
type: "active-thread/resumed";
|
type: "active-thread/resumed";
|
||||||
thread: Thread;
|
thread: Thread;
|
||||||
cwd: string;
|
|
||||||
model: string | null;
|
model: string | null;
|
||||||
reasoningEffort: ReasoningEffort | null;
|
reasoningEffort: ReasoningEffort | null;
|
||||||
serviceTier: ServiceTier | null;
|
serviceTier: ServiceTier | null;
|
||||||
|
|
@ -57,6 +57,7 @@ export interface ActiveThreadResumedAction extends RuntimePermissionState, Runti
|
||||||
listedThreads?: readonly Thread[];
|
listedThreads?: readonly Thread[];
|
||||||
preserveRequestedRuntimeSettings?: boolean;
|
preserveRequestedRuntimeSettings?: boolean;
|
||||||
preservePendingSubmissionId?: string;
|
preservePendingSubmissionId?: string;
|
||||||
|
expectedPanelTargetRevision?: number;
|
||||||
lifetime?:
|
lifetime?:
|
||||||
| { readonly kind: "persistent" }
|
| { readonly kind: "persistent" }
|
||||||
| { readonly kind: "ephemeral"; readonly sourceThreadId: string; readonly sourceThreadTitle: string | null };
|
| { readonly kind: "ephemeral"; readonly sourceThreadId: string; readonly sourceThreadTitle: string | null };
|
||||||
|
|
@ -64,7 +65,6 @@ export interface ActiveThreadResumedAction extends RuntimePermissionState, Runti
|
||||||
|
|
||||||
export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionState, RuntimePermissionKnownState {
|
export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionState, RuntimePermissionKnownState {
|
||||||
type: "active-thread/settings-applied";
|
type: "active-thread/settings-applied";
|
||||||
cwd: string;
|
|
||||||
model: string | null;
|
model: string | null;
|
||||||
reasoningEffort: ReasoningEffort | null;
|
reasoningEffort: ReasoningEffort | null;
|
||||||
collaborationMode: CollaborationModeSelection;
|
collaborationMode: CollaborationModeSelection;
|
||||||
|
|
@ -73,7 +73,6 @@ export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionStat
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveThreadSettingsAppliedActionSettings extends RuntimePermissionState {
|
export interface ActiveThreadSettingsAppliedActionSettings extends RuntimePermissionState {
|
||||||
cwd: string;
|
|
||||||
model: string | null;
|
model: string | null;
|
||||||
effort: string | null;
|
effort: string | null;
|
||||||
collaborationMode: { mode: CollaborationModeSelection };
|
collaborationMode: { mode: CollaborationModeSelection };
|
||||||
|
|
@ -100,11 +99,16 @@ export interface ClearLocalTurnAction {
|
||||||
|
|
||||||
export interface ClearActiveThreadAction {
|
export interface ClearActiveThreadAction {
|
||||||
type: "active-thread/cleared";
|
type: "active-thread/cleared";
|
||||||
|
expectedPanelTargetRevision?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadListAppliedAction {
|
export interface ThreadListAppliedAction {
|
||||||
type: "thread-list/applied";
|
type: "thread-list/applied";
|
||||||
threads: readonly Thread[];
|
threads: readonly Thread[];
|
||||||
|
hasMore?: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
|
isFetchingNextPage?: boolean;
|
||||||
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DisclosureSetAction {
|
export interface DisclosureSetAction {
|
||||||
|
|
@ -136,7 +140,6 @@ export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromAc
|
||||||
return resumedThreadAction({
|
return resumedThreadAction({
|
||||||
response: {
|
response: {
|
||||||
thread: params.thread,
|
thread: params.thread,
|
||||||
cwd: params.cwd,
|
|
||||||
approvalPolicyKnown: params.runtime.approvalPolicyKnown,
|
approvalPolicyKnown: params.runtime.approvalPolicyKnown,
|
||||||
sandboxPolicyKnown: params.runtime.sandboxPolicyKnown,
|
sandboxPolicyKnown: params.runtime.sandboxPolicyKnown,
|
||||||
permissionProfileKnown: params.runtime.permissionProfileKnown,
|
permissionProfileKnown: params.runtime.permissionProfileKnown,
|
||||||
|
|
@ -151,6 +154,7 @@ export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromAc
|
||||||
serviceTierKnown: params.runtime.serviceTierKnown,
|
serviceTierKnown: params.runtime.serviceTierKnown,
|
||||||
...(params.listedThreads ? { listedThreads: params.listedThreads } : {}),
|
...(params.listedThreads ? { listedThreads: params.listedThreads } : {}),
|
||||||
...(params.items ? { items: params.items } : {}),
|
...(params.items ? { items: params.items } : {}),
|
||||||
|
...(params.expectedPanelTargetRevision === undefined ? {} : { expectedPanelTargetRevision: params.expectedPanelTargetRevision }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,7 +164,6 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh
|
||||||
return {
|
return {
|
||||||
type: "active-thread/resumed",
|
type: "active-thread/resumed",
|
||||||
thread: response.thread,
|
thread: response.thread,
|
||||||
cwd: response.cwd,
|
|
||||||
model: response.model,
|
model: response.model,
|
||||||
reasoningEffort: response.reasoningEffort,
|
reasoningEffort: response.reasoningEffort,
|
||||||
serviceTier: response.serviceTier,
|
serviceTier: response.serviceTier,
|
||||||
|
|
@ -177,6 +180,7 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh
|
||||||
: {}),
|
: {}),
|
||||||
...(params.preserveRequestedRuntimeSettings ? { preserveRequestedRuntimeSettings: true } : {}),
|
...(params.preserveRequestedRuntimeSettings ? { preserveRequestedRuntimeSettings: true } : {}),
|
||||||
...(params.preservePendingSubmissionId ? { preservePendingSubmissionId: params.preservePendingSubmissionId } : {}),
|
...(params.preservePendingSubmissionId ? { preservePendingSubmissionId: params.preservePendingSubmissionId } : {}),
|
||||||
|
...(params.expectedPanelTargetRevision === undefined ? {} : { expectedPanelTargetRevision: params.expectedPanelTargetRevision }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,7 +207,6 @@ export function activeThreadSettingsAppliedAction(settings: ActiveThreadSettings
|
||||||
const permissions = runtimePermissionStateOrDefault(settings);
|
const permissions = runtimePermissionStateOrDefault(settings);
|
||||||
return {
|
return {
|
||||||
type: "active-thread/settings-applied",
|
type: "active-thread/settings-applied",
|
||||||
cwd: settings.cwd,
|
|
||||||
model: settings.model,
|
model: settings.model,
|
||||||
reasoningEffort: normalizeReasoningEffort(settings.effort),
|
reasoningEffort: normalizeReasoningEffort(settings.effort),
|
||||||
collaborationMode: settings.collaborationMode.mode,
|
collaborationMode: settings.collaborationMode.mode,
|
||||||
|
|
|
||||||
28
src/features/chat/application/state/panel-target.ts
Normal file
28
src/features/chat/application/state/panel-target.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { type ChatState, panelThreadId } from "./root-reducer";
|
||||||
|
|
||||||
|
type PanelTarget = { readonly kind: "empty" } | { readonly kind: "thread"; readonly threadId: string };
|
||||||
|
|
||||||
|
export interface PanelTargetLease {
|
||||||
|
readonly revision: number;
|
||||||
|
readonly target: PanelTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capturePanelTargetLease(state: ChatState): PanelTargetLease {
|
||||||
|
const threadId = panelThreadId(state);
|
||||||
|
return {
|
||||||
|
revision: state.panelTargetRevision,
|
||||||
|
target: threadId ? { kind: "thread", threadId } : { kind: "empty" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panelTargetLeaseIsCurrent(state: ChatState, lease: PanelTargetLease): boolean {
|
||||||
|
if (state.panelTargetRevision !== lease.revision) return false;
|
||||||
|
const threadId = panelThreadId(state);
|
||||||
|
return lease.target.kind === "empty" ? threadId === null : threadId === lease.target.threadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panelTargetLeasesMatch(left: PanelTargetLease, right: PanelTargetLease): boolean {
|
||||||
|
if (left.revision !== right.revision) return false;
|
||||||
|
if (left.target.kind === "empty") return right.target.kind === "empty";
|
||||||
|
return right.target.kind === "thread" && left.target.threadId === right.target.threadId;
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,14 @@ import type {
|
||||||
} from "./actions";
|
} from "./actions";
|
||||||
import { definedPatch, patchObject } from "./patch";
|
import { definedPatch, patchObject } from "./patch";
|
||||||
import type { ChatPendingSubmissionState, PendingSubmissionAction } from "./pending-submission";
|
import type { ChatPendingSubmissionState, PendingSubmissionAction } from "./pending-submission";
|
||||||
|
import {
|
||||||
|
type ChatSubagentActivityState,
|
||||||
|
initialSubagentActivityState,
|
||||||
|
isSubagentActivityAction,
|
||||||
|
reduceSubagentActivitySlice,
|
||||||
|
type SubagentActivityAction,
|
||||||
|
subagentActivityWithParentTurn,
|
||||||
|
} from "./subagent-activity";
|
||||||
import {
|
import {
|
||||||
type ChatThreadStreamState,
|
type ChatThreadStreamState,
|
||||||
initialChatThreadStreamState,
|
initialChatThreadStreamState,
|
||||||
|
|
@ -103,15 +111,19 @@ interface ChatConnectionState {
|
||||||
|
|
||||||
interface ChatThreadListState {
|
interface ChatThreadListState {
|
||||||
readonly listedThreads: readonly Thread[];
|
readonly listedThreads: readonly Thread[];
|
||||||
|
readonly hasMore: boolean;
|
||||||
|
readonly isFetching: boolean;
|
||||||
|
readonly isFetchingNextPage: boolean;
|
||||||
|
readonly error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatActiveThreadState {
|
export interface ChatActiveThreadState {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly title?: string | null;
|
readonly title?: string | null;
|
||||||
readonly cwd: string | null;
|
|
||||||
readonly goal: ThreadGoal | null;
|
readonly goal: ThreadGoal | null;
|
||||||
readonly tokenUsage: ThreadTokenUsage | null;
|
readonly tokenUsage: ThreadTokenUsage | null;
|
||||||
readonly lifetime: ActiveThreadLifetime | null;
|
readonly lifetime: ActiveThreadLifetime | null;
|
||||||
|
readonly canAcceptDirectInput: boolean | null;
|
||||||
readonly provenance: Thread["provenance"] | null;
|
readonly provenance: Thread["provenance"] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +143,7 @@ type ActiveThreadLifetime =
|
||||||
|
|
||||||
interface ChatComposerState {
|
interface ChatComposerState {
|
||||||
readonly draft: string;
|
readonly draft: string;
|
||||||
|
readonly pendingAttachmentSaveIds: readonly number[];
|
||||||
readonly suggestSelected: number;
|
readonly suggestSelected: number;
|
||||||
readonly suggestions: readonly ComposerSuggestion[];
|
readonly suggestions: readonly ComposerSuggestion[];
|
||||||
readonly suggestionsDismissedSignature: string | null;
|
readonly suggestionsDismissedSignature: string | null;
|
||||||
|
|
@ -140,9 +153,11 @@ interface ChatStateShape {
|
||||||
connection: ChatConnectionState;
|
connection: ChatConnectionState;
|
||||||
threadList: ChatThreadListState;
|
threadList: ChatThreadListState;
|
||||||
panelThread: ChatPanelThreadState;
|
panelThread: ChatPanelThreadState;
|
||||||
|
panelTargetRevision: number;
|
||||||
runtime: ChatRuntimeState;
|
runtime: ChatRuntimeState;
|
||||||
turn: ChatTurnState;
|
turn: ChatTurnState;
|
||||||
threadStream: ChatThreadStreamState;
|
threadStream: ChatThreadStreamState;
|
||||||
|
subagentActivity: ChatSubagentActivityState;
|
||||||
pendingSubmission: ChatPendingSubmissionState | null;
|
pendingSubmission: ChatPendingSubmissionState | null;
|
||||||
requests: ChatRequestState;
|
requests: ChatRequestState;
|
||||||
composer: ChatComposerState;
|
composer: ChatComposerState;
|
||||||
|
|
@ -166,9 +181,7 @@ type ConnectionAction =
|
||||||
|
|
||||||
type ThreadListAction = ThreadListAppliedAction;
|
type ThreadListAction = ThreadListAppliedAction;
|
||||||
|
|
||||||
type ActiveThreadAction =
|
type ActiveThreadAction = { type: "active-thread/token-usage-set"; tokenUsage: ThreadTokenUsage | null };
|
||||||
| { type: "active-thread/cwd-set"; cwd: string | null }
|
|
||||||
| { type: "active-thread/token-usage-set"; tokenUsage: ThreadTokenUsage | null };
|
|
||||||
|
|
||||||
type RuntimeAction =
|
type RuntimeAction =
|
||||||
| { type: "runtime/model-requested"; model: string }
|
| { type: "runtime/model-requested"; model: string }
|
||||||
|
|
@ -207,6 +220,16 @@ type TurnAction =
|
||||||
| TurnStartFailedAction;
|
| TurnStartFailedAction;
|
||||||
|
|
||||||
type ComposerAction =
|
type ComposerAction =
|
||||||
|
| {
|
||||||
|
type: "composer/attachment-save-started";
|
||||||
|
saveId: number;
|
||||||
|
draft: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "composer/attachment-save-settled";
|
||||||
|
saveId: number;
|
||||||
|
draft?: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "composer/draft-set";
|
type: "composer/draft-set";
|
||||||
draft: string;
|
draft: string;
|
||||||
|
|
@ -263,6 +286,7 @@ type ChatSliceAction =
|
||||||
| RuntimeAction
|
| RuntimeAction
|
||||||
| RequestAction
|
| RequestAction
|
||||||
| ThreadStreamAction
|
| ThreadStreamAction
|
||||||
|
| SubagentActivityAction
|
||||||
| ComposerAction
|
| ComposerAction
|
||||||
| UiAction;
|
| UiAction;
|
||||||
|
|
||||||
|
|
@ -271,9 +295,11 @@ export function createChatState(): ChatState {
|
||||||
connection: initialConnectionState(),
|
connection: initialConnectionState(),
|
||||||
threadList: initialThreadListState(),
|
threadList: initialThreadListState(),
|
||||||
panelThread: initialPanelThreadState(),
|
panelThread: initialPanelThreadState(),
|
||||||
|
panelTargetRevision: 0,
|
||||||
runtime: initialChatRuntimeState(),
|
runtime: initialChatRuntimeState(),
|
||||||
turn: initialTurnState(),
|
turn: initialTurnState(),
|
||||||
threadStream: initialThreadStreamState(),
|
threadStream: initialThreadStreamState(),
|
||||||
|
subagentActivity: initialSubagentActivityState(),
|
||||||
pendingSubmission: null,
|
pendingSubmission: null,
|
||||||
requests: initialRequestState(),
|
requests: initialRequestState(),
|
||||||
composer: initialComposerState(),
|
composer: initialComposerState(),
|
||||||
|
|
@ -339,7 +365,10 @@ function reduceChatTransition(state: ChatState, action: ChatTransitionAction): C
|
||||||
case "connection/context-replaced":
|
case "connection/context-replaced":
|
||||||
return clearConnectionContextState(state);
|
return clearConnectionContextState(state);
|
||||||
case "active-thread/cleared":
|
case "active-thread/cleared":
|
||||||
return clearThreadScopedState(state);
|
if (action.expectedPanelTargetRevision !== undefined && action.expectedPanelTargetRevision !== state.panelTargetRevision) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return clearThreadScopedState(state, { invalidatePanelTarget: true });
|
||||||
case "active-thread/resumed":
|
case "active-thread/resumed":
|
||||||
return reduceActiveThreadResumedTransition(state, action);
|
return reduceActiveThreadResumedTransition(state, action);
|
||||||
case "active-thread/settings-applied":
|
case "active-thread/settings-applied":
|
||||||
|
|
@ -405,15 +434,19 @@ function adoptPendingSteerItem(state: ChatThreadStreamState, item: ThreadStreamD
|
||||||
stableItems[matchedIndex] = {
|
stableItems[matchedIndex] = {
|
||||||
...current,
|
...current,
|
||||||
...(item.contextAttachments ? { contextAttachments: item.contextAttachments } : {}),
|
...(item.contextAttachments ? { contextAttachments: item.contextAttachments } : {}),
|
||||||
...(item.mentionedFiles ? { mentionedFiles: item.mentionedFiles } : {}),
|
...(item.referencedFiles ? { referencedFiles: item.referencedFiles } : {}),
|
||||||
...(item.referencedThread ? { referencedThread: item.referencedThread } : {}),
|
...(item.referencedThread ? { referencedThread: item.referencedThread } : {}),
|
||||||
};
|
};
|
||||||
return { ...state, stableItems };
|
return { ...state, stableItems };
|
||||||
}
|
}
|
||||||
|
|
||||||
function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThreadResumedAction): ChatState {
|
function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThreadResumedAction): ChatState {
|
||||||
|
if (action.expectedPanelTargetRevision !== undefined && action.expectedPanelTargetRevision !== state.panelTargetRevision) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
const runtimeBase = action.preserveRequestedRuntimeSettings ? state.runtime : initialChatRuntimeState();
|
const runtimeBase = action.preserveRequestedRuntimeSettings ? state.runtime : initialChatRuntimeState();
|
||||||
const turnScopedState = clearTurnScopedState(state);
|
const turnScopedState = clearTurnScopedState(state);
|
||||||
|
const nextPanelTargetRevision = panelThreadId(state) === action.thread.id ? state.panelTargetRevision : state.panelTargetRevision + 1;
|
||||||
return patchChatState(turnScopedState, {
|
return patchChatState(turnScopedState, {
|
||||||
connection: {
|
connection: {
|
||||||
...state.connection,
|
...state.connection,
|
||||||
|
|
@ -428,13 +461,14 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
|
||||||
thread: {
|
thread: {
|
||||||
id: action.thread.id,
|
id: action.thread.id,
|
||||||
title: (action.thread.name ?? action.thread.preview) || null,
|
title: (action.thread.name ?? action.thread.preview) || null,
|
||||||
cwd: action.cwd,
|
|
||||||
goal: null,
|
goal: null,
|
||||||
tokenUsage: null,
|
tokenUsage: null,
|
||||||
lifetime: action.lifetime ?? { kind: "persistent" },
|
lifetime: action.lifetime ?? { kind: "persistent" },
|
||||||
|
canAcceptDirectInput: action.thread.canAcceptDirectInput ?? null,
|
||||||
provenance: action.thread.provenance,
|
provenance: action.thread.provenance,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
panelTargetRevision: nextPanelTargetRevision,
|
||||||
runtime: {
|
runtime: {
|
||||||
...runtimeBase,
|
...runtimeBase,
|
||||||
active: {
|
active: {
|
||||||
|
|
@ -465,13 +499,8 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
|
||||||
}
|
}
|
||||||
|
|
||||||
function reduceActiveThreadSettingsAppliedTransition(state: ChatState, action: ActiveThreadSettingsAppliedAction): ChatState {
|
function reduceActiveThreadSettingsAppliedTransition(state: ChatState, action: ActiveThreadSettingsAppliedAction): ChatState {
|
||||||
const activeThread = activeThreadState(state);
|
if (!activeThreadState(state)) return state;
|
||||||
if (!activeThread) return state;
|
|
||||||
return patchChatState(state, {
|
return patchChatState(state, {
|
||||||
panelThread: {
|
|
||||||
kind: "active",
|
|
||||||
thread: { ...activeThread, cwd: action.cwd },
|
|
||||||
},
|
|
||||||
runtime: {
|
runtime: {
|
||||||
...state.runtime,
|
...state.runtime,
|
||||||
active: {
|
active: {
|
||||||
|
|
@ -506,7 +535,7 @@ function reduceActiveThreadGoalSetTransition(state: ChatState, goal: ThreadGoal
|
||||||
}
|
}
|
||||||
|
|
||||||
function reduceRestoredThreadAppliedTransition(state: ChatState, threadId: string, fallbackTitle: string | null): ChatState {
|
function reduceRestoredThreadAppliedTransition(state: ChatState, threadId: string, fallbackTitle: string | null): ChatState {
|
||||||
const cleared = clearThreadScopedState(state);
|
const cleared = clearThreadScopedState(state, { invalidatePanelTarget: true });
|
||||||
return patchChatState(cleared, {
|
return patchChatState(cleared, {
|
||||||
connection: { ...cleared.connection, statusText: "Thread ready to resume." },
|
connection: { ...cleared.connection, statusText: "Thread ready to resume." },
|
||||||
panelThread: createAwaitingResumeThreadState(threadId, fallbackTitle),
|
panelThread: createAwaitingResumeThreadState(threadId, fallbackTitle),
|
||||||
|
|
@ -519,7 +548,7 @@ function reduceRestoredThreadRenamedTransition(state: ChatState, threadId: strin
|
||||||
}
|
}
|
||||||
|
|
||||||
function reduceViewStateClearedTransition(state: ChatState): ChatState {
|
function reduceViewStateClearedTransition(state: ChatState): ChatState {
|
||||||
const cleared = clearThreadScopedState(state);
|
const cleared = clearThreadScopedState(state, { invalidatePanelTarget: true });
|
||||||
return patchChatState(cleared, { connection: { ...cleared.connection, statusText: "Idle" } });
|
return patchChatState(cleared, { connection: { ...cleared.connection, statusText: "Idle" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,11 +560,13 @@ function reduceTurnStartedTransition(state: ChatState, action: TurnStartedAction
|
||||||
if (!activeThread || activeThread.id !== action.threadId) return state;
|
if (!activeThread || activeThread.id !== action.threadId) return state;
|
||||||
return patchChatState(state, {
|
return patchChatState(state, {
|
||||||
panelThread: { kind: "active", thread: activeThread },
|
panelThread: { kind: "active", thread: activeThread },
|
||||||
|
panelTargetRevision: panelThreadId(state) === action.threadId ? state.panelTargetRevision : state.panelTargetRevision + 1,
|
||||||
turn: { lifecycle },
|
turn: { lifecycle },
|
||||||
connection: { ...state.connection, statusText: STATUS_TURN_RUNNING },
|
connection: { ...state.connection, statusText: STATUS_TURN_RUNNING },
|
||||||
threadStream: action.items
|
threadStream: action.items
|
||||||
? threadStreamWithActiveTurnItems(state.threadStream, action.turnId, action.items)
|
? threadStreamWithActiveTurnItems(state.threadStream, action.turnId, action.items)
|
||||||
: threadStreamStartActiveSegment(state.threadStream, action.turnId, []),
|
: threadStreamStartActiveSegment(state.threadStream, action.turnId, []),
|
||||||
|
subagentActivity: initialSubagentActivityState(action.turnId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,6 +576,7 @@ function reduceTurnCompletedTransition(state: ChatState, action: TurnCompletedAc
|
||||||
return patchChatState(state, {
|
return patchChatState(state, {
|
||||||
turn: { lifecycle },
|
turn: { lifecycle },
|
||||||
threadStream: threadStreamWithItems(state.threadStream, action.items),
|
threadStream: threadStreamWithItems(state.threadStream, action.items),
|
||||||
|
subagentActivity: initialSubagentActivityState(),
|
||||||
connection: { ...state.connection, statusText: `Turn ${action.status}.` },
|
connection: { ...state.connection, statusText: `Turn ${action.status}.` },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -564,6 +596,7 @@ function reduceTurnOptimisticStartedTransition(state: ChatState, action: TurnOpt
|
||||||
turn: { lifecycle },
|
turn: { lifecycle },
|
||||||
pendingSubmission: action.pendingSubmissionId ? null : state.pendingSubmission,
|
pendingSubmission: action.pendingSubmissionId ? null : state.pendingSubmission,
|
||||||
threadStream: threadStreamStartActiveSegment(state.threadStream, null, [action.item]),
|
threadStream: threadStreamStartActiveSegment(state.threadStream, null, [action.item]),
|
||||||
|
subagentActivity: initialSubagentActivityState(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -576,6 +609,7 @@ function reduceTurnStartAcknowledgedTransition(state: ChatState, action: TurnSta
|
||||||
return patchChatState(state, {
|
return patchChatState(state, {
|
||||||
turn: { lifecycle },
|
turn: { lifecycle },
|
||||||
threadStream: threadStreamWithActiveTurnItems(state.threadStream, action.turnId, action.items),
|
threadStream: threadStreamWithActiveTurnItems(state.threadStream, action.turnId, action.items),
|
||||||
|
subagentActivity: subagentActivityWithParentTurn(state.subagentActivity, action.turnId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -585,6 +619,7 @@ function reduceTurnStartFailedTransition(state: ChatState, action: TurnStartFail
|
||||||
return patchChatState(state, {
|
return patchChatState(state, {
|
||||||
turn: { lifecycle },
|
turn: { lifecycle },
|
||||||
threadStream: threadStreamWithItems(state.threadStream, action.items),
|
threadStream: threadStreamWithItems(state.threadStream, action.items),
|
||||||
|
subagentActivity: initialSubagentActivityState(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -618,15 +653,18 @@ function clearTurnScopedState(state: ChatState): ChatState {
|
||||||
lifecycle: transitionChatTurnLifecycleState(state.turn.lifecycle, { type: "cleared" }),
|
lifecycle: transitionChatTurnLifecycleState(state.turn.lifecycle, { type: "cleared" }),
|
||||||
},
|
},
|
||||||
threadStream: threadStreamWithItems(state.threadStream, threadStreamItems(state.threadStream)),
|
threadStream: threadStreamWithItems(state.threadStream, threadStreamItems(state.threadStream)),
|
||||||
|
subagentActivity: initialSubagentActivityState(),
|
||||||
requests: initialRequestState(),
|
requests: initialRequestState(),
|
||||||
ui: clearAllRequestDisclosures(state.ui),
|
ui: clearAllRequestDisclosures(state.ui),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearThreadScopedState(state: ChatState): ChatState {
|
function clearThreadScopedState(state: ChatState, options: { invalidatePanelTarget?: boolean } = {}): ChatState {
|
||||||
return clearTurnScopedState(
|
return clearTurnScopedState(
|
||||||
patchChatState(state, {
|
patchChatState(state, {
|
||||||
panelThread: initialPanelThreadState(),
|
panelThread: initialPanelThreadState(),
|
||||||
|
panelTargetRevision:
|
||||||
|
options.invalidatePanelTarget || panelThreadId(state) !== null ? state.panelTargetRevision + 1 : state.panelTargetRevision,
|
||||||
runtime: initialChatRuntimeState(),
|
runtime: initialChatRuntimeState(),
|
||||||
threadStream: initialThreadStreamState(),
|
threadStream: initialThreadStreamState(),
|
||||||
pendingSubmission: null,
|
pendingSubmission: null,
|
||||||
|
|
@ -639,8 +677,11 @@ function clearThreadScopedState(state: ChatState): ChatState {
|
||||||
function clearConnectionScopedState(state: ChatState): ChatState {
|
function clearConnectionScopedState(state: ChatState): ChatState {
|
||||||
const cleared = clearTurnScopedState(state);
|
const cleared = clearTurnScopedState(state);
|
||||||
const ephemeralExpired = state.panelThread.kind === "active" && state.panelThread.thread.lifetime?.kind === "ephemeral";
|
const ephemeralExpired = state.panelThread.kind === "active" && state.panelThread.thread.lifetime?.kind === "ephemeral";
|
||||||
|
const nextPanelThread = panelThreadAfterConnectionExit(state.panelThread);
|
||||||
return patchChatState(cleared, {
|
return patchChatState(cleared, {
|
||||||
panelThread: panelThreadAfterConnectionExit(state.panelThread),
|
panelThread: nextPanelThread,
|
||||||
|
panelTargetRevision:
|
||||||
|
panelThreadIdForState(nextPanelThread) === panelThreadId(state) ? state.panelTargetRevision : state.panelTargetRevision + 1,
|
||||||
runtime: initialChatRuntimeState(),
|
runtime: initialChatRuntimeState(),
|
||||||
connection: {
|
connection: {
|
||||||
...state.connection,
|
...state.connection,
|
||||||
|
|
@ -659,6 +700,7 @@ function clearConnectionContextState(state: ChatState): ChatState {
|
||||||
const cleared = clearConnectionScopedState(state);
|
const cleared = clearConnectionScopedState(state);
|
||||||
return patchChatState(cleared, {
|
return patchChatState(cleared, {
|
||||||
panelThread: initialPanelThreadState(),
|
panelThread: initialPanelThreadState(),
|
||||||
|
panelTargetRevision: state.panelTargetRevision + 1,
|
||||||
connection: {
|
connection: {
|
||||||
...cleared.connection,
|
...cleared.connection,
|
||||||
runtimeConfig: null,
|
runtimeConfig: null,
|
||||||
|
|
@ -680,16 +722,25 @@ function panelThreadAfterConnectionExit(panelThread: ChatPanelThreadState): Chat
|
||||||
return createAwaitingResumeThreadState(panelThread.thread.id, panelThread.thread.title ?? null, panelThread.thread.provenance);
|
return createAwaitingResumeThreadState(panelThread.thread.id, panelThread.thread.title ?? null, panelThread.thread.provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function panelThreadIdForState(panelThread: ChatPanelThreadState): string | null {
|
||||||
|
if (panelThread.kind === "awaiting-resume") return panelThread.threadId;
|
||||||
|
return panelThread.kind === "active" ? panelThread.thread.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState {
|
function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState {
|
||||||
return patchChatState(state, {
|
return patchChatState(state, {
|
||||||
connection: reduceConnectionSlice(state.connection, action),
|
connection: reduceConnectionSlice(state.connection, action),
|
||||||
threadList: reduceThreadListSlice(state.threadList, action),
|
threadList: reduceThreadListSlice(state.threadList, action),
|
||||||
panelThread: reducePanelThreadSlice(state.panelThread, action),
|
panelThread: reducePanelThreadSlice(state.panelThread, action),
|
||||||
|
panelTargetRevision: state.panelTargetRevision,
|
||||||
runtime: reduceRuntimeSlice(state.runtime, action),
|
runtime: reduceRuntimeSlice(state.runtime, action),
|
||||||
turn: state.turn,
|
turn: state.turn,
|
||||||
pendingSubmission: state.pendingSubmission,
|
pendingSubmission: state.pendingSubmission,
|
||||||
requests: isRequestAction(action) ? reduceRequestSlice(state.requests, action) : state.requests,
|
requests: isRequestAction(action) ? reduceRequestSlice(state.requests, action) : state.requests,
|
||||||
threadStream: isThreadStreamAction(action) ? reduceThreadStreamSlice(state.threadStream, action) : state.threadStream,
|
threadStream: isThreadStreamAction(action) ? reduceThreadStreamSlice(state.threadStream, action) : state.threadStream,
|
||||||
|
subagentActivity: isSubagentActivityAction(action)
|
||||||
|
? reduceSubagentActivitySlice(state.subagentActivity, action)
|
||||||
|
: state.subagentActivity,
|
||||||
composer: reduceComposerSlice(state.composer, action),
|
composer: reduceComposerSlice(state.composer, action),
|
||||||
ui: isUiAction(action) ? reduceUiSlice(state.ui, action) : state.ui,
|
ui: isUiAction(action) ? reduceUiSlice(state.ui, action) : state.ui,
|
||||||
});
|
});
|
||||||
|
|
@ -717,14 +768,18 @@ function reduceConnectionSlice(state: ChatConnectionState, action: ChatSliceActi
|
||||||
|
|
||||||
function reduceThreadListSlice(state: ChatThreadListState, action: ChatSliceAction): ChatThreadListState {
|
function reduceThreadListSlice(state: ChatThreadListState, action: ChatSliceAction): ChatThreadListState {
|
||||||
if (action.type !== "thread-list/applied") return state;
|
if (action.type !== "thread-list/applied") return state;
|
||||||
return patchObject(state, { listedThreads: action.threads });
|
return patchObject(state, {
|
||||||
|
listedThreads: action.threads,
|
||||||
|
...definedPatch("hasMore", action.hasMore),
|
||||||
|
...definedPatch("isFetching", action.isFetching),
|
||||||
|
...definedPatch("isFetchingNextPage", action.isFetchingNextPage),
|
||||||
|
...definedPatch("error", action.error),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function reducePanelThreadSlice(state: ChatPanelThreadState, action: ChatSliceAction): ChatPanelThreadState {
|
function reducePanelThreadSlice(state: ChatPanelThreadState, action: ChatSliceAction): ChatPanelThreadState {
|
||||||
if (state.kind !== "active") return state;
|
if (state.kind !== "active") return state;
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "active-thread/cwd-set":
|
|
||||||
return patchObject(state, { thread: patchObject(state.thread, { cwd: action.cwd }) });
|
|
||||||
case "active-thread/token-usage-set":
|
case "active-thread/token-usage-set":
|
||||||
return patchObject(state, { thread: patchObject(state.thread, { tokenUsage: action.tokenUsage }) });
|
return patchObject(state, { thread: patchObject(state.thread, { tokenUsage: action.tokenUsage }) });
|
||||||
default:
|
default:
|
||||||
|
|
@ -765,6 +820,18 @@ function reduceRuntimeSlice(state: ChatRuntimeState, action: ChatSliceAction): C
|
||||||
|
|
||||||
function reduceComposerSlice(state: ChatComposerState, action: ChatSliceAction): ChatComposerState {
|
function reduceComposerSlice(state: ChatComposerState, action: ChatSliceAction): ChatComposerState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
case "composer/attachment-save-started":
|
||||||
|
return patchObject(state, {
|
||||||
|
draft: action.draft,
|
||||||
|
pendingAttachmentSaveIds: [...state.pendingAttachmentSaveIds, action.saveId],
|
||||||
|
suggestions: [],
|
||||||
|
suggestSelected: 0,
|
||||||
|
});
|
||||||
|
case "composer/attachment-save-settled":
|
||||||
|
return patchObject(state, {
|
||||||
|
...(action.draft === undefined ? {} : { draft: action.draft }),
|
||||||
|
pendingAttachmentSaveIds: state.pendingAttachmentSaveIds.filter((saveId) => saveId !== action.saveId),
|
||||||
|
});
|
||||||
case "composer/draft-set":
|
case "composer/draft-set":
|
||||||
return patchObject(state, {
|
return patchObject(state, {
|
||||||
draft: action.draft,
|
draft: action.draft,
|
||||||
|
|
@ -808,7 +875,7 @@ function initialConnectionState(): ChatConnectionState {
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialThreadListState(): ChatThreadListState {
|
function initialThreadListState(): ChatThreadListState {
|
||||||
return { listedThreads: [] };
|
return { listedThreads: [], hasMore: false, isFetching: false, isFetchingNextPage: false, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialPanelThreadState(): ChatPanelThreadState {
|
function initialPanelThreadState(): ChatPanelThreadState {
|
||||||
|
|
@ -832,10 +899,10 @@ function createActiveThreadState(id: string): ChatActiveThreadState {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: null,
|
title: null,
|
||||||
cwd: null,
|
|
||||||
goal: null,
|
goal: null,
|
||||||
tokenUsage: null,
|
tokenUsage: null,
|
||||||
lifetime: null,
|
lifetime: null,
|
||||||
|
canAcceptDirectInput: null,
|
||||||
provenance: null,
|
provenance: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -855,6 +922,7 @@ function initialRequestState(): ChatRequestState {
|
||||||
function initialComposerState(): ChatComposerState {
|
function initialComposerState(): ChatComposerState {
|
||||||
return {
|
return {
|
||||||
draft: "",
|
draft: "",
|
||||||
|
pendingAttachmentSaveIds: [],
|
||||||
suggestSelected: 0,
|
suggestSelected: 0,
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
suggestionsDismissedSignature: null,
|
suggestionsDismissedSignature: null,
|
||||||
|
|
|
||||||
234
src/features/chat/application/state/subagent-activity.ts
Normal file
234
src/features/chat/application/state/subagent-activity.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import { streamedTextThreadStreamItem, streamedToolOutputThreadStreamItem } from "../../domain/thread-stream/factories/streaming-items";
|
||||||
|
import { normalizeProposedPlanMarkdown } from "../../domain/thread-stream/format/proposed-plan";
|
||||||
|
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
|
||||||
|
import { upsertThreadStreamItemById } from "../../domain/thread-stream/updates";
|
||||||
|
|
||||||
|
interface SubagentActivityEntry {
|
||||||
|
readonly threadId: string;
|
||||||
|
readonly childTurnId: string | null;
|
||||||
|
readonly latestItem: ThreadStreamItem | null;
|
||||||
|
readonly executionState: "running" | "completed" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatSubagentActivityState {
|
||||||
|
readonly parentTurnId: string | null;
|
||||||
|
readonly byThreadId: ReadonlyMap<string, SubagentActivityEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubagentActivityAction =
|
||||||
|
| { type: "subagent-activity/tracked"; threadId: string; parentTurnId: string }
|
||||||
|
| { type: "subagent-activity/turn-started"; threadId: string; childTurnId: string }
|
||||||
|
| { type: "subagent-activity/item-observed"; threadId: string; item: ThreadStreamItem; advance: boolean }
|
||||||
|
| { type: "subagent-activity/assistant-delta-appended"; threadId: string; childTurnId: string; itemId: string; delta: string }
|
||||||
|
| { type: "subagent-activity/plan-delta-appended"; threadId: string; childTurnId: string; itemId: string; delta: string }
|
||||||
|
| {
|
||||||
|
type: "subagent-activity/text-delta-appended";
|
||||||
|
threadId: string;
|
||||||
|
childTurnId: string;
|
||||||
|
itemId: string;
|
||||||
|
label: string;
|
||||||
|
delta: string;
|
||||||
|
kind: "tool" | "hook" | "reasoning";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "subagent-activity/tool-output-appended";
|
||||||
|
threadId: string;
|
||||||
|
childTurnId: string;
|
||||||
|
itemId: string;
|
||||||
|
delta: string;
|
||||||
|
fallbackLabel: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "subagent-activity/turn-completed";
|
||||||
|
threadId: string;
|
||||||
|
childTurnId: string;
|
||||||
|
items: readonly ThreadStreamItem[];
|
||||||
|
executionState: "completed" | "failed";
|
||||||
|
}
|
||||||
|
| { type: "subagent-activity/execution-state-changed"; threadId: string; executionState: "completed" | "failed" };
|
||||||
|
|
||||||
|
export function initialSubagentActivityState(parentTurnId: string | null = null): ChatSubagentActivityState {
|
||||||
|
return { parentTurnId, byThreadId: new Map() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subagentActivityWithParentTurn(state: ChatSubagentActivityState, parentTurnId: string): ChatSubagentActivityState {
|
||||||
|
return state.parentTurnId === parentTurnId ? state : initialSubagentActivityState(parentTurnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSubagentActivityAction(action: { type: string }): action is SubagentActivityAction {
|
||||||
|
return action.type.startsWith("subagent-activity/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduceSubagentActivitySlice(state: ChatSubagentActivityState, action: SubagentActivityAction): ChatSubagentActivityState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "subagent-activity/tracked":
|
||||||
|
return trackSubagent(state, action.threadId, action.parentTurnId);
|
||||||
|
case "subagent-activity/turn-started":
|
||||||
|
return updateTrackedEntry(state, action.threadId, (entry) => ({
|
||||||
|
...entry,
|
||||||
|
childTurnId: action.childTurnId,
|
||||||
|
latestItem: null,
|
||||||
|
executionState: "running",
|
||||||
|
}));
|
||||||
|
case "subagent-activity/item-observed":
|
||||||
|
return updateTrackedEntry(state, action.threadId, (entry) => {
|
||||||
|
if (isStaleChildTurn(entry, action.item.turnId)) return entry;
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
childTurnId: action.item.turnId ?? entry.childTurnId,
|
||||||
|
latestItem: observedLatestItem(entry.latestItem, action.item, action.advance),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
case "subagent-activity/assistant-delta-appended":
|
||||||
|
return updateCurrentTurnEntry(state, action.threadId, action.childTurnId, (entry) => ({
|
||||||
|
...entry,
|
||||||
|
childTurnId: action.childTurnId,
|
||||||
|
latestItem: appendAssistantDelta(entry.latestItem, action.itemId, action.childTurnId, action.delta),
|
||||||
|
}));
|
||||||
|
case "subagent-activity/plan-delta-appended":
|
||||||
|
return updateCurrentTurnEntry(state, action.threadId, action.childTurnId, (entry) => ({
|
||||||
|
...entry,
|
||||||
|
childTurnId: action.childTurnId,
|
||||||
|
latestItem: appendPlanDelta(entry.latestItem, action.itemId, action.childTurnId, action.delta),
|
||||||
|
}));
|
||||||
|
case "subagent-activity/text-delta-appended":
|
||||||
|
return updateCurrentTurnEntry(state, action.threadId, action.childTurnId, (entry) => ({
|
||||||
|
...entry,
|
||||||
|
childTurnId: action.childTurnId,
|
||||||
|
latestItem: appendTextDelta(entry.latestItem, action.itemId, action.childTurnId, action.label, action.delta, action.kind),
|
||||||
|
}));
|
||||||
|
case "subagent-activity/tool-output-appended":
|
||||||
|
return updateCurrentTurnEntry(state, action.threadId, action.childTurnId, (entry) => ({
|
||||||
|
...entry,
|
||||||
|
childTurnId: action.childTurnId,
|
||||||
|
latestItem: appendToolOutput(entry.latestItem, action.itemId, action.childTurnId, action.delta, action.fallbackLabel),
|
||||||
|
}));
|
||||||
|
case "subagent-activity/turn-completed":
|
||||||
|
return updateCurrentTurnEntry(state, action.threadId, action.childTurnId, (entry) => ({
|
||||||
|
...entry,
|
||||||
|
childTurnId: action.childTurnId,
|
||||||
|
latestItem: latestDisplayableItem(action.items) ?? entry.latestItem,
|
||||||
|
executionState: action.executionState,
|
||||||
|
}));
|
||||||
|
case "subagent-activity/execution-state-changed":
|
||||||
|
return updateTrackedEntry(state, action.threadId, (entry) => ({ ...entry, executionState: action.executionState }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackSubagent(state: ChatSubagentActivityState, threadId: string, parentTurnId: string): ChatSubagentActivityState {
|
||||||
|
const current = state.parentTurnId === parentTurnId ? state : initialSubagentActivityState(parentTurnId);
|
||||||
|
if (current.byThreadId.has(threadId)) return current;
|
||||||
|
const byThreadId = new Map(current.byThreadId);
|
||||||
|
byThreadId.set(threadId, { threadId, childTurnId: null, latestItem: null, executionState: "running" });
|
||||||
|
return { ...current, byThreadId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTrackedEntry(
|
||||||
|
state: ChatSubagentActivityState,
|
||||||
|
threadId: string,
|
||||||
|
update: (entry: SubagentActivityEntry) => SubagentActivityEntry,
|
||||||
|
): ChatSubagentActivityState {
|
||||||
|
const entry = state.byThreadId.get(threadId);
|
||||||
|
if (!entry) return state;
|
||||||
|
const next = update(entry);
|
||||||
|
if (next === entry) return state;
|
||||||
|
const byThreadId = new Map(state.byThreadId);
|
||||||
|
byThreadId.set(threadId, next);
|
||||||
|
return { ...state, byThreadId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCurrentTurnEntry(
|
||||||
|
state: ChatSubagentActivityState,
|
||||||
|
threadId: string,
|
||||||
|
childTurnId: string,
|
||||||
|
update: (entry: SubagentActivityEntry) => SubagentActivityEntry,
|
||||||
|
): ChatSubagentActivityState {
|
||||||
|
return updateTrackedEntry(state, threadId, (entry) => (isStaleChildTurn(entry, childTurnId) ? entry : update(entry)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaleChildTurn(entry: SubagentActivityEntry, childTurnId: string | null | undefined): boolean {
|
||||||
|
return Boolean(entry.childTurnId && childTurnId && entry.childTurnId !== childTurnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function observedLatestItem(current: ThreadStreamItem | null, item: ThreadStreamItem, advance: boolean): ThreadStreamItem | null {
|
||||||
|
if (advance || !current) return item;
|
||||||
|
if (!sameSourceItem(current, item.id)) return current;
|
||||||
|
return upsertThreadStreamItemById([current], item)[0] ?? item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendAssistantDelta(current: ThreadStreamItem | null, itemId: string, turnId: string, delta: string): ThreadStreamItem {
|
||||||
|
if (current && sameSourceItem(current, itemId) && current.kind === "dialogue" && current.dialogueKind === "assistantResponse") {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
text: `${current.text}${delta}`,
|
||||||
|
copyText: `${current.text}${delta}`,
|
||||||
|
turnId,
|
||||||
|
dialogueState: "streaming",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: itemId,
|
||||||
|
kind: "dialogue",
|
||||||
|
dialogueKind: "assistantResponse",
|
||||||
|
role: "assistant",
|
||||||
|
text: delta,
|
||||||
|
copyText: delta,
|
||||||
|
turnId,
|
||||||
|
sourceItemId: itemId,
|
||||||
|
provenance: { source: "appServer", channel: "notification", event: "streamingDelta", sourceItemId: itemId },
|
||||||
|
dialogueState: "streaming",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPlanDelta(current: ThreadStreamItem | null, itemId: string, turnId: string, delta: string): ThreadStreamItem {
|
||||||
|
const previousText =
|
||||||
|
current && sameSourceItem(current, itemId) && current.kind === "dialogue" && current.role === "assistant" ? current.text : "";
|
||||||
|
const text = normalizeProposedPlanMarkdown(`${previousText}${delta}`);
|
||||||
|
return {
|
||||||
|
id: itemId,
|
||||||
|
kind: "dialogue",
|
||||||
|
dialogueKind: "proposedPlan",
|
||||||
|
role: "assistant",
|
||||||
|
text,
|
||||||
|
copyText: text,
|
||||||
|
turnId,
|
||||||
|
sourceItemId: itemId,
|
||||||
|
provenance: { source: "appServer", channel: "notification", event: "streamingDelta", sourceItemId: itemId },
|
||||||
|
dialogueState: "streaming",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTextDelta(
|
||||||
|
current: ThreadStreamItem | null,
|
||||||
|
itemId: string,
|
||||||
|
turnId: string,
|
||||||
|
label: string,
|
||||||
|
delta: string,
|
||||||
|
kind: "tool" | "hook" | "reasoning",
|
||||||
|
): ThreadStreamItem {
|
||||||
|
if (current && sameSourceItem(current, itemId) && current.kind === kind) {
|
||||||
|
return { ...current, text: `${current.text ?? ""}${delta}`, turnId };
|
||||||
|
}
|
||||||
|
return streamedTextThreadStreamItem({ id: itemId, turnId, label, delta, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendToolOutput(
|
||||||
|
current: ThreadStreamItem | null,
|
||||||
|
itemId: string,
|
||||||
|
turnId: string,
|
||||||
|
delta: string,
|
||||||
|
fallbackLabel: string,
|
||||||
|
): ThreadStreamItem {
|
||||||
|
if (current && sameSourceItem(current, itemId) && (current.kind === "tool" || current.kind === "hook")) {
|
||||||
|
return { ...current, output: `${current.output ?? ""}${delta}`, turnId };
|
||||||
|
}
|
||||||
|
return streamedToolOutputThreadStreamItem({ id: itemId, turnId, output: delta, fallbackLabel });
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestDisplayableItem(items: readonly ThreadStreamItem[]): ThreadStreamItem | null {
|
||||||
|
return [...items].reverse().find((item) => item.kind !== "system" && !(item.kind === "dialogue" && item.role === "user")) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSourceItem(item: ThreadStreamItem, sourceItemId: string): boolean {
|
||||||
|
return item.id === sourceItemId || item.sourceItemId === sourceItemId;
|
||||||
|
}
|
||||||
|
|
@ -5,21 +5,28 @@ import {
|
||||||
type ThreadRenameLifecycleEvent,
|
type ThreadRenameLifecycleEvent,
|
||||||
type ThreadRenameLifecycleState,
|
type ThreadRenameLifecycleState,
|
||||||
threadRenameGenerationStillActive,
|
threadRenameGenerationStillActive,
|
||||||
|
threadRenameSaveStillActive,
|
||||||
transitionThreadRenameLifecycleState,
|
transitionThreadRenameLifecycleState,
|
||||||
} from "../../../../domain/threads/rename-lifecycle";
|
} from "../../../../domain/threads/rename-lifecycle";
|
||||||
|
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
|
||||||
import type { DisclosureSetAction } from "./actions";
|
import type { DisclosureSetAction } from "./actions";
|
||||||
import { patchObject } from "./patch";
|
import { patchObject } from "./patch";
|
||||||
|
|
||||||
export type ChatRenameUiState = { readonly kind: "idle" } | (ThreadRenameActiveState & { readonly threadId: string });
|
export type ChatRenameUiState = { readonly kind: "idle" } | (ThreadRenameActiveState & { readonly threadId: string });
|
||||||
|
|
||||||
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
|
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
|
||||||
|
export type ChatRenameSavingUiState = Extract<ChatRenameUiState, { kind: "saving" }>;
|
||||||
type ChatRenameUiAction = Extract<
|
type ChatRenameUiAction = Extract<
|
||||||
UiAction,
|
UiAction,
|
||||||
{
|
{
|
||||||
type:
|
type:
|
||||||
| "ui/rename-started"
|
| "ui/rename-started"
|
||||||
| "ui/rename-draft-updated"
|
| "ui/rename-draft-updated"
|
||||||
|
| "ui/rename-auto-name-context-resolved"
|
||||||
| "ui/rename-cancelled"
|
| "ui/rename-cancelled"
|
||||||
|
| "ui/rename-save-started"
|
||||||
|
| "ui/rename-save-failed"
|
||||||
|
| "ui/rename-save-succeeded"
|
||||||
| "ui/rename-generation-started"
|
| "ui/rename-generation-started"
|
||||||
| "ui/rename-generation-succeeded"
|
| "ui/rename-generation-succeeded"
|
||||||
| "ui/rename-generation-finished"
|
| "ui/rename-generation-finished"
|
||||||
|
|
@ -71,7 +78,11 @@ export type UiAction =
|
||||||
| { type: "ui/archive-confirm-set"; threadId: string | null }
|
| { type: "ui/archive-confirm-set"; threadId: string | null }
|
||||||
| { type: "ui/rename-started"; threadId: string; draft: string }
|
| { type: "ui/rename-started"; threadId: string; draft: string }
|
||||||
| { type: "ui/rename-draft-updated"; threadId: string; draft: string }
|
| { type: "ui/rename-draft-updated"; threadId: string; draft: string }
|
||||||
|
| { type: "ui/rename-auto-name-context-resolved"; threadId: string; context: ThreadTitleContext | null }
|
||||||
| { type: "ui/rename-cancelled"; threadId: string }
|
| { type: "ui/rename-cancelled"; threadId: string }
|
||||||
|
| { type: "ui/rename-save-started"; threadId: string; saveToken: number }
|
||||||
|
| { type: "ui/rename-save-failed"; threadId: string; saveToken: number }
|
||||||
|
| { type: "ui/rename-save-succeeded"; threadId: string; saveToken: number }
|
||||||
| { type: "ui/rename-generation-started"; threadId: string; generationToken: number }
|
| { type: "ui/rename-generation-started"; threadId: string; generationToken: number }
|
||||||
| { type: "ui/rename-generation-succeeded"; threadId: string; generationToken: number; draft: string }
|
| { type: "ui/rename-generation-succeeded"; threadId: string; generationToken: number; draft: string }
|
||||||
| { type: "ui/rename-generation-finished"; threadId: string; generationToken: number }
|
| { type: "ui/rename-generation-finished"; threadId: string; generationToken: number }
|
||||||
|
|
@ -99,7 +110,11 @@ export function isUiAction(action: { type: string }): action is UiAction {
|
||||||
case "ui/archive-confirm-set":
|
case "ui/archive-confirm-set":
|
||||||
case "ui/rename-started":
|
case "ui/rename-started":
|
||||||
case "ui/rename-draft-updated":
|
case "ui/rename-draft-updated":
|
||||||
|
case "ui/rename-auto-name-context-resolved":
|
||||||
case "ui/rename-cancelled":
|
case "ui/rename-cancelled":
|
||||||
|
case "ui/rename-save-started":
|
||||||
|
case "ui/rename-save-failed":
|
||||||
|
case "ui/rename-save-succeeded":
|
||||||
case "ui/rename-generation-started":
|
case "ui/rename-generation-started":
|
||||||
case "ui/rename-generation-succeeded":
|
case "ui/rename-generation-succeeded":
|
||||||
case "ui/rename-generation-finished":
|
case "ui/rename-generation-finished":
|
||||||
|
|
@ -123,7 +138,11 @@ export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState
|
||||||
return patchObject(state, { archiveConfirmThreadId: action.threadId });
|
return patchObject(state, { archiveConfirmThreadId: action.threadId });
|
||||||
case "ui/rename-started":
|
case "ui/rename-started":
|
||||||
case "ui/rename-draft-updated":
|
case "ui/rename-draft-updated":
|
||||||
|
case "ui/rename-auto-name-context-resolved":
|
||||||
case "ui/rename-cancelled":
|
case "ui/rename-cancelled":
|
||||||
|
case "ui/rename-save-started":
|
||||||
|
case "ui/rename-save-failed":
|
||||||
|
case "ui/rename-save-succeeded":
|
||||||
case "ui/rename-generation-started":
|
case "ui/rename-generation-started":
|
||||||
case "ui/rename-generation-succeeded":
|
case "ui/rename-generation-succeeded":
|
||||||
case "ui/rename-generation-finished":
|
case "ui/rename-generation-finished":
|
||||||
|
|
@ -172,6 +191,10 @@ export function renameGenerationStillActive(
|
||||||
return state.kind === "generating" && state.threadId === threadId && threadRenameGenerationStillActive(state, generationToken);
|
return state.kind === "generating" && state.threadId === threadId && threadRenameGenerationStillActive(state, generationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameSaveStillActive(state: ChatRenameUiState, threadId: string, saveToken: number): state is ChatRenameSavingUiState {
|
||||||
|
return state.kind === "saving" && state.threadId === threadId && threadRenameSaveStillActive(state, saveToken);
|
||||||
|
}
|
||||||
|
|
||||||
export function clearAllRequestDisclosures(state: ChatUiState): ChatUiState {
|
export function clearAllRequestDisclosures(state: ChatUiState): ChatUiState {
|
||||||
if (state.disclosures.approvalDetails.size === 0) return state;
|
if (state.disclosures.approvalDetails.size === 0) return state;
|
||||||
return patchObject(state, {
|
return patchObject(state, {
|
||||||
|
|
@ -253,11 +276,23 @@ function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string)
|
||||||
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
|
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "ui/rename-started":
|
case "ui/rename-started":
|
||||||
return { kind: "editing", threadId: action.threadId, draft: action.draft };
|
if (state.kind === "saving") return state;
|
||||||
|
return { kind: "editing", threadId: action.threadId, draft: action.draft, autoName: { kind: "checking" } };
|
||||||
case "ui/rename-draft-updated":
|
case "ui/rename-draft-updated":
|
||||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "draft-updated", draft: action.draft });
|
return transitionScopedChatRenameUiState(state, action.threadId, { type: "draft-updated", draft: action.draft });
|
||||||
|
case "ui/rename-auto-name-context-resolved":
|
||||||
|
return transitionScopedChatRenameUiState(state, action.threadId, {
|
||||||
|
type: "auto-name-context-resolved",
|
||||||
|
context: action.context,
|
||||||
|
});
|
||||||
case "ui/rename-cancelled":
|
case "ui/rename-cancelled":
|
||||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "cancelled" });
|
return transitionScopedChatRenameUiState(state, action.threadId, { type: "cancelled" });
|
||||||
|
case "ui/rename-save-started":
|
||||||
|
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-started", saveToken: action.saveToken });
|
||||||
|
case "ui/rename-save-failed":
|
||||||
|
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-failed", saveToken: action.saveToken });
|
||||||
|
case "ui/rename-save-succeeded":
|
||||||
|
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-succeeded", saveToken: action.saveToken });
|
||||||
case "ui/rename-generation-started":
|
case "ui/rename-generation-started":
|
||||||
return transitionScopedChatRenameUiState(state, action.threadId, {
|
return transitionScopedChatRenameUiState(state, action.threadId, {
|
||||||
type: "generation-started",
|
type: "generation-started",
|
||||||
|
|
@ -304,12 +339,14 @@ function chatRenameLifecycleStateWithoutThreadId(state: ChatRenameUiState): Thre
|
||||||
function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState, { kind: "idle" }>): ThreadRenameActiveState {
|
function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState, { kind: "idle" }>): ThreadRenameActiveState {
|
||||||
switch (state.kind) {
|
switch (state.kind) {
|
||||||
case "editing":
|
case "editing":
|
||||||
return { kind: "editing", draft: state.draft };
|
return { kind: "editing", draft: state.draft, autoName: state.autoName };
|
||||||
|
case "saving":
|
||||||
|
return { kind: "saving", draft: state.draft, autoName: state.autoName, saveToken: state.saveToken };
|
||||||
case "generating":
|
case "generating":
|
||||||
return {
|
return {
|
||||||
kind: "generating",
|
kind: "generating",
|
||||||
draft: state.draft,
|
draft: state.draft,
|
||||||
originalDraft: state.originalDraft,
|
autoName: state.autoName,
|
||||||
generationToken: state.generationToken,
|
generationToken: state.generationToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ export function createAutoTitleCoordinator(host: AutoTitleCoordinatorHost): Auto
|
||||||
let activeThreadHadTurns = false;
|
let activeThreadHadTurns = false;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
const attemptedThreadIds = new Set<string>();
|
const attemptedThreadIds = new Set<string>();
|
||||||
const inFlightThreadIds = new Set<string>();
|
|
||||||
|
|
||||||
const thread = (threadId: string): Thread | undefined =>
|
const thread = (threadId: string): Thread | undefined =>
|
||||||
host.stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
|
host.stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
|
||||||
|
|
@ -44,8 +43,6 @@ export function createAutoTitleCoordinator(host: AutoTitleCoordinatorHost): Auto
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Auto-title is best-effort metadata. Leave the thread preview untouched on failure.
|
// Auto-title is best-effort metadata. Leave the thread preview untouched on failure.
|
||||||
} finally {
|
|
||||||
if (operationGeneration === generation) inFlightThreadIds.delete(threadId);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -54,7 +51,6 @@ export function createAutoTitleCoordinator(host: AutoTitleCoordinatorHost): Auto
|
||||||
generation += 1;
|
generation += 1;
|
||||||
activeThreadHadTurns = false;
|
activeThreadHadTurns = false;
|
||||||
attemptedThreadIds.clear();
|
attemptedThreadIds.clear();
|
||||||
inFlightThreadIds.clear();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
resetThreadTurnPresence(hadTurns) {
|
resetThreadTurnPresence(hadTurns) {
|
||||||
|
|
@ -67,12 +63,11 @@ export function createAutoTitleCoordinator(host: AutoTitleCoordinatorHost): Auto
|
||||||
|
|
||||||
if (hadTurnsBeforeThisCompletion) return;
|
if (hadTurnsBeforeThisCompletion) return;
|
||||||
if (threadHasTitle(threadId)) return;
|
if (threadHasTitle(threadId)) return;
|
||||||
if (attemptedThreadIds.has(threadId) || inFlightThreadIds.has(threadId)) return;
|
if (attemptedThreadIds.has(threadId)) return;
|
||||||
const context = host.completedTurnTitleContext(turnId, completedTurnTranscriptSummary);
|
const context = host.completedTurnTitleContext(turnId, completedTurnTranscriptSummary);
|
||||||
if (!context) return;
|
if (!context) return;
|
||||||
|
|
||||||
attemptedThreadIds.add(threadId);
|
attemptedThreadIds.add(threadId);
|
||||||
inFlightThreadIds.add(threadId);
|
|
||||||
void generateAndSetTitle(threadId, context, generation);
|
void generateAndSetTitle(threadId, context, generation);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { effectCompletedInCurrentContext } from "../effect-outcome";
|
||||||
import { ephemeralThreadActivatedAction } from "../state/actions";
|
import { ephemeralThreadActivatedAction } from "../state/actions";
|
||||||
|
import { capturePanelTargetLease, panelTargetLeaseIsCurrent } from "../state/panel-target";
|
||||||
import { activeThreadState } from "../state/root-reducer";
|
import { activeThreadState } from "../state/root-reducer";
|
||||||
import type { ChatStateStore } from "../state/store";
|
import type { ChatStateStore } from "../state/store";
|
||||||
import { activeTurnId, chatTurnBusy } from "../turns/turn-state";
|
import { activeTurnId, chatTurnBusy } from "../turns/turn-state";
|
||||||
|
|
@ -30,6 +32,8 @@ export function createEphemeralThreadLifecycle(host: EphemeralThreadLifecycleHos
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let openGeneration = 0;
|
let openGeneration = 0;
|
||||||
const cleanupRequiredThreadIds = new Set<string>();
|
const cleanupRequiredThreadIds = new Set<string>();
|
||||||
|
const openIsStale = (generation: number, panelTarget: ReturnType<typeof capturePanelTargetLease>): boolean =>
|
||||||
|
disposed || generation !== openGeneration || !panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget);
|
||||||
const unsubscribeActiveEphemeralThread = async (): Promise<boolean> => {
|
const unsubscribeActiveEphemeralThread = async (): Promise<boolean> => {
|
||||||
const active = activeThreadState(host.stateStore.getState());
|
const active = activeThreadState(host.stateStore.getState());
|
||||||
if (active?.lifetime?.kind === "ephemeral") {
|
if (active?.lifetime?.kind === "ephemeral") {
|
||||||
|
|
@ -41,16 +45,27 @@ export function createEphemeralThreadLifecycle(host: EphemeralThreadLifecycleHos
|
||||||
return {
|
return {
|
||||||
async open(input): Promise<boolean> {
|
async open(input): Promise<boolean> {
|
||||||
const generation = ++openGeneration;
|
const generation = ++openGeneration;
|
||||||
|
const panelTarget = capturePanelTargetLease(host.stateStore.getState());
|
||||||
if (!(await host.ensureConnected())) return false;
|
if (!(await host.ensureConnected())) return false;
|
||||||
const result = await host.transport.forkEphemeralThread(input.sourceThreadId);
|
if (openIsStale(generation, panelTarget)) return false;
|
||||||
if (!result) return false;
|
const effect = await host.transport.forkEphemeralThread(input.sourceThreadId);
|
||||||
|
if (!effectCompletedInCurrentContext(effect)) return false;
|
||||||
|
const result = effect.value;
|
||||||
if (result.kind === "cleanup-required") {
|
if (result.kind === "cleanup-required") {
|
||||||
|
if (openIsStale(generation, panelTarget)) {
|
||||||
|
try {
|
||||||
|
await host.transport.unsubscribeEphemeralThread(result.threadId);
|
||||||
|
} catch {
|
||||||
|
// The connection close remains the final cleanup boundary.
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
cleanupRequiredThreadIds.add(result.threadId);
|
cleanupRequiredThreadIds.add(result.threadId);
|
||||||
host.addSystemMessage("Could not prepare the side chat. Cleanup will be retried when this view closes.");
|
host.addSystemMessage("Could not prepare the side chat. Cleanup will be retried when this view closes.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const snapshot = result;
|
const snapshot = result;
|
||||||
if (disposed || generation !== openGeneration) {
|
if (generation !== openGeneration || !panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) {
|
||||||
try {
|
try {
|
||||||
await host.transport.unsubscribeEphemeralThread(snapshot.activation.thread.id);
|
await host.transport.unsubscribeEphemeralThread(snapshot.activation.thread.id);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -63,29 +78,36 @@ export function createEphemeralThreadLifecycle(host: EphemeralThreadLifecycleHos
|
||||||
response: snapshot.activation,
|
response: snapshot.activation,
|
||||||
sourceThreadId: input.sourceThreadId,
|
sourceThreadId: input.sourceThreadId,
|
||||||
sourceThreadTitle: input.sourceThreadTitle,
|
sourceThreadTitle: input.sourceThreadTitle,
|
||||||
|
expectedPanelTargetRevision: panelTarget.revision,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (activeThreadState(host.stateStore.getState())?.id !== snapshot.activation.thread.id) return false;
|
||||||
host.notifyActiveThreadIdentityChanged();
|
host.notifyActiveThreadIdentityChanged();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
async prepareForPersistentNavigation(): Promise<boolean> {
|
async prepareForPersistentNavigation(): Promise<boolean> {
|
||||||
const state = host.stateStore.getState();
|
const state = host.stateStore.getState();
|
||||||
if (activeThreadState(state)?.lifetime?.kind !== "ephemeral") return true;
|
const active = activeThreadState(state);
|
||||||
|
if (active?.lifetime?.kind !== "ephemeral") return true;
|
||||||
|
const panelTarget = capturePanelTargetLease(state);
|
||||||
if (chatTurnBusy(state)) {
|
if (chatTurnBusy(state)) {
|
||||||
host.addSystemMessage("Finish or interrupt the current turn before switching threads.");
|
host.addSystemMessage("Finish or interrupt the current turn before switching threads.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!(await unsubscribeActiveEphemeralThread())) {
|
if (!(await unsubscribeActiveEphemeralThread())) {
|
||||||
|
if (!panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
|
||||||
host.addSystemMessage("Could not discard the side chat. Try again before switching threads.");
|
host.addSystemMessage("Could not discard the side chat. Try again before switching threads.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
|
||||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
host.stateStore.dispatch({ type: "active-thread/cleared" });
|
if (!panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
|
||||||
|
host.stateStore.dispatch({ type: "active-thread/cleared", expectedPanelTargetRevision: panelTarget.revision });
|
||||||
host.notifyActiveThreadIdentityChanged();
|
host.notifyActiveThreadIdentityChanged();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation";
|
import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation";
|
||||||
|
import type { EffectOutcome } from "../effect-outcome";
|
||||||
|
|
||||||
type EphemeralThreadForkResult =
|
export type EphemeralThreadForkResult =
|
||||||
| { kind: "ready"; activation: ThreadActivationSnapshot; sourceThreadId: string }
|
| { kind: "ready"; activation: ThreadActivationSnapshot; sourceThreadId: string }
|
||||||
| { kind: "cleanup-required"; threadId: string };
|
| { kind: "cleanup-required"; threadId: string };
|
||||||
|
|
||||||
export interface EphemeralThreadTransport {
|
export interface EphemeralThreadTransport {
|
||||||
forkEphemeralThread(sourceThreadId: string): Promise<EphemeralThreadForkResult | null>;
|
forkEphemeralThread(sourceThreadId: string): Promise<EffectOutcome<EphemeralThreadForkResult>>;
|
||||||
unsubscribeEphemeralThread(threadId: string): Promise<boolean>;
|
unsubscribeEphemeralThread(threadId: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../../domain/threads/goal";
|
import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../../domain/threads/goal";
|
||||||
|
import { createKeyedOperationQueue, type KeyedOperationQueue } from "../../../../shared/runtime/keyed-operation-queue";
|
||||||
import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items";
|
import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items";
|
||||||
import type { GoalThreadStreamItem } from "../../domain/thread-stream/items";
|
import type { GoalThreadStreamItem } from "../../domain/thread-stream/items";
|
||||||
|
import { effectCompletedInCurrentContext } from "../effect-outcome";
|
||||||
import type { LocalIdSource } from "../local-id-source";
|
import type { LocalIdSource } from "../local-id-source";
|
||||||
|
import { activePanelOperationDecision } from "../panel-operation-policy";
|
||||||
|
import { capturePanelTargetLease, type PanelTargetLease, panelTargetLeaseIsCurrent } from "../state/panel-target";
|
||||||
import { activeThreadId, activeThreadState } from "../state/root-reducer";
|
import { activeThreadId, activeThreadState } from "../state/root-reducer";
|
||||||
import type { ChatStateStore } from "../state/store";
|
import type { ChatStateStore } from "../state/store";
|
||||||
import type { ThreadGoalReadTransport, ThreadGoalTransport } from "./goal-transport";
|
import type { ThreadGoalReadTransport, ThreadGoalTransport } from "./goal-transport";
|
||||||
|
import type { ThreadStartOutcome } from "./thread-start-actions";
|
||||||
|
|
||||||
export interface ThreadGoalSyncHost {
|
export interface ThreadGoalSyncHost {
|
||||||
stateStore: ChatStateStore;
|
stateStore: ChatStateStore;
|
||||||
|
|
@ -12,12 +17,23 @@ export interface ThreadGoalSyncHost {
|
||||||
localItemIds: LocalIdSource;
|
localItemIds: LocalIdSource;
|
||||||
addSystemMessage: (text: string) => void;
|
addSystemMessage: (text: string) => void;
|
||||||
addGoalEvent: (item: GoalThreadStreamItem) => void;
|
addGoalEvent: (item: GoalThreadStreamItem) => void;
|
||||||
refreshLiveState: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GoalActionsHost extends ThreadGoalSyncHost {
|
export interface GoalActionsHost extends ThreadGoalSyncHost {
|
||||||
goalTransport: ThreadGoalTransport;
|
goalTransport: ThreadGoalTransport;
|
||||||
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>;
|
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<ThreadStartOutcome>;
|
||||||
|
ensureRestoredThreadLoaded: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoalActionsContext extends GoalActionsHost {
|
||||||
|
goalOperations: ThreadGoalOperationCoordinator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadGoalOperationCoordinator {
|
||||||
|
readonly goalMutations: KeyedOperationQueue<string>;
|
||||||
|
captureReadRevision(threadId: string): number;
|
||||||
|
readRevisionIsCurrent(threadId: string, revision: number): boolean;
|
||||||
|
markMutationCommitted(threadId: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadGoalSyncActions {
|
export interface ThreadGoalSyncActions {
|
||||||
|
|
@ -44,24 +60,55 @@ type GoalObjectiveSavePlan =
|
||||||
|
|
||||||
type NormalizedGoalObjective = string & { readonly __brand: "NormalizedGoalObjective" };
|
type NormalizedGoalObjective = string & { readonly __brand: "NormalizedGoalObjective" };
|
||||||
|
|
||||||
const EMPTY_GOAL_OBJECTIVE_MESSAGE = "Goal objective cannot be empty.";
|
interface GoalMutationScope {
|
||||||
|
readonly panelTarget: PanelTargetLease;
|
||||||
|
}
|
||||||
|
|
||||||
export function createThreadGoalSyncActions(host: ThreadGoalSyncHost): ThreadGoalSyncActions {
|
interface GoalMutationOutcome {
|
||||||
|
readonly committed: boolean;
|
||||||
|
readonly presented: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_GOAL_OBJECTIVE_MESSAGE = "Goal objective cannot be empty.";
|
||||||
|
const GOAL_MUTATION_NOT_COMMITTED: GoalMutationOutcome = { committed: false, presented: false };
|
||||||
|
|
||||||
|
export function createThreadGoalOperationCoordinator(): ThreadGoalOperationCoordinator {
|
||||||
|
const readRevisions = new Map<string, number>();
|
||||||
|
const goalMutations = createKeyedOperationQueue<string>();
|
||||||
return {
|
return {
|
||||||
syncThreadGoal: (threadId) => syncThreadGoal(host, threadId),
|
goalMutations,
|
||||||
|
captureReadRevision: (threadId) => readRevisions.get(threadId) ?? 0,
|
||||||
|
readRevisionIsCurrent: (threadId, revision) => (readRevisions.get(threadId) ?? 0) === revision,
|
||||||
|
markMutationCommitted: (threadId) => {
|
||||||
|
readRevisions.set(threadId, (readRevisions.get(threadId) ?? 0) + 1);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGoalActions(host: GoalActionsHost): GoalActions {
|
export function createThreadGoalSyncActions(
|
||||||
|
host: ThreadGoalSyncHost,
|
||||||
|
goalOperations: ThreadGoalOperationCoordinator = createThreadGoalOperationCoordinator(),
|
||||||
|
): ThreadGoalSyncActions {
|
||||||
|
return {
|
||||||
|
syncThreadGoal: (threadId) => syncThreadGoal(host, threadId, goalOperations),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGoalActions(
|
||||||
|
host: GoalActionsHost,
|
||||||
|
goalOperations: ThreadGoalOperationCoordinator = createThreadGoalOperationCoordinator(),
|
||||||
|
): GoalActions {
|
||||||
|
const context: GoalActionsContext = { ...host, goalOperations };
|
||||||
return {
|
return {
|
||||||
activeGoal: () => activeThreadState(host.stateStore.getState())?.goal ?? null,
|
activeGoal: () => activeThreadState(host.stateStore.getState())?.goal ?? null,
|
||||||
syncThreadGoal: (threadId) => syncThreadGoal(host, threadId),
|
syncThreadGoal: (threadId) => syncThreadGoal(host, threadId, goalOperations),
|
||||||
saveObjective: (objective, tokenBudget) => saveObjective(host, objective, tokenBudget),
|
saveObjective: (objective, tokenBudget) => saveObjective(context, objective, tokenBudget),
|
||||||
setObjective: (threadId, objective, tokenBudget) => setObjective(host, threadId, objective, tokenBudget),
|
setObjective: (threadId, objective, tokenBudget) =>
|
||||||
setStatus: (threadId, status) => setGoalStatus(host, threadId, status),
|
enqueueGoalMutation(context, threadId, (scope) => setObjective(context, threadId, objective, tokenBudget, scope)),
|
||||||
clear: (threadId) => clearGoal(host, threadId),
|
setStatus: (threadId, status) => enqueueGoalMutation(context, threadId, (scope) => setGoalStatus(context, threadId, status, scope)),
|
||||||
|
clear: (threadId) => enqueueGoalMutation(context, threadId, (scope) => clearGoal(context, threadId, scope)),
|
||||||
startEditingCurrent: () => {
|
startEditingCurrent: () => {
|
||||||
startEditingCurrent(host);
|
void startEditingCurrent(context);
|
||||||
},
|
},
|
||||||
startEditing: (threadId, objective, tokenBudget) => {
|
startEditing: (threadId, objective, tokenBudget) => {
|
||||||
startEditing(host, threadId, objective, tokenBudget);
|
startEditing(host, threadId, objective, tokenBudget);
|
||||||
|
|
@ -78,80 +125,117 @@ export function createGoalActions(host: GoalActionsHost): GoalActions {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncThreadGoal(host: ThreadGoalSyncHost, threadId: string): Promise<void> {
|
async function syncThreadGoal(host: ThreadGoalSyncHost, threadId: string, goalOperations: ThreadGoalOperationCoordinator): Promise<void> {
|
||||||
|
const panelTarget = capturePanelTargetLease(host.stateStore.getState());
|
||||||
|
const readRevision = goalOperations.captureReadRevision(threadId);
|
||||||
try {
|
try {
|
||||||
const goal = await host.goalTransport.readThreadGoal(threadId);
|
const goal = await host.goalTransport.readThreadGoal(threadId);
|
||||||
if (goal === undefined) return;
|
if (goal === undefined || !goalOperations.readRevisionIsCurrent(threadId, readRevision)) return;
|
||||||
applyGoalIfActive(host, threadId, goal, { reportChange: false });
|
applyGoalIfActive(host, threadId, goal, { reportChange: false, panelTarget });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addThreadScopedSystemMessage(host, threadId, `Could not load thread goal: ${errorMessage(error)}`);
|
if (!goalOperations.readRevisionIsCurrent(threadId, readRevision)) return;
|
||||||
|
addThreadScopedSystemMessage(host, threadId, `Could not load thread goal: ${errorMessage(error)}`, panelTarget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setObjective(host: GoalActionsHost, threadId: string, objective: string, tokenBudget: number | null): Promise<boolean> {
|
async function setObjective(
|
||||||
|
host: GoalActionsContext,
|
||||||
|
threadId: string,
|
||||||
|
objective: string,
|
||||||
|
tokenBudget: number | null,
|
||||||
|
scope: GoalMutationScope,
|
||||||
|
): Promise<boolean> {
|
||||||
const normalized = normalizedGoalObjective(objective);
|
const normalized = normalizedGoalObjective(objective);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
host.addSystemMessage(EMPTY_GOAL_OBJECTIVE_MESSAGE);
|
host.addSystemMessage(EMPTY_GOAL_OBJECTIVE_MESSAGE);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return setNormalizedObjective(host, threadId, normalized, tokenBudget);
|
return setNormalizedObjective(host, threadId, normalized, tokenBudget, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setNormalizedObjective(
|
async function setNormalizedObjective(
|
||||||
host: GoalActionsHost,
|
host: GoalActionsContext,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
objective: NormalizedGoalObjective,
|
objective: NormalizedGoalObjective,
|
||||||
tokenBudget: number | null,
|
tokenBudget: number | null,
|
||||||
|
scope: GoalMutationScope,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const current = activeThreadState(host.stateStore.getState())?.goal ?? null;
|
const current = activeThreadState(host.stateStore.getState())?.goal ?? null;
|
||||||
const isNewGoal = current === null;
|
const isNewGoal = current === null;
|
||||||
const applied = await setGoal(host, threadId, {
|
const outcome = await setGoal(
|
||||||
objective,
|
host,
|
||||||
status: current?.status ?? "active",
|
threadId,
|
||||||
tokenBudget,
|
{
|
||||||
});
|
objective,
|
||||||
if (applied && isNewGoal) {
|
status: current?.status ?? "active",
|
||||||
await recordGoalUserMessage(host, threadId, objective);
|
tokenBudget,
|
||||||
|
},
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
if (outcome.committed && isNewGoal) {
|
||||||
|
await recordGoalUserMessage(host, threadId, objective, scope);
|
||||||
}
|
}
|
||||||
return applied;
|
return outcome.presented;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveObjective(host: GoalActionsHost, objective: string, tokenBudget: number | null): Promise<boolean> {
|
async function saveObjective(host: GoalActionsContext, objective: string, tokenBudget: number | null): Promise<boolean> {
|
||||||
|
if (!(await prepareGoalMutation(host))) return false;
|
||||||
const plan = planGoalObjectiveSave(activeThreadId(host.stateStore.getState()), objective, tokenBudget);
|
const plan = planGoalObjectiveSave(activeThreadId(host.stateStore.getState()), objective, tokenBudget);
|
||||||
switch (plan.kind) {
|
switch (plan.kind) {
|
||||||
case "reject":
|
case "reject":
|
||||||
host.addSystemMessage(plan.message);
|
host.addSystemMessage(plan.message);
|
||||||
return false;
|
return false;
|
||||||
case "save-existing":
|
case "save-existing":
|
||||||
return setNormalizedObjective(host, plan.threadId, plan.objective, plan.tokenBudget);
|
return enqueueGoalMutation(host, plan.threadId, (scope) =>
|
||||||
|
setNormalizedObjective(host, plan.threadId, plan.objective, plan.tokenBudget, scope),
|
||||||
|
);
|
||||||
case "start-thread-and-save":
|
case "start-thread-and-save":
|
||||||
return startThreadAndSaveObjective(host, plan);
|
return startThreadAndSaveObjective(host, plan);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setGoalStatus(host: GoalActionsHost, threadId: string, status: ThreadGoalStatus): Promise<boolean> {
|
async function setGoalStatus(
|
||||||
return setGoal(host, threadId, { status });
|
host: GoalActionsContext,
|
||||||
|
threadId: string,
|
||||||
|
status: ThreadGoalStatus,
|
||||||
|
scope: GoalMutationScope,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return (await setGoal(host, threadId, { status }, scope)).presented;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearGoal(host: GoalActionsHost, threadId: string): Promise<boolean> {
|
async function clearGoal(host: GoalActionsContext, threadId: string, scope: GoalMutationScope): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (!(await host.goalTransport.clearThreadGoal(threadId))) return false;
|
if (!(await host.goalTransport.ensureConnected()) || !goalMutationAdmissionIsCurrent(host, threadId, scope)) return false;
|
||||||
applyGoalIfActive(host, threadId, null, { reportChange: true });
|
const effect = await host.goalTransport.clearThreadGoal(threadId);
|
||||||
return true;
|
if (!effectCompletedInCurrentContext(effect)) return false;
|
||||||
|
host.goalOperations.markMutationCommitted(threadId);
|
||||||
|
return applyGoalIfActive(host, threadId, null, { reportChange: true, panelTarget: scope.panelTarget });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addThreadScopedSystemMessage(host, threadId, errorMessage(error));
|
addThreadScopedSystemMessage(host, threadId, errorMessage(error), scope.panelTarget);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setGoal(host: GoalActionsHost, threadId: string, params: ThreadGoalUpdate): Promise<boolean> {
|
async function setGoal(
|
||||||
|
host: GoalActionsContext,
|
||||||
|
threadId: string,
|
||||||
|
params: ThreadGoalUpdate,
|
||||||
|
scope: GoalMutationScope,
|
||||||
|
): Promise<GoalMutationOutcome> {
|
||||||
try {
|
try {
|
||||||
const goal = await host.goalTransport.setThreadGoal(threadId, params);
|
if (!(await host.goalTransport.ensureConnected()) || !goalMutationAdmissionIsCurrent(host, threadId, scope)) {
|
||||||
if (goal === undefined) return false;
|
return GOAL_MUTATION_NOT_COMMITTED;
|
||||||
return applyGoalIfActive(host, threadId, goal, { reportChange: true });
|
}
|
||||||
|
const effect = await host.goalTransport.setThreadGoal(threadId, params);
|
||||||
|
if (!effectCompletedInCurrentContext(effect)) return GOAL_MUTATION_NOT_COMMITTED;
|
||||||
|
host.goalOperations.markMutationCommitted(threadId);
|
||||||
|
return {
|
||||||
|
committed: true,
|
||||||
|
presented: applyGoalIfActive(host, threadId, effect.value, { reportChange: true, panelTarget: scope.panelTarget }),
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addThreadScopedSystemMessage(host, threadId, errorMessage(error));
|
addThreadScopedSystemMessage(host, threadId, errorMessage(error), scope.panelTarget);
|
||||||
return false;
|
return GOAL_MUTATION_NOT_COMMITTED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,24 +243,46 @@ function applyGoalIfActive(
|
||||||
host: ThreadGoalSyncHost,
|
host: ThreadGoalSyncHost,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
goal: ThreadGoal | null,
|
goal: ThreadGoal | null,
|
||||||
options: { reportChange: boolean },
|
options: { reportChange: boolean; panelTarget?: PanelTargetLease },
|
||||||
): boolean {
|
): boolean {
|
||||||
const state = host.stateStore.getState();
|
const state = host.stateStore.getState();
|
||||||
|
if (options.panelTarget && !panelTargetLeaseIsCurrent(state, options.panelTarget)) return false;
|
||||||
const activeThread = activeThreadState(state);
|
const activeThread = activeThreadState(state);
|
||||||
if (!activeThread || activeThread.id !== threadId) return false;
|
if (!activeThread || activeThread.id !== threadId) return false;
|
||||||
const item = options.reportChange ? goalChangeItem(host.localItemIds.next("goal"), activeThread.goal, goal) : null;
|
const item = options.reportChange ? goalChangeItem(host.localItemIds.next("goal"), activeThread.goal, goal) : null;
|
||||||
host.stateStore.dispatch({ type: "active-thread/goal-set", goal });
|
host.stateStore.dispatch({ type: "active-thread/goal-set", goal });
|
||||||
if (item) host.addGoalEvent(item);
|
if (item) host.addGoalEvent(item);
|
||||||
host.refreshLiveState();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditingCurrent(host: GoalActionsHost): void {
|
async function startEditingCurrent(host: GoalActionsContext): Promise<void> {
|
||||||
|
if (!(await prepareGoalMutation(host))) return;
|
||||||
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||||
const goal = activeThreadState(host.stateStore.getState())?.goal ?? null;
|
const goal = activeThreadState(host.stateStore.getState())?.goal ?? null;
|
||||||
startEditing(host, goal?.threadId ?? null, goal?.objective ?? "", goal?.tokenBudget ?? null);
|
startEditing(host, goal?.threadId ?? null, goal?.objective ?? "", goal?.tokenBudget ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function prepareGoalMutation(host: GoalActionsContext): Promise<boolean> {
|
||||||
|
const decision = activePanelOperationDecision(host.stateStore.getState(), "goal-mutation");
|
||||||
|
if (decision.kind === "allowed") return true;
|
||||||
|
if (decision.kind === "blocked") {
|
||||||
|
host.addSystemMessage(decision.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!(await host.ensureRestoredThreadLoaded())) return false;
|
||||||
|
const resumedDecision = activePanelOperationDecision(host.stateStore.getState(), "goal-mutation");
|
||||||
|
if (resumedDecision.kind === "allowed") return true;
|
||||||
|
if (resumedDecision.kind === "blocked") host.addSystemMessage(resumedDecision.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goalMutationAllowedNow(host: GoalActionsHost): boolean {
|
||||||
|
const decision = activePanelOperationDecision(host.stateStore.getState(), "goal-mutation");
|
||||||
|
if (decision.kind === "allowed") return true;
|
||||||
|
if (decision.kind === "blocked") host.addSystemMessage(decision.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function startEditing(host: GoalActionsHost, threadId: string | null, objective: string, tokenBudget: number | null): void {
|
function startEditing(host: GoalActionsHost, threadId: string | null, objective: string, tokenBudget: number | null): void {
|
||||||
host.stateStore.dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget });
|
host.stateStore.dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget });
|
||||||
}
|
}
|
||||||
|
|
@ -195,33 +301,78 @@ function normalizedGoalObjective(objective: string): NormalizedGoalObjective | n
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startThreadAndSaveObjective(
|
async function startThreadAndSaveObjective(
|
||||||
host: GoalActionsHost,
|
host: GoalActionsContext,
|
||||||
plan: Extract<GoalObjectiveSavePlan, { kind: "start-thread-and-save" }>,
|
plan: Extract<GoalObjectiveSavePlan, { kind: "start-thread-and-save" }>,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const panelTarget = capturePanelTargetLease(host.stateStore.getState());
|
||||||
try {
|
try {
|
||||||
if (!(await host.goalTransport.ensureConnected())) return false;
|
if (!(await host.goalTransport.ensureConnected())) return false;
|
||||||
const response = await host.startThread(plan.objective, { syncGoal: false });
|
if (!panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget) || !emptyPanelCanStartGoalThread(host)) return false;
|
||||||
const threadId = response?.threadId ?? null;
|
const outcome = await host.startThread(plan.objective, { syncGoal: false });
|
||||||
return threadId ? await setNormalizedObjective(host, threadId, plan.objective, plan.tokenBudget) : false;
|
if (outcome.kind === "created-not-activated") {
|
||||||
|
host.addSystemMessage(
|
||||||
|
`Created thread ${outcome.threadId}, but the connection changed before it could be opened. Resume it from history before setting its goal.`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (outcome.kind !== "created-activated") return false;
|
||||||
|
return await enqueueGoalMutation(host, outcome.threadId, (scope) =>
|
||||||
|
setNormalizedObjective(host, outcome.threadId, plan.objective, plan.tokenBudget, scope),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
host.addSystemMessage(errorMessage(error));
|
if (panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) host.addSystemMessage(errorMessage(error));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordGoalUserMessage(host: GoalActionsHost, threadId: string, objective: string): Promise<void> {
|
function emptyPanelCanStartGoalThread(host: GoalActionsHost): boolean {
|
||||||
|
const state = host.stateStore.getState();
|
||||||
|
return state.panelThread.kind === "empty" && activePanelOperationDecision(state, "goal-mutation").kind === "allowed";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordGoalUserMessage(
|
||||||
|
host: GoalActionsContext,
|
||||||
|
threadId: string,
|
||||||
|
objective: string,
|
||||||
|
scope: GoalMutationScope,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await host.goalTransport.recordThreadGoalUserMessage(threadId, objective);
|
await host.goalTransport.recordThreadGoalUserMessage(threadId, objective);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addThreadScopedSystemMessage(host, threadId, `Could not record goal message: ${errorMessage(error)}`);
|
addThreadScopedSystemMessage(host, threadId, `Could not record goal message: ${errorMessage(error)}`, scope.panelTarget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addThreadScopedSystemMessage(host: ThreadGoalSyncHost, threadId: string, text: string): void {
|
function addThreadScopedSystemMessage(host: ThreadGoalSyncHost, threadId: string, text: string, panelTarget?: PanelTargetLease): void {
|
||||||
if (activeThreadId(host.stateStore.getState()) !== threadId) return;
|
const state = host.stateStore.getState();
|
||||||
|
if ((panelTarget && !panelTargetLeaseIsCurrent(state, panelTarget)) || activeThreadId(state) !== threadId) return;
|
||||||
host.addSystemMessage(text);
|
host.addSystemMessage(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goalMutationPresentationIsCurrent(host: GoalActionsContext, threadId: string, scope: GoalMutationScope): boolean {
|
||||||
|
return (
|
||||||
|
panelTargetLeaseIsCurrent(host.stateStore.getState(), scope.panelTarget) && activeThreadId(host.stateStore.getState()) === threadId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goalMutationAdmissionIsCurrent(host: GoalActionsContext, threadId: string, scope: GoalMutationScope): boolean {
|
||||||
|
return goalMutationPresentationIsCurrent(host, threadId, scope) && goalMutationAllowedNow(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueGoalMutation(
|
||||||
|
host: GoalActionsContext,
|
||||||
|
threadId: string,
|
||||||
|
operation: (scope: GoalMutationScope) => Promise<boolean>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const scope = {
|
||||||
|
panelTarget: capturePanelTargetLease(host.stateStore.getState()),
|
||||||
|
};
|
||||||
|
return host.goalOperations.goalMutations.run(threadId, async () => {
|
||||||
|
if (!goalMutationAdmissionIsCurrent(host, threadId, scope)) return false;
|
||||||
|
return operation(scope);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import type { ThreadGoal, ThreadGoalUpdate } from "../../../../domain/threads/goal";
|
import type { ThreadGoal, ThreadGoalUpdate } from "../../../../domain/threads/goal";
|
||||||
|
import type { EffectOutcome } from "../effect-outcome";
|
||||||
|
|
||||||
export interface ThreadGoalReadTransport {
|
export interface ThreadGoalReadTransport {
|
||||||
readThreadGoal(threadId: string): Promise<ThreadGoal | null | undefined>;
|
readThreadGoal(threadId: string): Promise<ThreadGoal | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadGoalTransport extends ThreadGoalReadTransport {
|
export interface ThreadGoalTransport extends ThreadGoalReadTransport {
|
||||||
setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise<ThreadGoal | null | undefined>;
|
setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise<EffectOutcome<ThreadGoal | null>>;
|
||||||
clearThreadGoal(threadId: string): Promise<boolean>;
|
clearThreadGoal(threadId: string): Promise<EffectOutcome<void>>;
|
||||||
recordThreadGoalUserMessage(threadId: string, objective: string): Promise<boolean>;
|
recordThreadGoalUserMessage(threadId: string, objective: string): Promise<boolean>;
|
||||||
ensureConnected(): Promise<boolean>;
|
ensureConnected(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { activeThreadState } from "../state/root-reducer";
|
||||||
|
import type { ChatStateStore } from "../state/store";
|
||||||
|
import { chatTurnBusy } from "../turns/turn-state";
|
||||||
|
import type { EphemeralThreadLifecycle } from "./ephemeral-thread-lifecycle";
|
||||||
|
import type { ThreadSubscriptionTransport } from "./thread-subscription-transport";
|
||||||
|
|
||||||
|
export interface PersistentNavigationLifecycle {
|
||||||
|
prepareForPersistentNavigation(targetThreadId: string | null): Promise<PersistentNavigationPreparation | null>;
|
||||||
|
commitPersistentNavigation(preparation: PersistentNavigationPreparation): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersistentNavigationPreparation =
|
||||||
|
| { readonly kind: "ready" }
|
||||||
|
| { readonly kind: "unsubscribe-on-adoption"; readonly threadId: string };
|
||||||
|
|
||||||
|
interface PersistentNavigationLifecycleHost {
|
||||||
|
stateStore: ChatStateStore;
|
||||||
|
ephemeral: EphemeralThreadLifecycle;
|
||||||
|
subscriptions: ThreadSubscriptionTransport;
|
||||||
|
addSystemMessage(text: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPersistentNavigationLifecycle(host: PersistentNavigationLifecycleHost): PersistentNavigationLifecycle {
|
||||||
|
const pendingUnsubscribes = new Set<string>();
|
||||||
|
const unsubscribeAttempts = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
const attemptPendingUnsubscribe = (threadId: string): void => {
|
||||||
|
if (!pendingUnsubscribes.has(threadId) || unsubscribeAttempts.has(threadId)) return;
|
||||||
|
const attempt = (async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (await host.subscriptions.unsubscribeThread(threadId)) {
|
||||||
|
pendingUnsubscribes.delete(threadId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.addSystemMessage("Could not unsubscribe from the previous subagent.");
|
||||||
|
} catch (error) {
|
||||||
|
host.addSystemMessage(error instanceof Error ? error.message : "Could not unsubscribe from the previous subagent.");
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
unsubscribeAttempts.delete(threadId);
|
||||||
|
});
|
||||||
|
unsubscribeAttempts.set(threadId, attempt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryPendingUnsubscribes = (): void => {
|
||||||
|
for (const threadId of pendingUnsubscribes) attemptPendingUnsubscribe(threadId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
async prepareForPersistentNavigation(targetThreadId): Promise<PersistentNavigationPreparation | null> {
|
||||||
|
retryPendingUnsubscribes();
|
||||||
|
const state = host.stateStore.getState();
|
||||||
|
const active = activeThreadState(state);
|
||||||
|
if (!active || active.id === targetThreadId) return { kind: "ready" };
|
||||||
|
|
||||||
|
if (active.lifetime?.kind === "ephemeral") {
|
||||||
|
return (await host.ephemeral.prepareForPersistentNavigation()) ? { kind: "ready" } : null;
|
||||||
|
}
|
||||||
|
if (active.provenance?.kind !== "subagent" || !chatTurnBusy(state)) return { kind: "ready" };
|
||||||
|
return { kind: "unsubscribe-on-adoption", threadId: active.id };
|
||||||
|
},
|
||||||
|
|
||||||
|
commitPersistentNavigation(preparation): void {
|
||||||
|
if (preparation.kind !== "unsubscribe-on-adoption") return;
|
||||||
|
pendingUnsubscribes.add(preparation.threadId);
|
||||||
|
attemptPendingUnsubscribe(preparation.threadId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import type { ThreadTitleContext } from "../../../../domain/threads/title-genera
|
||||||
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
|
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||||
import type { ChatStateStore } from "../state/store";
|
import type { ChatStateStore } from "../state/store";
|
||||||
import { threadStreamItems } from "../state/thread-stream";
|
import { threadStreamItems } from "../state/thread-stream";
|
||||||
import { type ChatRenameUiState, renameGenerationStillActive } from "../state/ui-state";
|
import { type ChatRenameUiState, renameGenerationStillActive, renameSaveStillActive } from "../state/ui-state";
|
||||||
import { firstThreadTitleContextFromThreadStreamItems } from "./title-context";
|
import { firstThreadTitleContextFromThreadStreamItems } from "./title-context";
|
||||||
|
|
||||||
interface RenameEditState {
|
interface RenameEditState {
|
||||||
|
|
@ -16,7 +16,8 @@ export interface ThreadRenameEditorActionsHost {
|
||||||
ensureConnected: () => Promise<void>;
|
ensureConnected: () => Promise<void>;
|
||||||
addSystemMessage: (text: string) => void;
|
addSystemMessage: (text: string) => void;
|
||||||
renameThread(threadId: string, value: string): Promise<boolean>;
|
renameThread(threadId: string, value: string): Promise<boolean>;
|
||||||
generateThreadTitle(threadId: string): Promise<string>;
|
resolveThreadTitleContext(threadId: string): Promise<ThreadTitleContext | null>;
|
||||||
|
generateThreadTitle(context: ThreadTitleContext, signal?: AbortSignal): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadRenameEditorActions {
|
export interface ThreadRenameEditorActions {
|
||||||
|
|
@ -26,15 +27,22 @@ export interface ThreadRenameEditorActions {
|
||||||
start(threadId: string): void;
|
start(threadId: string): void;
|
||||||
updateDraft(threadId: string, value: string): void;
|
updateDraft(threadId: string, value: string): void;
|
||||||
cancel(threadId: string): void;
|
cancel(threadId: string): void;
|
||||||
|
cancelAutoName(threadId: string): void;
|
||||||
save(threadId: string, value: string): Promise<void>;
|
save(threadId: string, value: string): Promise<void>;
|
||||||
autoNameDraft(threadId: string): Promise<void>;
|
autoNameDraft(threadId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions {
|
export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions {
|
||||||
let nextRenameGenerationToken = 1;
|
let nextRenameGenerationToken = 1;
|
||||||
|
let nextRenameSaveToken = 1;
|
||||||
|
let activeGeneration: { threadId: string; generationToken: number; controller: AbortController } | null = null;
|
||||||
|
let activeContextPreparation: { threadId: string } | null = null;
|
||||||
|
|
||||||
const action = {
|
const action = {
|
||||||
invalidate(): void {
|
invalidate(): void {
|
||||||
|
activeGeneration?.controller.abort();
|
||||||
|
activeGeneration = null;
|
||||||
|
activeContextPreparation = null;
|
||||||
dispatch(host, { type: "ui/rename-cleared" });
|
dispatch(host, { type: "ui/rename-cleared" });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -52,9 +60,13 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
||||||
},
|
},
|
||||||
|
|
||||||
start(threadId: string): void {
|
start(threadId: string): void {
|
||||||
|
const current = renameState(host);
|
||||||
|
if (current.kind === "saving") return;
|
||||||
const thread = host.stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
|
const thread = host.stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
|
||||||
if (!thread) return;
|
if (!thread) return;
|
||||||
|
abortActiveGeneration();
|
||||||
dispatch(host, { type: "ui/rename-started", threadId, draft: threadRenameDraftTitle(thread) });
|
dispatch(host, { type: "ui/rename-started", threadId, draft: threadRenameDraftTitle(thread) });
|
||||||
|
void prepareAutoName(threadId);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateDraft(threadId: string, value: string): void {
|
updateDraft(threadId: string, value: string): void {
|
||||||
|
|
@ -62,29 +74,38 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
||||||
},
|
},
|
||||||
|
|
||||||
cancel(threadId: string): void {
|
cancel(threadId: string): void {
|
||||||
|
const current = renameState(host);
|
||||||
|
if (current.kind === "saving" && current.threadId === threadId) return;
|
||||||
|
abortGeneration(threadId);
|
||||||
|
if (activeContextPreparation?.threadId === threadId) activeContextPreparation = null;
|
||||||
dispatch(host, { type: "ui/rename-cancelled", threadId });
|
dispatch(host, { type: "ui/rename-cancelled", threadId });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cancelAutoName(threadId: string): void {
|
||||||
|
const current = renameState(host);
|
||||||
|
if (current.kind !== "generating" || current.threadId !== threadId) return;
|
||||||
|
abortGeneration(threadId);
|
||||||
|
finishAutoNameDraftGeneration(host, threadId, current.generationToken);
|
||||||
|
},
|
||||||
|
|
||||||
async save(threadId: string, value: string): Promise<void> {
|
async save(threadId: string, value: string): Promise<void> {
|
||||||
const current = renameState(host);
|
const current = renameState(host);
|
||||||
if (current.kind === "idle" || current.threadId !== threadId || current.kind === "generating") return;
|
if (current.kind !== "editing" || current.threadId !== threadId) return;
|
||||||
const editingState = current;
|
const saveToken = nextRenameSaveToken;
|
||||||
|
dispatch(host, { type: "ui/rename-save-started", threadId, saveToken });
|
||||||
|
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
|
||||||
|
nextRenameSaveToken += 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await host.ensureConnected();
|
await host.ensureConnected();
|
||||||
if (renameState(host) !== editingState) return;
|
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
|
||||||
|
|
||||||
const result = await host.renameThread(threadId, value);
|
await host.renameThread(threadId, value);
|
||||||
if (!result) {
|
dispatch(host, { type: "ui/rename-save-succeeded", threadId, saveToken });
|
||||||
if (renameState(host) === editingState) action.cancel(threadId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (renameState(host) === editingState) {
|
|
||||||
dispatch(host, { type: "ui/rename-cleared" });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (renameState(host) !== editingState) return;
|
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
|
||||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
dispatch(host, { type: "ui/rename-save-failed", threadId, saveToken });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -92,34 +113,67 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
||||||
const current = renameState(host);
|
const current = renameState(host);
|
||||||
if (current.kind !== "editing" || current.threadId !== threadId) return;
|
if (current.kind !== "editing" || current.threadId !== threadId) return;
|
||||||
|
|
||||||
const editingState = current;
|
|
||||||
|
|
||||||
await host.ensureConnected();
|
|
||||||
if (renameState(host) !== editingState) return;
|
|
||||||
|
|
||||||
dispatch(host, {
|
dispatch(host, {
|
||||||
type: "ui/rename-generation-started",
|
type: "ui/rename-generation-started",
|
||||||
threadId,
|
threadId,
|
||||||
generationToken: nextRenameGenerationToken,
|
generationToken: nextRenameGenerationToken,
|
||||||
});
|
});
|
||||||
const generationToken = nextRenameGenerationToken;
|
const generationToken = nextRenameGenerationToken;
|
||||||
if (!renameGenerationStillActive(renameState(host), threadId, generationToken)) return;
|
const generating = renameState(host);
|
||||||
|
if (!renameGenerationStillActive(generating, threadId, generationToken)) return;
|
||||||
nextRenameGenerationToken += 1;
|
nextRenameGenerationToken += 1;
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeGeneration = { threadId, generationToken, controller };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const title = await host.generateThreadTitle(threadId);
|
const title = await host.generateThreadTitle(generating.autoName.context, controller.signal);
|
||||||
|
if (!title) throw new Error("Codex did not return a usable thread title.");
|
||||||
dispatch(host, { type: "ui/rename-generation-succeeded", threadId, generationToken, draft: title });
|
dispatch(host, { type: "ui/rename-generation-succeeded", threadId, generationToken, draft: title });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (renameGenerationStillActive(renameState(host), threadId, generationToken)) {
|
if (renameGenerationStillActive(renameState(host), threadId, generationToken)) {
|
||||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearGeneration(generationToken);
|
||||||
finishAutoNameDraftGeneration(host, threadId, generationToken);
|
finishAutoNameDraftGeneration(host, threadId, generationToken);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return action;
|
return action;
|
||||||
|
|
||||||
|
function abortGeneration(threadId: string): void {
|
||||||
|
if (activeGeneration?.threadId !== threadId) return;
|
||||||
|
abortActiveGeneration();
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortActiveGeneration(): void {
|
||||||
|
if (!activeGeneration) return;
|
||||||
|
activeGeneration.controller.abort();
|
||||||
|
activeGeneration = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGeneration(generationToken: number): void {
|
||||||
|
if (activeGeneration?.generationToken === generationToken) activeGeneration = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareAutoName(threadId: string): Promise<void> {
|
||||||
|
const preparation = { threadId };
|
||||||
|
activeContextPreparation = preparation;
|
||||||
|
let context: ThreadTitleContext | null = null;
|
||||||
|
try {
|
||||||
|
await host.ensureConnected();
|
||||||
|
if (activeContextPreparation !== preparation) return;
|
||||||
|
context = await host.resolveThreadTitleContext(threadId);
|
||||||
|
} catch {
|
||||||
|
// Auto-name availability is reflected by the disabled action.
|
||||||
|
}
|
||||||
|
if (activeContextPreparation !== preparation) return;
|
||||||
|
activeContextPreparation = null;
|
||||||
|
const current = renameState(host);
|
||||||
|
if ((current.kind !== "editing" && current.kind !== "saving") || current.threadId !== threadId) return;
|
||||||
|
dispatch(host, { type: "ui/rename-auto-name-context-resolved", threadId, context });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null {
|
export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { awaitingResumeThreadState } from "../state/root-reducer";
|
import { capturePanelTargetLease, type PanelTargetLease, panelTargetLeaseIsCurrent, panelTargetLeasesMatch } from "../state/panel-target";
|
||||||
|
import { activeThreadId, awaitingResumeThreadState } from "../state/root-reducer";
|
||||||
import type { ChatStateStore } from "../state/store";
|
import type { ChatStateStore } from "../state/store";
|
||||||
|
|
||||||
export interface RestorationControllerHost {
|
export interface RestorationControllerHost {
|
||||||
|
|
@ -8,7 +9,7 @@ export interface RestorationControllerHost {
|
||||||
export type RestoredThreadLoader = (threadId: string) => Promise<void>;
|
export type RestoredThreadLoader = (threadId: string) => Promise<void>;
|
||||||
|
|
||||||
export class RestorationController {
|
export class RestorationController {
|
||||||
private loading: { threadId: string; promise: Promise<void> } | null = null;
|
private loading: { threadId: string; panelTarget: PanelTargetLease; promise: Promise<void> } | null = null;
|
||||||
|
|
||||||
constructor(private readonly host: RestorationControllerHost) {}
|
constructor(private readonly host: RestorationControllerHost) {}
|
||||||
|
|
||||||
|
|
@ -19,27 +20,30 @@ export class RestorationController {
|
||||||
async ensureLoaded(loadThread: RestoredThreadLoader): Promise<boolean> {
|
async ensureLoaded(loadThread: RestoredThreadLoader): Promise<boolean> {
|
||||||
const restoredThread = awaitingResumeThreadState(this.host.stateStore.getState());
|
const restoredThread = awaitingResumeThreadState(this.host.stateStore.getState());
|
||||||
if (!restoredThread) return true;
|
if (!restoredThread) return true;
|
||||||
if (this.loading?.threadId === restoredThread.threadId) {
|
const panelTarget = capturePanelTargetLease(this.host.stateStore.getState());
|
||||||
await this.loading.promise;
|
const activeLoading = this.loading;
|
||||||
return this.restorationLoaded();
|
if (activeLoading?.threadId === restoredThread.threadId && panelTargetLeasesMatch(activeLoading.panelTarget, panelTarget)) {
|
||||||
|
await activeLoading.promise;
|
||||||
|
return this.restorationLoaded(activeLoading.panelTarget, restoredThread.threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const threadId = restoredThread.threadId;
|
const threadId = restoredThread.threadId;
|
||||||
const loading = { threadId, promise: loadThread(threadId) };
|
const loading = { threadId, panelTarget, promise: loadThread(threadId) };
|
||||||
this.loading = loading;
|
this.loading = loading;
|
||||||
try {
|
try {
|
||||||
await loading.promise;
|
await loading.promise;
|
||||||
} finally {
|
} finally {
|
||||||
if (this.loading === loading) this.loading = null;
|
if (this.loading === loading) this.loading = null;
|
||||||
}
|
}
|
||||||
return this.restorationLoaded();
|
return this.restorationLoaded(loading.panelTarget, threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
isPending(threadId: string): boolean {
|
isPending(threadId: string): boolean {
|
||||||
return awaitingResumeThreadState(this.host.stateStore.getState())?.threadId === threadId;
|
return awaitingResumeThreadState(this.host.stateStore.getState())?.threadId === threadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private restorationLoaded(): boolean {
|
private restorationLoaded(panelTarget: PanelTargetLease, threadId: string): boolean {
|
||||||
return awaitingResumeThreadState(this.host.stateStore.getState()) === null;
|
const state = this.host.stateStore.getState();
|
||||||
|
return panelTargetLeaseIsCurrent(state, panelTarget) && activeThreadId(state) === threadId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue