Compare commits

...

69 commits
5.1.0 ... main

Author SHA1 Message Date
murashit
8dc4d82c26 chore(release): 5.2.1 2026-07-22 14:42:14 +09:00
murashit
811a4b7158 fix(threads): publish fork activity with list replacement 2026-07-22 13:23:18 +09:00
murashit
05ea87643a refactor(threads): coordinate fork publication atomically 2026-07-22 12:31:16 +09:00
murashit
bb673b2a75 refactor(chat): replace rollback RPC with marker fork 2026-07-22 11:10:22 +09:00
murashit
01a20dcf70 feat(chat): honor app-server direct input capability 2026-07-22 09:47:18 +09:00
murashit
73be2beea7 chore(app-server): update bindings for Codex CLI 0.145.0 2026-07-22 09:09:31 +09:00
murashit
14adac592d chore(deps): apply npm audit dependency updates 2026-07-21 21:07:10 +09:00
murashit
f4616cf945 refactor: consolidate thin module ownership 2026-07-21 20:59:09 +09:00
murashit
67289765c6 refactor(selection-rewrite): consolidate transport output 2026-07-21 20:59:09 +09:00
murashit
ab0a9047f4 refactor(diff): replace custom LCS with jsdiff 2026-07-21 20:58:23 +09:00
murashit
488344a74d refactor(markdown): derive code ranges from mdast 2026-07-21 20:57:59 +09:00
murashit
6bdd9e5972 refactor(archive): consolidate export ownership 2026-07-21 20:57:49 +09:00
murashit
5b6fce1a4d fix(archive): normalize markdown links with mdast 2026-07-21 19:40:52 +09:00
murashit
05f9740e8e chore(release): 5.2.0 2026-07-21 17:05:36 +09:00
murashit
6b2a20cf0d perf(chat): resume before shared resource hydration 2026-07-21 16:58:21 +09:00
murashit
d0965d19b1 feat(context): replace metadata envelopes with thread links 2026-07-21 16:39:27 +09:00
murashit
381e2a5467 chore(release): 5.1.2 2026-07-21 14:40:40 +09:00
murashit
03305a47ef fix(context): hide normalized resume manifests 2026-07-21 14:35:33 +09:00
murashit
d74747fbf0 fix(threads): serialize rename saves 2026-07-21 14:33:04 +09:00
murashit
f707da0274 fix(threads): keep errors out of navigation rows 2026-07-21 14:25:44 +09:00
murashit
2fb7d29841 fix(threads): disable unavailable auto-name actions 2026-07-21 14:25:44 +09:00
murashit
8a798a5bca fix(ui): disable operations with unmet preconditions 2026-07-21 14:25:44 +09:00
murashit
689368fe8d docs(design): prefer coherent UI over explanatory copy 2026-07-21 13:19:03 +09:00
murashit
5cd106973c refactor(threads): make auto-name exclusive and cancellable 2026-07-21 10:43:01 +09:00
murashit
f3d987ee03 refactor(settings): serialize dynamic refreshes 2026-07-21 10:26:57 +09:00
murashit
bbe311c4de refactor(chat): flatten session runtime indirection 2026-07-21 10:09:47 +09:00
murashit
4f3ebcaa31 docs: remove defensive reference caveats 2026-07-21 08:29:05 +09:00
murashit
96b88c1715 test: remove obsolete specification guards 2026-07-21 07:11:27 +09:00
murashit
193b680f06 refactor(runtime): remove obsolete lifetime and query abstractions 2026-07-20 23:47:49 +09:00
murashit
2d9f98f434 refactor(query): consolidate runtime-owned resources 2026-07-20 23:35:21 +09:00
murashit
ffcc814ba5 refactor(runtime): simplify disposable runtime boundaries 2026-07-20 23:33:43 +09:00
murashit
1f5aa50862 chore(release): 5.1.1 2026-07-20 21:31:36 +09:00
murashit
a2c896d0ae docs(review): require proportional evidence for findings 2026-07-20 19:32:45 +09:00
murashit
205893cc1f fix(thread-picker): keep one active picker per runtime 2026-07-20 19:07:33 +09:00
murashit
b00f6292c4 refactor: remove test-only source exports 2026-07-20 18:22:21 +09:00
murashit
8d952fcfbf chore: ignore local pnpm store 2026-07-20 18:00:02 +09:00
murashit
54dcbd8759 refactor(query): require complete runtime-owned context 2026-07-20 18:00:02 +09:00
murashit
381646ebfb refactor(settings): bind dynamic data to one runtime lease 2026-07-20 18:00:02 +09:00
murashit
aaa04507e0 refactor(chat): make reconnect reset one scoped transition 2026-07-20 17:58:03 +09:00
murashit
a92f3b031d refactor(chat): construct thread lifecycle without late binding 2026-07-20 17:52:33 +09:00
murashit
567c744624 refactor(protocol): isolate released legacy reference decoding
Decode released /refer envelopes and file references only in a dedicated protocol adapter.
Keep the compatibility layer removable without changing the current message path.
2026-07-20 17:27:57 +09:00
murashit
11c233b8fe refactor(runtime): replace executable-bound runtime atomically
Own executable- and vault-bound resources in one disposable runtime.
On executable changes, detach stable view shells, dispose all bound work,
and publish a fully attached replacement runtime.
2026-07-20 17:27:57 +09:00
murashit
aadcf179bc refactor(runtime): make vault the fixed workspace root
Remove protocol thread cwd from panel state and operations.
Require the immutable vault root wherever path presentation needs a workspace base.
2026-07-20 17:27:56 +09:00
murashit
52c350093a fix(query): align resource refresh semantics 2026-07-20 09:56:39 +09:00
murashit
a35c9f4263 feat(threads): share paginated thread history and search 2026-07-20 09:09:41 +09:00
murashit
dc467695f4 refactor(chat): replace thread stream presenter facade 2026-07-19 06:31:34 +09:00
murashit
1ef344b02a test(chat): split view lifecycle coverage for parallel execution 2026-07-19 06:22:46 +09:00
murashit
f93570848f refactor(chat): derive Threads activity from panel state 2026-07-19 06:17:10 +09:00
murashit
cf0548a0d0 refactor(app-server): make TanStack Query authoritative 2026-07-19 05:40:36 +09:00
murashit
ea665af455 chore(lint): re-enable Obsidian setting definition rule 2026-07-18 20:14:21 +09:00
murashit
7ec4acf2b1 test(chat): cover auto-review audit history 2026-07-18 19:33:10 +09:00
murashit
44128caf2c test(chat): protect stale reducer transitions 2026-07-18 19:33:10 +09:00
murashit
e7efd773b3 test(protocol): protect app-server boundary contracts 2026-07-18 19:28:21 +09:00
murashit
9c71b0eb88 test: expand mutation coverage at protocol boundaries 2026-07-18 19:11:18 +09:00
murashit
d3b426d500 test(runtime): cover approval policy commit state 2026-07-18 19:05:58 +09:00
murashit
d114c3e473 test(chat): protect context manifest placement 2026-07-18 19:05:58 +09:00
murashit
eecf73dc77 fix(chat): preserve accepted MCP elicitation URLs 2026-07-18 19:05:58 +09:00
murashit
358cdd7b7c test(chat): harden context serialization boundaries 2026-07-18 18:18:36 +09:00
murashit
5cd898fabe test(chat): harden stream behavior contracts 2026-07-18 18:18:28 +09:00
murashit
8351156589 test: harden vault and runtime boundaries 2026-07-18 18:18:24 +09:00
murashit
ef5d1d803d feat(chat): show live subagent activity summaries 2026-07-18 18:15:41 +09:00
murashit
760421613d test: add exploratory domain mutation testing 2026-07-18 17:43:34 +09:00
murashit
46ecf3addd test(chat): cover pending request UI boundaries 2026-07-18 14:50:45 +09:00
murashit
93c4ba50a1 test(settings): cover declarative API parity 2026-07-18 14:50:42 +09:00
murashit
d568f9abb1 test(app-server): cover RPC boundary failures 2026-07-18 14:48:22 +09:00
murashit
d1337a6c85 test(ui): cover turn diff and toolbar boundaries 2026-07-18 14:35:05 +09:00
murashit
eb6d4a3c2d test(app-server): cover client and transport lifecycles 2026-07-18 14:35:00 +09:00
murashit
4820fd5821 test(chat): cover pending requests and agent states 2026-07-18 14:34:14 +09:00
murashit
f588719440 test: add source coverage reporting 2026-07-18 14:17:42 +09:00
370 changed files with 17132 additions and 7918 deletions

6
.github/release-notes/5.1.1.md vendored Normal file
View 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
View 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
View 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
View 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.

4
.gitignore vendored
View file

@ -1,4 +1,6 @@
node_modules/
.pnpm-store/
.stryker-tmp/
.jj/
.codex/
main.js
@ -7,4 +9,6 @@ data.json
*.log
.DS_Store
dist/
coverage/
reports/
tmp/

View file

@ -2,7 +2,7 @@ This repository contains the Codex Panel Obsidian plugin.
## 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.
- 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.

View file

@ -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.
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.
![Codex Panel](assets/screenshot.webp)
@ -48,7 +48,7 @@ If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executab
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.
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>`.
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.
### Bring in the right context
@ -56,9 +56,6 @@ The composer lets you point Codex to relevant material without pasting it into t
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.
These references tell Codex what is relevant to the request; they do not change its vault-root working directory or its permissions.
Large transient references are bounded before they reach Codex, and the panel marks a web or thread reference when it had to truncate it.
### 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.
@ -77,7 +74,7 @@ Use `/help` for the current slash command list.
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------- |
| `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. |
| `codexAppServer.testedCliVersion` | `0.144.5` | 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.

View file

@ -10,9 +10,13 @@ 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.
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.
Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context, while persisting only a tightly bounded, context-body-free metadata descriptor needed to reconstruct panel display and archive metadata. Treat that fixed-schema envelope as inert reference metadata rather than user instructions. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer.
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.
@ -26,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.
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
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.
@ -48,7 +54,11 @@ Obsidian and app-server boundaries stay outside Preact components. External life
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 likewise have one authoritative query record. UI subscriptions should stay narrow enough that unrelated updates do not disturb independent panel regions.
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.
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.
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.
@ -62,7 +72,9 @@ Coordinate conflicting work at the narrowest shared semantic owner. Independent
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.
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.
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.

View file

@ -8,12 +8,18 @@ Use this document for day-to-day implementation mechanics: commands, generated f
npm ci
npm run fix
npm run check
npm run test:coverage
npm run test:mutation
```
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 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
Use [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) for new commits:

View file

@ -20,7 +20,7 @@ jj git push --remote origin --bookmark main
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.

View file

@ -54,8 +54,6 @@ export default defineConfig([
},
],
"@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",
},
},
{

View file

@ -1,7 +1,7 @@
{
"id": "codex-panel",
"name": "Codex Panel",
"version": "5.1.0",
"version": "5.2.1",
"minAppVersion": "1.12.0",
"description": "Codex in your sidebar.",
"author": "murashit",

2317
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "codex-panel",
"version": "5.1.0",
"version": "5.2.1",
"description": "Codex in your Obsidian sidebar.",
"main": "main.js",
"author": "murashit",
@ -36,14 +36,20 @@
"release:preflight": "node scripts/release/preflight.mjs",
"release:prepare": "node scripts/release/prepare.mjs",
"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"
},
"devDependencies": {
"@biomejs/biome": "^2.5.3",
"@commitlint/cli": "^21.2.1",
"@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",
"concurrently": "^10.0.3",
"@vitest/coverage-v8": "^4.1.10",
"concurrently": "^9.2.4",
"conventional-changelog-conventionalcommits": "^10.2.1",
"conventional-commits-parser": "^7.1.0",
"esbuild": "^0.28.0",
@ -60,8 +66,12 @@
"dependencies": {
"@tanstack/query-core": "^5.101.2",
"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",
"obsidian-daily-notes-interface": "^0.9.5",
"preact": "^10.29.7"
"preact": "^10.29.7",
"unist-util-visit": "^5.1.0"
}
}

View file

@ -30,7 +30,6 @@ import type { ThreadItemsListResponse } from "../../generated/app-server/v2/Thre
import type { ThreadListResponse } from "../../generated/app-server/v2/ThreadListResponse";
import type { ThreadReadResponse } from "../../generated/app-server/v2/ThreadReadResponse";
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 { ThreadSettingsUpdateResponse } from "../../generated/app-server/v2/ThreadSettingsUpdateResponse";
import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse";
@ -87,7 +86,6 @@ export interface ClientResponseByMethod {
"thread/delete": ThreadDeleteResponse;
"thread/unsubscribe": ThreadUnsubscribeResponse;
"thread/unarchive": ThreadUnarchiveResponse;
"thread/rollback": ThreadRollbackResponse;
"thread/name/set": ThreadSetNameResponse;
"thread/settings/update": ThreadSettingsUpdateResponse;
"thread/turns/list": ThreadTurnsListResponse;

View file

@ -1,6 +1,6 @@
{
"codexAppServer": {
"testedCliVersion": "0.144.5",
"testedCliVersion": "0.145.0",
"typeGeneration": {
"arguments": ["app-server", "generate-ts", "--experimental"]
},

View file

@ -5,32 +5,23 @@ import type { ServerRequest } from "../../generated/app-server/ServerRequest";
import { AppServerClient, type AppServerClientHandlers, type AppServerServerRequestResponder } from "./client";
export interface ConnectionManagerHandlers {
onNotification: (notification: ServerNotification, context: AppServerConnectionContext) => void;
onServerRequest: (request: ServerRequest, responder: AppServerServerRequestResponder, context: AppServerConnectionContext) => void;
onLog: (message: string, context: AppServerConnectionContext) => void;
onExit: (context: AppServerConnectionContext) => void;
onNotification: (notification: ServerNotification) => void;
onServerRequest: (request: ServerRequest, responder: AppServerServerRequestResponder) => void;
onLog: (message: string) => void;
onExit: () => void;
}
export type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient;
export interface AppServerConnectionContext {
readonly codexPath: string;
readonly cwd: string;
readonly generation: number;
}
type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient;
type ConnectionLifecycleState =
| { kind: "idle"; generation: number }
| {
kind: "connecting";
generation: number;
contextGeneration: number;
codexPath: string;
cwd: string;
client: AppServerClient;
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 };
export class StaleConnectionError extends Error {
@ -44,25 +35,15 @@ export class ConnectionManager {
private state: ConnectionLifecycleState = { kind: "idle", generation: 0 };
constructor(
private readonly codexPath: () => string,
private readonly codexPath: string,
private readonly cwd: string,
initializeParams: InitializeParams,
private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) =>
new AppServerClient({ codexPath, cwd, handlers, initializeParams }),
private readonly contextGeneration: () => number = () => 0,
) {}
currentClient(): AppServerClient | null {
return this.state.kind === "connected" && this.state.client.isConnected() && this.stateMatchesCurrentContext(this.state)
? 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 };
return this.state.kind === "connected" && this.state.client.isConnected() ? this.state.client : null;
}
isConnected(): boolean {
@ -74,31 +55,29 @@ export class ConnectionManager {
if (currentClient) {
return serverInitializationFromResponse(currentClient.initializeResponse);
}
if (this.state.kind === "connecting" && this.stateMatchesCurrentContext(this.state)) return this.state.promise;
if (this.state.kind === "connecting" || this.state.kind === "connected") this.disconnect();
if (this.state.kind === "connecting") return this.state.promise;
if (this.state.kind === "connected") this.disconnect();
const generation = this.state.generation + 1;
const contextGeneration = this.contextGeneration();
const codexPath = this.codexPath();
const codexPath = this.codexPath;
const cwd = this.cwd;
const context = { codexPath, cwd, generation: contextGeneration };
const client = this.clientFactory(codexPath, cwd, {
onNotification: (notification) => {
if (this.isStale(generation)) return;
handlers.onNotification(notification, context);
handlers.onNotification(notification);
},
onServerRequest: (request, responder) => {
if (this.isStale(generation)) return;
handlers.onServerRequest(request, responder, context);
handlers.onServerRequest(request, responder);
},
onLog: (message) => {
if (this.isStale(generation)) return;
handlers.onLog(message, context);
handlers.onLog(message);
},
onExit: () => {
if (this.isStale(generation)) return;
this.state = { kind: "disconnected", generation };
handlers.onExit(context);
handlers.onExit();
},
});
const promise = client
@ -108,7 +87,7 @@ export class ConnectionManager {
client.disconnect();
throw new StaleConnectionError();
}
this.state = { kind: "connected", generation, contextGeneration, codexPath, cwd, client };
this.state = { kind: "connected", generation, client };
return serverInitializationFromResponse(response);
})
.catch((error: unknown) => {
@ -122,14 +101,10 @@ export class ConnectionManager {
throw error;
});
this.state = { kind: "connecting", generation, contextGeneration, codexPath, cwd, client, promise };
this.state = { kind: "connecting", generation, client, promise };
return promise;
}
resetConnection(): void {
this.disconnect();
}
disconnect(): void {
const previous = this.state;
this.state = { kind: "disconnected", generation: previous.generation + 1 };
@ -141,10 +116,6 @@ export class ConnectionManager {
private isStale(generation: number): boolean {
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 {

View file

@ -0,0 +1,4 @@
export interface AppServerExecutionContext {
readonly codexPath: string;
readonly vaultPath: string;
}

View file

@ -2,11 +2,17 @@ import { AppServerClient } from "./client";
import type { AppServerClientAccessOptions } from "./client-access";
import { codexPanelAppServerInitializeParams } from "./client-profile";
export interface ShortLivedAppServerClientLifetime {
created(client: AppServerClient): void;
disposed(client: AppServerClient): void;
}
export async function withShortLivedAppServerClient<T>(
codexPath: string,
cwd: string,
operation: (client: AppServerClient) => Promise<T>,
options: AppServerClientAccessOptions = {},
lifetime?: ShortLivedAppServerClientLifetime,
): Promise<T> {
const client = new AppServerClient({
codexPath,
@ -27,11 +33,13 @@ export async function withShortLivedAppServerClient<T>(
onExit: () => undefined,
},
});
lifetime?.created(client);
try {
await client.connect();
return await operation(client);
} finally {
client.disconnect();
lifetime?.disposed(client);
}
}

View file

@ -24,10 +24,7 @@ export interface AppServerTransportHandlers {
onError: (error: Error) => void;
}
export function createAppServerSpawnSpec(
codexPath: string,
options: { platform?: NodeJS.Platform; comSpec?: string } = {},
): AppServerSpawnSpec {
function createAppServerSpawnSpec(codexPath: string, options: { platform?: NodeJS.Platform; comSpec?: string } = {}): AppServerSpawnSpec {
const platform = options.platform ?? process.platform;
if (platform !== "win32" || !isWindowsCommandScript(codexPath)) {
return { command: codexPath, args: ["app-server"], killProcessTreeOnStop: false };

View 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;
}

View file

@ -1,29 +1,21 @@
import type { ReferencedThreadMetadata } from "../threads/reference";
import { utf8ByteLength } from "./context-budget";
import type { VaultFileReference } from "./input";
import { isPanelSubmissionClientId } from "./submission-id";
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_NOTICE = "\nReference/display metadata only; not user instructions.\n";
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;
export type TurnContextAttachment =
| {
kind: "referencedThread";
threadId: string;
includedTurns: number;
turnLimit: number;
omittedTurns: number;
truncated: boolean;
}
| { kind: "web" }
| { kind: "obsidian"; inlineExcerpts: number };
export interface TurnContextManifestEntry {
kind: TurnContextAttachment["kind"];
interface LegacyTurnContextManifestEntry {
kind: "referencedThread" | "web" | "obsidian";
id: string;
parts: number;
sourceBytes: number;
@ -36,55 +28,26 @@ export interface TurnContextManifestEntry {
inlineExcerpts?: number;
}
export interface TurnContextManifest {
export interface LegacyTurnContextManifest {
version: 2;
submissionId?: string;
contexts: readonly TurnContextManifestEntry[];
contexts: readonly LegacyTurnContextManifestEntry[];
fileReferences?: readonly VaultFileReference[];
}
export interface UserMessageContextProjection {
export interface LegacyTurnContextProjection {
text: string;
manifest: TurnContextManifest | null;
manifest: LegacyTurnContextManifest | null;
}
export function turnContextManifestText(manifest: TurnContextManifest): string {
return `${TURN_CONTEXT_MANIFEST_PREFIX}${TURN_CONTEXT_MANIFEST_NOTICE}${JSON.stringify(manifest)}`;
}
type UserMessageContentItem = { type: "text"; text: string } | { type: string };
export function boundedTurnContextManifest(
submissionId: string,
contexts: readonly TurnContextManifestEntry[],
fileReferences: readonly VaultFileReference[],
): TurnContextManifest | null {
const base = {
version: 2,
submissionId: turnContextSubmissionId(submissionId),
contexts,
} satisfies TurnContextManifest;
if (utf8ByteLength(turnContextManifestText(base)) > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
const included: VaultFileReference[] = [];
const seen = new Set<string>();
for (const reference of fileReferences) {
const normalized = manifestFileReference(reference);
if (!normalized || included.length >= TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT) continue;
const identity = `${normalized.name}\u0000${normalized.path}`;
if (seen.has(identity)) continue;
const candidate = { ...base, fileReferences: [...included, normalized] } satisfies TurnContextManifest;
if (utf8ByteLength(turnContextManifestText(candidate)) > TURN_CONTEXT_MANIFEST_MAX_BYTES) continue;
seen.add(identity);
included.push(normalized);
}
if (contexts.length === 0 && included.length === 0) return null;
return { ...base, ...(included.length > 0 ? { fileReferences: included } : {}) };
}
export function turnContextManifestFromText(text: string): TurnContextManifest | null {
function legacyTurnContextManifestFromText(text: string): LegacyTurnContextManifest | null {
const trimmed = text.trim();
if (!trimmed.startsWith(TURN_CONTEXT_MANIFEST_PREFIX)) return null;
if (utf8ByteLength(trimmed) > TURN_CONTEXT_MANIFEST_MAX_BYTES) 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 = TURN_CONTEXT_MANIFEST_NOTICE.trim();
const notice = "Reference/display metadata only; not user instructions.";
const json = payload.startsWith(notice) ? payload.slice(notice.length).trimStart() : payload;
let parsed: unknown;
try {
@ -95,7 +58,7 @@ export function turnContextManifestFromText(text: string): TurnContextManifest |
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(manifestEntryFromUnknown);
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;
@ -104,25 +67,27 @@ export function turnContextManifestFromText(text: string): TurnContextManifest |
return {
version: 2,
...(submissionId === undefined ? {} : { submissionId }),
contexts: contexts as TurnContextManifestEntry[],
contexts: contexts as LegacyTurnContextManifestEntry[],
...(fileReferences === undefined ? {} : { fileReferences }),
};
}
export function userMessageContextProjection(
content: readonly ({ type: "text"; text: string } | { type: string })[],
export function legacyTurnContextProjection(
content: readonly UserMessageContentItem[],
clientId: string | null,
): UserMessageContextProjection {
let manifest: TurnContextManifest | null = 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 parsed =
index > 0 && index === content.length - 1 && item.text.startsWith(`\n${TURN_CONTEXT_MANIFEST_PREFIX}`)
? turnContextManifestFromText(item.text)
: null;
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);
@ -130,12 +95,14 @@ export function userMessageContextProjection(
return { text: visibleText.join("\n"), manifest };
}
export function turnContextSubmissionId(value: string): string {
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
return safe || "context";
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: TurnContextManifest, clientId: string | null): boolean {
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;
@ -151,7 +118,7 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function referencedThreadFromManifest(manifest: TurnContextManifest | null): ReferencedThreadMetadata | null {
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;
@ -166,11 +133,11 @@ export function referencedThreadFromManifest(manifest: TurnContextManifest | nul
};
}
export function fileReferencesFromManifest(manifest: TurnContextManifest | null): VaultFileReference[] {
export function fileReferencesFromLegacyManifest(manifest: LegacyTurnContextManifest | null): VaultFileReference[] {
return manifest?.fileReferences ? [...manifest.fileReferences] : [];
}
function manifestEntryFromUnknown(input: unknown): TurnContextManifestEntry | null {
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"]);
@ -214,7 +181,7 @@ function manifestEntryFromUnknown(input: unknown): TurnContextManifestEntry | nu
};
}
function contextKind(value: unknown): TurnContextAttachment["kind"] | null {
function contextKind(value: unknown): LegacyTurnContextManifestEntry["kind"] | null {
return value === "referencedThread" || value === "web" || value === "obsidian" ? value : null;
}

View file

@ -1,12 +1,6 @@
import { splitUtf8Context, utf8ByteLength } from "../../domain/chat/context-budget";
import {
boundedTurnContextManifest,
type TurnContextManifest,
type TurnContextManifestEntry,
turnContextManifestText,
turnContextSubmissionId,
} from "../../domain/chat/context-manifest";
import type { CodexInputItem, VaultFileReference } from "../../domain/chat/input";
import { splitUtf8Context } from "../../domain/chat/context-budget";
import type { CodexInputItem } from "../../domain/chat/input";
import { turnContextSubmissionId } from "../../domain/chat/submission-id";
type AppServerUserInputImageDetail = "auto" | "low" | "high" | "original";
@ -26,30 +20,15 @@ export interface AppServerTurnInput {
additionalContext?: Record<string, AppServerAdditionalContextEntry>;
}
export const ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES = 2_800;
export const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
const ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES = 2_800;
const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
export function toAppServerUserInput(input: readonly CodexInputItem[], manifest: TurnContextManifest | null = null): AppServerUserInput[] {
const userInput = input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
if (manifest) {
userInput.push({ type: "text", text: `\n${turnContextManifestText(manifest)}`, text_elements: [] });
}
return userInput;
}
export function additionalContextFromCodexInput(
input: readonly CodexInputItem[],
submissionId = "submission",
): Record<string, AppServerAdditionalContextEntry> | undefined {
return appServerTurnInputFromCodexInput(input, submissionId).additionalContext;
export function toAppServerUserInput(input: readonly CodexInputItem[]): AppServerUserInput[] {
return input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
}
export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[], submissionId: string): AppServerTurnInput {
const additionalContext: Record<string, AppServerAdditionalContextEntry> = {};
const manifest: TurnContextManifestEntry[] = [];
const fileReferences = input.flatMap((item): VaultFileReference[] =>
item.type === "fileReference" && item.name && item.path ? [{ name: item.name, path: item.path }] : [],
);
const contexts = input.filter(
(item): item is Extract<CodexInputItem, { type: "additionalContext" }> =>
item.type === "additionalContext" && Boolean(item.key) && Boolean(item.value),
@ -60,7 +39,6 @@ export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[
const partAllocations = allocatedPartCounts(contexts);
for (const [contextIndex, item] of contexts.entries()) {
const id = `${turnContextSubmissionId(submissionId)}.${String(contextIndex).padStart(2, "0")}`;
const sourceBytes = utf8ByteLength(item.value);
const split = splitUtf8Context(item.value, ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES, partAllocations[contextIndex] ?? 1);
const partCount = split.parts.length;
split.parts.forEach((part, partIndex) => {
@ -80,13 +58,9 @@ export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[
].join("\n"),
};
});
if (item.attachment || split.includedBytes < sourceBytes) {
manifest.push(manifestEntry(item, id, partCount, sourceBytes, split.includedBytes));
}
}
const persistedManifest = boundedTurnContextManifest(submissionId, manifest, fileReferences);
return {
input: toAppServerUserInput(input, persistedManifest),
input: toAppServerUserInput(input),
...(Object.keys(additionalContext).length > 0 ? { additionalContext } : {}),
};
}
@ -122,36 +96,6 @@ function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServ
}
}
function manifestEntry(
item: Extract<CodexInputItem, { type: "additionalContext" }>,
id: string,
parts: number,
sourceBytes: number,
includedBytes: number,
): TurnContextManifestEntry {
const common = {
kind: item.attachment?.kind ?? "obsidian",
id,
parts,
sourceBytes,
includedBytes,
truncated: includedBytes < sourceBytes,
} as const;
if (item.attachment?.kind === "obsidian") {
return { ...common, kind: "obsidian", inlineExcerpts: item.attachment.inlineExcerpts };
}
if (item.attachment?.kind !== "referencedThread") return common;
return {
...common,
kind: "referencedThread",
threadId: item.attachment.threadId,
includedTurns: item.attachment.includedTurns,
turnLimit: item.attachment.turnLimit,
omittedTurns: item.attachment.omittedTurns,
truncated: common.truncated || item.attachment.truncated,
};
}
function safeKeyPart(value: string): string {
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
return safe || "context";

View file

@ -21,6 +21,7 @@ export function threadFromThreadRecord(thread: ThreadRecord, options: { archived
archived: options.archived ?? false,
createdAt: finiteTimestamp(thread.createdAt),
updatedAt: finiteTimestamp(thread.updatedAt),
canAcceptDirectInput: typeof thread.canAcceptDirectInput === "boolean" ? thread.canAcceptDirectInput : null,
provenance: threadProvenance(thread),
...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}),
};

View file

@ -1,6 +1,5 @@
import { fileReferencesFromManifest, referencedThreadFromManifest, userMessageContextProjection } from "../../domain/chat/context-manifest";
import type { VaultFileReference } from "../../domain/chat/input";
import { type ReferencedThreadMetadata, referencedThreadMetadataFromPrompt } from "../../domain/threads/reference";
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
import {
nonEmptyTurnTranscriptSummaries,
type ThreadTranscriptEntry,
@ -9,6 +8,12 @@ import {
} from "../../domain/threads/transcript";
import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem";
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 TurnRecord = GeneratedTurn;
@ -51,33 +56,41 @@ export function chronologicalTurnTranscriptSummariesFromTurnRecords(turns: reado
export interface TurnUserItemProjection {
text: string;
referencedThread: ReferencedThreadMetadata | null;
manifest: ReturnType<typeof userMessageContextProjection>["manifest"];
fileReferences: VaultFileReference[];
contexts: TurnUserContextMetadata[];
}
interface TurnUserContextMetadata {
kind: "web" | "obsidian";
truncated: boolean;
inlineExcerpts?: number;
}
export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMessage" }>): TurnUserItemProjection {
const projected = userMessageContextProjection(item.content, item.clientId);
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");
const legacyReference = referencedThreadMetadataFromPrompt(text);
return {
text: legacyReference?.text ?? text,
referencedThread: referencedThreadFromManifest(projected.manifest) ?? legacyReference?.reference ?? null,
manifest: projected.manifest,
text,
referencedThread: referencedThreadFromLegacyManifest(projected.manifest),
fileReferences: fileReferencesFromLegacyManifest(projected.manifest),
contexts: legacyContextMetadata(projected.manifest.contexts),
};
}
export function turnUserFileReferences(
item: Extract<TurnItem, { type: "userMessage" }>,
manifest: ReturnType<typeof userMessageContextProjection>["manifest"],
): VaultFileReference[] {
const legacyReferences = item.content.flatMap((input) => {
if (input.type !== "mention") return [];
const reference = legacyPanelFileReference(input);
return reference ? [reference] : [];
});
return [...fileReferencesFromManifest(manifest), ...legacyReferences];
}
export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | null {
for (let index = turn.items.length - 1; index >= 0; index -= 1) {
const item = turn.items[index];
@ -93,10 +106,7 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
if (item.type === "userMessage") {
const projection = turnUserItemProjection(item);
const text = projection.text.trim();
const contexts =
projection.manifest?.contexts
.filter((context) => context.kind === "web" || context.kind === "obsidian")
.map((context) => ({ kind: context.kind as "web" | "obsidian", truncated: context.truncated })) ?? [];
const contexts = projection.contexts.map((context) => ({ kind: context.kind, truncated: context.truncated }));
return text
? [
{
@ -120,26 +130,36 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
return [];
}
function nonTextUserInputText(content: Extract<TurnItem, { type: "userMessage" }>["content"], visibleText: string): string {
function legacyContextMetadata(
contexts: readonly { kind: "referencedThread" | "web" | "obsidian"; truncated: boolean; inlineExcerpts?: number }[],
): TurnUserContextMetadata[] {
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
.map((item) => {
.map((item, index) => {
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 === "mention") {
if (hasText) return "";
const fileReference = legacyPanelFileReference(item);
return fileReference ? `[file] ${fileReference.path}` : `[@${item.name}] ${item.path}`;
}
if (item.type === "mention") return mentionTextByContentIndex?.get(index) ?? "";
if (item.type === "skill") return hasText ? "" : `[$${item.name}] ${item.path}`;
return "";
})
.filter(Boolean)
.join("\n");
}
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 };
}

View 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);
}

View file

@ -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 { cloneRuntimeConfigSnapshot, type RuntimeConfigSnapshot } from "../../domain/runtime/config";
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
@ -12,100 +20,86 @@ import {
} from "../../domain/server/diagnostics";
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../domain/server/metadata";
import type { Thread } from "../../domain/threads/model";
import { StaleExecutionRuntimeError } from "../../shared/runtime/execution-runtime-lifetime";
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 { listModelMetadata } from "../services/catalog";
import { readEffectiveConfig } from "../services/runtime-metadata";
import { listThreads, readThreadPage } from "../services/threads";
import { listThreads, readThreadPage, type ThreadPage } from "../services/threads";
import {
type AppServerQueryContextIdentity as AppServerQueryContext,
activeThreadsQueryKey,
appServerModelsQueryKey,
appServerPermissionProfilesQueryKey,
appServerQueryContextIsComplete,
appServerRateLimitsQueryKey,
appServerRuntimeConfigQueryKey,
appServerSkillsQueryKey,
archivedThreadsQueryKey,
cloneAppServerQueryContextIdentity,
} from "./keys";
type ActiveThreadCursor,
type ActiveThreadData,
activeThreadDataHasMore,
activeThreadsFromData,
applyActiveThreadMutation,
recentActiveThreadsFromData,
} from "./active-thread-inventory";
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, 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 APP_SERVER_QUERY_GC_TIME_MS = 5 * 60_000;
const FULL_ACTIVE_THREAD_FETCH_ATTEMPTS = 2;
export interface AppServerQueryClientRunner {
runWithClient<T>(
context: AppServerQueryContext,
operation: (client: AppServerClient) => Promise<T>,
options?: AppServerClientAccessOptions,
): Promise<T>;
}
const ACTIVE_THREADS_QUERY_KEY = ["threads", "active"] as const;
const ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY = ["threads", "active-search-inventory"] as const;
const ARCHIVED_THREADS_QUERY_KEY = ["threads", "archived"] as const;
const MODELS_QUERY_KEY = ["models"] as const;
const RUNTIME_CONFIG_QUERY_KEY = ["runtime-config"] as const;
const SKILLS_QUERY_KEY = ["skills"] as const;
const PERMISSION_PROFILES_QUERY_KEY = ["permission-profiles"] as const;
const RATE_LIMITS_QUERY_KEY = ["rate-limits"] as const;
interface AppServerQueryOptions<T> {
readonly queryKey: readonly unknown[];
readonly queryFn: () => Promise<T>;
readonly staleTime: number;
readonly queryFn: (context: { signal: AbortSignal }) => Promise<T>;
readonly staleTime?: number;
}
type ActiveThreadsQueryKey = typeof ACTIVE_THREADS_QUERY_KEY;
interface MetadataResourceSnapshot<T> {
readonly value: T;
readonly probe: DiagnosticProbeResult;
}
type MetadataResourceKind = "skills" | "permissionProfiles" | "rateLimits";
type MetadataNotificationResourceKind = "skills" | "rateLimits";
type MetadataResourceValue = readonly SkillMetadata[] | readonly RuntimePermissionProfileSummary[] | RateLimitSnapshot | null;
interface MetadataNotificationRefresh {
dirty: boolean;
readonly generation: number;
promise: Promise<void>;
}
export class AppServerQueryCache {
private readonly context: AppServerQueryContext;
private readonly context: Readonly<AppServerExecutionContext>;
private readonly client: QueryClient;
private readonly clientRunner: AppServerQueryClientRunner | null;
private activeThreadCursorKnown = false;
private activeThreadCursor: string | null = null;
private activeThreadRevision = 0;
private readonly threadListEpochs: Record<ThreadListKind, number> = { active: 0, archived: 0 };
private readonly metadataResourceFetches = new Map<MetadataResourceKind, Set<Promise<void>>>();
private readonly metadataNotificationRefreshes = new Map<MetadataNotificationResourceKind, MetadataNotificationRefresh>();
private generation = 0;
private readonly clientAccess: AppServerClientAccess;
private readonly observerUnsubscribes = new Set<() => void>();
private disposed = false;
constructor(context: AppServerQueryContext, options: { client?: QueryClient; clientRunner?: AppServerQueryClientRunner } = {}) {
this.context = cloneAppServerQueryContextIdentity(context);
this.client = options.client ?? createAppServerQueryClient();
this.clientRunner = options.clientRunner ?? null;
constructor(context: AppServerExecutionContext, clientAccess: AppServerClientAccess) {
this.context = Object.freeze({ ...context });
this.client = createAppServerQueryClient();
this.clientAccess = clientAccess;
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.generation += 1;
this.activeThreadCursorKnown = false;
this.activeThreadCursor = null;
this.activeThreadRevision = 0;
this.threadListEpochs.active = 0;
this.threadListEpochs.archived = 0;
this.metadataResourceFetches.clear();
this.metadataNotificationRefreshes.clear();
for (const unsubscribe of [...this.observerUnsubscribes]) unsubscribe();
this.observerUnsubscribes.clear();
this.client.clear();
}
activeThreadsSnapshot(): readonly Thread[] | 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 {
@ -113,92 +107,148 @@ export class AppServerQueryCache {
return this.threadListSnapshot("archived");
}
observeActiveThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options: { emitCurrent?: boolean } = {}): () => void {
observeActiveThreadsResult(
listener: ObservedPaginatedResultListener<readonly Thread[]>,
options: { emitCurrent?: boolean } = {},
): () => void {
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 {
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[]> {
this.assertUsable();
return this.fetchThreadList("active", options);
fetchActiveThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
return this.runWhileActive(async () => {
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[]> {
this.assertUsable();
return this.fetchThreadList("archived", options);
}
async refreshActiveThreads(): Promise<readonly Thread[]> {
refreshActiveThreads(): Promise<readonly Thread[]> {
return this.fetchActiveThreads({ force: true });
}
async fetchAllActiveThreads(): Promise<readonly Thread[]> {
this.assertUsable();
if (!appServerQueryContextIsComplete(this.context)) return [];
const snapshot = this.activeThreadsSnapshot();
if (snapshot && this.activeThreadCursorKnown && !this.activeThreadCursor) return snapshot;
for (let attempt = 0; attempt < FULL_ACTIVE_THREAD_FETCH_ATTEMPTS; attempt += 1) {
const revision = this.activeThreadRevision;
const threads = await this.runWithClient((client) => listThreads(client, this.context.vaultPath));
if (this.activeThreadRevision !== revision) continue;
this.storeThreadList("active", threads);
this.rememberActiveThreadCursor(null);
return cloneThreads(threads);
}
throw new Error("Active thread inventory changed while it was being fetched.");
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]> {
return this.runWhileActive(async () => {
const key = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
const options = {
queryKey: key,
queryFn: ({ signal }: { signal: AbortSignal }) =>
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { signal })),
};
return this.readFreshThroughQueryCancellation(key, async () => cloneThreads(await this.client.fetchQuery(options)));
});
}
hasMoreActiveThreads(): boolean {
if (this.disposed) return false;
if (!appServerQueryContextIsComplete(this.context)) return false;
return Boolean(this.activeThreadCursor);
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY));
}
async loadMoreActiveThreads(): Promise<readonly Thread[]> {
this.assertUsable();
if (!appServerQueryContextIsComplete(this.context)) return [];
const current = this.activeThreadsSnapshot() ?? (await this.fetchActiveThreads());
const cursor = this.activeThreadCursor;
if (!cursor) return current;
const revision = this.activeThreadRevision;
const page = await this.runWithClient((client) => readThreadPage(client, this.context.vaultPath, { cursor, archived: false }));
if (page.nextCursor === cursor) throw new Error("Codex app-server returned a repeated thread list cursor.");
if (this.activeThreadRevision !== revision) return this.activeThreadsSnapshot() ?? current;
const latest = this.activeThreadsSnapshot() ?? current;
const existingIds = new Set(latest.map((thread) => thread.id));
const threads = [...latest, ...page.threads.filter((thread) => !existingIds.has(thread.id))];
this.storeThreadList("active", threads);
this.rememberActiveThreadCursor(page.nextCursor);
return cloneThreads(threads);
loadMoreActiveThreads(): Promise<readonly Thread[]> {
return this.runWhileActive(async () => {
const current = this.activeThreadsSnapshot() ?? (await this.fetchActiveThreads());
const observer = new InfiniteQueryObserver(this.client, {
...this.activeThreadsQueryOptions(),
enabled: false,
});
try {
if (!observer.getCurrentResult().hasNextPage) return current;
const result = await observer.fetchNextPage({ cancelRefetch: false, throwOnError: true });
this.assertUsable();
return result.data ? cloneThreads(activeThreadsFromData(result.data) ?? []) : current;
} catch (error) {
if (error instanceof CancelledError) return this.activeThreadsSnapshot() ?? current;
throw error;
} finally {
observer.destroy();
}
});
}
async refreshArchivedThreads(): Promise<readonly Thread[]> {
refreshArchivedThreads(): Promise<readonly Thread[]> {
return this.fetchArchivedThreads({ force: true });
}
fetchArchivedThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
return this.runWhileActive(async () => {
const key = ARCHIVED_THREADS_QUERY_KEY;
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())),
);
});
}
applyThreadListMutations(mutations: readonly ThreadListMutation[]): void {
this.assertUsable();
if (!appServerQueryContextIsComplete(this.context) || mutations.length === 0) return;
for (const kind of ["active", "archived"] as const) {
const relevant = mutations.filter((mutation) => mutation.list === kind);
if (relevant.length === 0) continue;
const key = this.threadListQueryKey(kind);
const before = this.threadListSnapshot(kind);
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";
this.threadListEpochs[kind] += 1;
const after = relevant.reduce<readonly Thread[] | null>(applyThreadListMutation, before);
if (after) this.storeThreadList(kind, after);
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 partialSnapshotCreated = before === null && after !== null;
const refreshRequested = relevant.some((mutation) => mutation.kind === "refresh");
if (before !== null || wasFetching || partialSnapshotCreated || refreshRequested) {
void this.fetchThreadList(kind, { force: true }).catch(() => {
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.
});
}
@ -206,33 +256,14 @@ export class AppServerQueryCache {
}
private threadListSnapshot(kind: ThreadListKind): readonly Thread[] | null {
if (!appServerQueryContextIsComplete(this.context)) return null;
const threads = this.client.getQueryData<readonly Thread[]>(this.threadListQueryKey(kind));
if (kind === "active") return this.activeThreadsSnapshot();
const threads = this.client.getQueryData<readonly Thread[]>(ARCHIVED_THREADS_QUERY_KEY);
return threads ? cloneThreads(threads) : null;
}
private async fetchThreadList(kind: ThreadListKind, options: { force?: boolean } = {}): Promise<readonly Thread[]> {
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();
return cloneThreads(threads);
}
private storeThreadList(kind: ThreadListKind, threads: readonly Thread[]): void {
if (!appServerQueryContextIsComplete(this.context)) return;
this.client.setQueryData(this.threadListQueryKey(kind), cloneThreads(threads));
if (kind === "active") this.bumpActiveThreadRevision();
}
appServerMetadataSnapshot(): SharedServerMetadata | null {
if (this.disposed) return null;
if (!appServerQueryContextIsComplete(this.context)) return null;
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(appServerRuntimeConfigQueryKey(this.context));
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(RUNTIME_CONFIG_QUERY_KEY);
if (!runtimeConfig) return null;
const skills = this.metadataResourceState("skills");
const permissionProfiles = this.metadataResourceState("permissionProfiles");
@ -255,10 +286,9 @@ export class AppServerQueryCache {
options: { emitCurrent?: boolean } = {},
): () => void {
this.assertUsable();
const generation = this.generation;
let disposed = false;
let unsubscribed = false;
const emit = (resource: SharedServerMetadataResource): void => {
if (!disposed && generation === this.generation) listener(cloneSharedServerMetadataResource(resource));
if (!this.disposed && !unsubscribed) listener(cloneSharedServerMetadataResource(resource));
};
const runtimeConfig = new QueryObserver(this.client, { ...this.runtimeConfigQueryOptions(), enabled: false });
@ -309,50 +339,54 @@ export class AppServerQueryCache {
emitPermissionProfiles(permissionProfiles.getCurrentResult(), true);
emitRateLimits(rateLimits.getCurrentResult(), true);
}
return () => {
disposed = true;
return this.trackObserver(() => {
unsubscribed = true;
for (const unsubscribe of unsubscribers) unsubscribe();
};
runtimeConfig.destroy();
models.destroy();
skills.destroy();
permissionProfiles.destroy();
rateLimits.destroy();
});
}
async refreshAppServerMetadata(): Promise<void> {
this.assertUsable();
if (!appServerQueryContextIsComplete(this.context)) return;
const runtimeResult = this.fetchRuntimeConfig(true).then(
() => ({ ok: true as const }),
(error: unknown) => ({ ok: false as const, error }),
);
const [, runtime] = await Promise.all([
Promise.allSettled([
this.fetchMetadataResource("skills", true),
this.fetchMetadataResource("permissionProfiles", true),
this.fetchMetadataResource("rateLimits", true),
this.fetchModels({ force: true }),
]),
runtimeResult,
]);
this.assertUsable();
if (!runtime.ok) throw runtime.error;
refreshAppServerMetadata(): Promise<void> {
return this.runWhileActive(async () => {
const runtimeResult = this.fetchRuntimeConfig().then(
() => ({ ok: true as const }),
(error: unknown) => ({ ok: false as const, error }),
);
const [, runtime] = await Promise.all([
Promise.allSettled([
this.fetchMetadataResource("skills"),
this.fetchMetadataResource("permissionProfiles"),
this.fetchMetadataResource("rateLimits"),
this.fetchModels({ force: true }),
]),
runtimeResult,
]);
this.assertUsable();
if (!runtime.ok) throw runtime.error;
});
}
async refreshSkills(): Promise<void> {
this.assertUsable();
if (!appServerQueryContextIsComplete(this.context)) return;
await this.refreshMetadataResourceNotification("skills");
this.assertUsable();
refreshSkills(): Promise<void> {
return this.runWhileActive(async () => {
await this.refreshNotifiedMetadataResource("skills");
this.assertUsable();
});
}
async refreshRateLimits(): Promise<void> {
this.assertUsable();
if (!appServerQueryContextIsComplete(this.context)) return;
await this.refreshMetadataResourceNotification("rateLimits");
this.assertUsable();
refreshRateLimits(): Promise<void> {
return this.runWhileActive(async () => {
await this.refreshNotifiedMetadataResource("rateLimits");
this.assertUsable();
});
}
modelsSnapshot(): readonly ModelMetadata[] | null {
if (this.disposed) return null;
if (!appServerQueryContextIsComplete(this.context)) return null;
const models = this.client.getQueryData<readonly ModelMetadata[]>(appServerModelsQueryKey(this.context));
const models = this.client.getQueryData<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
return models ? cloneModelMetadata(models) : null;
}
@ -361,175 +395,125 @@ export class AppServerQueryCache {
return this.observeQueryResult(this.modelsQueryOptions(), cloneModelMetadata, listener, options);
}
async fetchModels(options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
this.assertUsable();
if (!appServerQueryContextIsComplete(this.context)) return [];
const key = appServerModelsQueryKey(this.context);
if (options.force) {
await this.client.invalidateQueries({ queryKey: key });
fetchModels(options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
return this.runWhileActive(async () => {
const key = MODELS_QUERY_KEY;
if (options.force) {
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
this.assertUsable();
}
const models = await this.client.fetchQuery(this.modelsQueryOptions());
this.assertUsable();
}
const models = await this.client.fetchQuery(this.modelsQueryOptions());
this.assertUsable();
return cloneModelMetadata(models);
return cloneModelMetadata(models);
});
}
async refreshModels(): Promise<readonly ModelMetadata[]> {
refreshModels(): Promise<readonly ModelMetadata[]> {
return this.fetchModels({ force: true });
}
private threadListQueryOptions(kind: ThreadListKind): AppServerQueryOptions<readonly Thread[]> {
private activeThreadsQueryOptions(): InfiniteQueryObserverOptions<
ThreadPage,
Error,
ActiveThreadData,
ActiveThreadsQueryKey,
ActiveThreadCursor
> {
return {
queryKey: this.threadListQueryKey(kind),
queryFn: async (): Promise<readonly Thread[]> => {
if (kind === "active") {
for (;;) {
const epoch = this.threadListEpochs.active;
const revision = this.activeThreadRevision;
const page = await this.runWithClient((client) => readThreadPage(client, this.context.vaultPath, { archived: false }));
if (this.threadListEpochs.active !== epoch) continue;
if (this.activeThreadRevision !== revision) return this.activeThreadsSnapshot() ?? [];
this.rememberActiveThreadCursor(page.nextCursor);
return cloneThreads(page.threads);
}
}
for (;;) {
const epoch = this.threadListEpochs.archived;
const threads = await this.runWithClient((client) => listThreads(client, this.context.vaultPath, { archived: true }));
if (this.threadListEpochs.archived === epoch) return cloneThreads(threads);
}
queryKey: ACTIVE_THREADS_QUERY_KEY,
queryFn: async ({ pageParam, signal }) => {
signal.throwIfAborted();
const page = await this.runWithClient((client) =>
readThreadPage(client, this.context.vaultPath, { cursor: pageParam, archived: false }),
);
signal.throwIfAborted();
return page;
},
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> {
return kind === "archived" ? archivedThreadsQueryKey(this.context) : activeThreadsQueryKey(this.context);
private archivedThreadsQueryOptions(): AppServerQueryOptions<readonly Thread[]> {
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> {
return {
queryKey: appServerRuntimeConfigQueryKey(this.context),
queryKey: RUNTIME_CONFIG_QUERY_KEY,
queryFn: async (): Promise<RuntimeConfigSnapshot> =>
this.runWithClient(async (client) =>
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 {
queryKey: appServerSkillsQueryKey(this.context),
queryKey: SKILLS_QUERY_KEY,
queryFn: async () =>
this.runWithClient(async (client) =>
successfulMetadataResource(await readSkillMetadataProbe(client, this.context.vaultPath, forceReload)),
),
staleTime: APP_SERVER_METADATA_STALE_TIME_MS,
this.runWithClient(async (client) => successfulMetadataResource(await readSkillMetadataProbe(client, this.context.vaultPath))),
};
}
private permissionProfilesQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>> {
return {
queryKey: appServerPermissionProfilesQueryKey(this.context),
queryKey: PERMISSION_PROFILES_QUERY_KEY,
queryFn: async () =>
this.runWithClient(async (client) =>
successfulMetadataResource(await readPermissionProfileMetadataProbe(client, this.context.vaultPath)),
),
staleTime: APP_SERVER_METADATA_STALE_TIME_MS,
};
}
private rateLimitsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<RateLimitSnapshot | null>> {
return {
queryKey: appServerRateLimitsQueryKey(this.context),
queryKey: RATE_LIMITS_QUERY_KEY,
queryFn: async () => this.runWithClient(async (client) => successfulMetadataResource(await readRateLimitMetadataProbe(client))),
staleTime: APP_SERVER_METADATA_STALE_TIME_MS,
};
}
private async fetchRuntimeConfig(force: boolean): Promise<RuntimeConfigSnapshot> {
const key = appServerRuntimeConfigQueryKey(this.context);
if (force) {
await this.client.invalidateQueries({ queryKey: key });
this.assertUsable();
}
private async fetchRuntimeConfig(): Promise<RuntimeConfigSnapshot> {
const runtimeConfig = await this.client.fetchQuery(this.runtimeConfigQueryOptions());
this.assertUsable();
return cloneRuntimeConfigSnapshot(runtimeConfig);
}
private fetchMetadataResource(resource: MetadataResourceKind, force: boolean, reloadSkills = false): Promise<void> {
const refresh = (async (): Promise<void> => {
private fetchMetadataResource(resource: MetadataResourceKind): Promise<void> {
return (async (): Promise<void> => {
if (resource === "skills") {
const options = this.skillsQueryOptions(reloadSkills);
if (force) {
await this.client.invalidateQueries({ queryKey: options.queryKey });
this.assertUsable();
}
const options = this.skillsQueryOptions();
await this.client.fetchQuery(options);
this.assertUsable();
return;
}
if (resource === "permissionProfiles") {
const options = this.permissionProfilesQueryOptions();
if (force) {
await this.client.invalidateQueries({ queryKey: options.queryKey });
this.assertUsable();
}
await this.client.fetchQuery(options);
this.assertUsable();
return;
}
const options = this.rateLimitsQueryOptions();
if (force) {
await this.client.invalidateQueries({ queryKey: options.queryKey });
this.assertUsable();
}
await this.client.fetchQuery(options);
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<void> {
const generation = this.generation;
const current = this.metadataNotificationRefreshes.get(resource);
if (current?.generation === generation) {
current.dirty = true;
return current.promise;
private async refreshNotifiedMetadataResource(resource: "skills" | "rateLimits"): Promise<void> {
const queryKey = resource === "skills" ? SKILLS_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
await this.client.cancelQueries({ queryKey, exact: true });
this.assertUsable();
try {
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;
}
private metadataResourceState(resource: "skills"): { value: readonly SkillMetadata[] | null; probe: DiagnosticProbeResult };
@ -540,11 +524,7 @@ export class AppServerQueryCache {
private metadataResourceState(resource: "rateLimits"): { value: RateLimitSnapshot | null; probe: DiagnosticProbeResult };
private metadataResourceState(resource: MetadataResourceKind): { value: MetadataResourceValue; probe: DiagnosticProbeResult } {
const key =
resource === "skills"
? appServerSkillsQueryKey(this.context)
: resource === "permissionProfiles"
? appServerPermissionProfilesQueryKey(this.context)
: appServerRateLimitsQueryKey(this.context);
resource === "skills" ? SKILLS_QUERY_KEY : resource === "permissionProfiles" ? PERMISSION_PROFILES_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
const state = this.client.getQueryState<MetadataResourceSnapshot<MetadataResourceValue>>(key);
const failedProbe = diagnosticProbeFromError(state?.error);
return {
@ -554,7 +534,7 @@ export class AppServerQueryCache {
}
private modelsProbe(): DiagnosticProbeResult {
const state = this.client.getQueryState<readonly ModelMetadata[]>(appServerModelsQueryKey(this.context));
const state = this.client.getQueryState<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
return (
diagnosticProbeFromError(state?.error) ??
(state?.data
@ -565,7 +545,7 @@ export class AppServerQueryCache {
private modelsQueryOptions(): AppServerQueryOptions<readonly ModelMetadata[]> {
return {
queryKey: appServerModelsQueryKey(this.context),
queryKey: MODELS_QUERY_KEY,
queryFn: async (): Promise<readonly ModelMetadata[]> => {
try {
return cloneModelMetadata(
@ -581,68 +561,107 @@ export class AppServerQueryCache {
};
}
private observeQueryResult<T>(
queryOptions: AppServerQueryOptions<T>,
clone: (value: T) => T,
listener: ObservedResultListener<T>,
private observeQueryResult<TQuery, TValue>(
queryOptions: AppServerQueryOptions<TQuery>,
project: (value: TQuery) => TValue,
listener: ObservedResultListener<TValue>,
options: { emitCurrent?: boolean },
): () => void {
const observer = new QueryObserver<T>(this.client, {
const observer = new QueryObserver<TQuery>(this.client, {
...queryOptions,
enabled: false,
});
const emit = (result: QueryObserverResult<T>): void => {
listener(this.cloneObservedResult(result, clone));
const emit = (result: QueryObserverResult<TQuery>): void => {
if (!this.disposed) listener(this.projectObservedResult(result, project));
};
const unsubscribe = observer.subscribe(emit);
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 {
value: result.data === undefined ? null : clone(result.data),
value: result.data === undefined ? null : project(result.data),
error: result.error instanceof Error ? result.error : null,
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> {
this.assertUsable();
const generation = this.generation;
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(
(result) => {
if (this.disposed || generation !== this.generation) throw new DisposedAppServerQueryCacheError();
return result;
},
(error: unknown) => {
if (this.disposed || generation !== this.generation) throw new DisposedAppServerQueryCacheError();
throw error;
},
);
return this.clientAccess.withClient(operation, options);
}
private async readThroughQueryCancellation<T>(read: () => Promise<T>, fallback?: () => T | null): Promise<T> {
for (;;) {
try {
const value = await read();
this.assertUsable();
return value;
} catch (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 {
if (this.disposed) throw new DisposedAppServerQueryCacheError();
if (this.disposed) throw new StaleExecutionRuntimeError();
}
private rememberActiveThreadCursor(cursor: string | null): void {
this.activeThreadCursorKnown = true;
this.activeThreadCursor = cursor;
this.bumpActiveThreadRevision();
private runWhileActive<T>(operation: () => Promise<T>): Promise<T> {
this.assertUsable();
return (async () => {
try {
const result = await operation();
this.assertUsable();
return result;
} catch (error) {
if (this.disposed) throw new StaleExecutionRuntimeError();
throw error;
}
})();
}
private bumpActiveThreadRevision(): void {
this.activeThreadRevision += 1;
}
}
class DisposedAppServerQueryCacheError extends Error {
constructor() {
super("Codex app-server query cache was disposed.");
this.name = "DisposedAppServerQueryCacheError";
private trackObserver(unsubscribe: () => void): () => void {
let subscribed = true;
const trackedUnsubscribe = (): void => {
if (!subscribed) return;
subscribed = false;
this.observerUnsubscribes.delete(trackedUnsubscribe);
unsubscribe();
};
this.observerUnsubscribes.add(trackedUnsubscribe);
return trackedUnsubscribe;
}
}
@ -662,17 +681,12 @@ function diagnosticProbeFromError(error: unknown): DiagnosticProbeResult | null
return error instanceof MetadataResourceQueryError ? error.probe : null;
}
function metadataNotificationRefreshIsDirty(refresh: MetadataNotificationRefresh): boolean {
return refresh.dirty;
}
function createAppServerQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
gcTime: APP_SERVER_QUERY_GC_TIME_MS,
networkMode: "always",
retry: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
mutations: {

View file

@ -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;
}

View file

@ -16,16 +16,9 @@ export type SkillMetadataProbeResult = MetadataProbeResult<SkillMetadata[], "ski
export type PermissionProfileMetadataProbeResult = MetadataProbeResult<RuntimePermissionProfileSummary[], "permissionProfiles">;
export type RateLimitMetadataProbeResult = MetadataProbeResult<RateLimitSnapshot | null, "rateLimits">;
export async function readSkillMetadataProbe(
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()) };
}
export async function readSkillMetadataProbe(client: AppServerRequestClient, vaultPath: string): Promise<SkillMetadataProbeResult> {
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()) };
} catch (error) {
return { value: [], probe: diagnosticProbeError("skills", error, Date.now()) };
@ -33,15 +26,9 @@ export async function readSkillMetadataProbe(
}
export async function readPermissionProfileMetadataProbe(
client: AppServerRequestClient | null,
client: AppServerRequestClient,
vaultPath: string,
): Promise<PermissionProfileMetadataProbeResult> {
if (!client) {
return {
value: [],
probe: diagnosticProbeError("permissionProfiles", new Error("Codex app-server is not connected."), Date.now()),
};
}
try {
const profiles = await listPermissionProfiles(client, vaultPath);
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> {
if (!client) {
return {
value: null,
probe: diagnosticProbeError("rateLimits", new Error("Codex app-server is not connected."), Date.now()),
};
}
export async function readRateLimitMetadataProbe(client: AppServerRequestClient): Promise<RateLimitMetadataProbeResult> {
try {
const response = await readAccountRateLimits(client);
return {

View file

@ -6,6 +6,13 @@ export interface ObservedResult<T> {
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 {
return currentValue == null && result.isFetching;
}

View file

@ -1,276 +0,0 @@
import type { ModelMetadata } from "../../domain/catalog/metadata";
import type { SharedServerMetadata, SharedServerMetadataResource } 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";
import type { ThreadListMutation } from "./thread-list-mutation";
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());
}
applyThreadListMutations(mutations: readonly ThreadListMutation[]): void {
this.currentCache().applyThreadListMutations(mutations);
}
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<void> {
return this.runForCurrentContext((cache) => cache.refreshAppServerMetadata());
}
refreshSkills(): Promise<void> {
return this.runForCurrentContext((cache) => cache.refreshSkills());
}
refreshRateLimits(): Promise<void> {
return this.runForCurrentContext((cache) => cache.refreshRateLimits());
}
observeAppServerMetadataResources(
listener: (resource: SharedServerMetadataResource) => void,
options?: { emitCurrent?: boolean },
): () => void {
return this.observeCurrentContext(
(cache, contextListener, observeOptions) => cache.observeAppServerMetadataResources(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;
}
}

View file

@ -18,7 +18,7 @@ export function applyThreadListMutation(snapshot: readonly Thread[] | null, muta
case "refresh":
return snapshot;
case "upsert": {
if (!snapshot) return [mutation.thread];
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));

View file

@ -38,7 +38,7 @@ export interface EphemeralStructuredTurnClient {
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
export type EphemeralStructuredTurnClientFactory = (
type EphemeralStructuredTurnClientFactory = (
codexPath: string,
cwd: string,
handlers: AppServerClientHandlers,
@ -67,6 +67,12 @@ export type EphemeralStructuredTurnRunner = (options: RunEphemeralStructuredTurn
export interface EphemeralStructuredTurnDependencies {
clientFactory?: EphemeralStructuredTurnClientFactory | undefined;
timers?: EphemeralStructuredTurnTimers | undefined;
clientLifecycle?:
| {
created(client: EphemeralStructuredTurnClient): void;
disposed(client: EphemeralStructuredTurnClient): void;
}
| undefined;
}
export async function runEphemeralStructuredTurn(
@ -131,6 +137,7 @@ export async function runEphemeralStructuredTurn(
abortOperation(new Error(options.exitedMessage));
},
});
dependencies.clientLifecycle?.created(client);
try {
await runAbortable(client.connect());
@ -175,8 +182,12 @@ export async function runEphemeralStructuredTurn(
} finally {
state = completeEphemeralStructuredTurnState(state);
timers.clearTimeout(timeout);
await deleteEphemeralStructuredTurnThread(client, threadId);
client.disconnect();
try {
await deleteEphemeralStructuredTurnThread(client, threadId);
} finally {
client.disconnect();
dependencies.clientLifecycle?.disposed(client);
}
}
}

View file

@ -6,7 +6,6 @@ import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../doma
import type { ThreadActivationSnapshot } from "../../domain/threads/activation";
import type { ArchiveThreadInput } from "../../domain/threads/archive-markdown";
import type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal";
import type { HistoricalTurn } from "../../domain/threads/history";
import type { Thread } from "../../domain/threads/model";
import { REFERENCED_THREAD_TURN_LIMIT } from "../../domain/threads/reference";
import type { TurnTranscriptSummary } from "../../domain/threads/transcript";
@ -23,8 +22,6 @@ import {
} from "../protocol/turn";
import type { AppServerRequestClient } from "./request-client";
const THREAD_LIST_PAGE_LIMIT = 100;
export type ThreadTurnSortDirection = "asc" | "desc";
interface TurnTranscriptSummaryPage {
@ -63,6 +60,7 @@ export interface AppServerThreadListOptions {
export interface ThreadPage {
readonly threads: readonly Thread[];
readonly nextCursor: string | null;
readonly fetchedSize: number;
}
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 threads: Thread[] = [];
const seenCursors = new Set<string>();
let cursor: string | null = null;
for (;;) {
options.signal?.throwIfAborted();
const page = await readThreadPage(client, cwd, {
archived,
cursor,
limit: THREAD_LIST_PAGE_LIMIT,
});
threads.push(...page.threads);
@ -141,11 +143,11 @@ export async function readThreadPage(
const page = await readThreadRecordPage(client, cwd, {
...options,
archived,
limit: options.limit ?? THREAD_LIST_PAGE_LIMIT,
});
return {
threads: threadsFromThreadRecords(page.data, { archived }),
nextCursor: page.nextCursor ?? null,
fetchedSize: page.data.length,
};
}
@ -184,47 +186,61 @@ export async function readReferencedThreadTurnTranscriptSummaries(
return chronologicalTurnTranscriptSummariesFromTurnRecords(response.data);
}
export interface ThreadRollbackSnapshot {
thread: Thread;
cwd: string;
turns: readonly HistoricalTurn[];
}
type ThreadForkPosition =
| { readonly kind: "through-turn"; readonly turnId: string }
| { readonly kind: "before-turn"; readonly turnId: string };
interface ThreadRollbackResult {
readonly thread: ThreadRecord & {
readonly cwd: string;
readonly turns: readonly HistoricalTurn[];
interface AppServerForkThreadOptions {
readonly position?: ThreadForkPosition;
readonly deferGoalContinuation?: boolean;
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(
client: AppServerRequestClient,
threadId: string,
cwd: string,
lastTurnId: string | null = null,
options: AppServerForkThreadOptions = {},
): 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", {
threadId,
cwd,
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);
}
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 {
readonly activation: ThreadActivationSnapshot;
readonly sourceThreadId: string;
@ -245,13 +261,12 @@ export class EphemeralThreadCleanupRequiredError extends Error {
export async function forkEphemeralThread(
client: AppServerRequestClient,
sourceThreadId: string,
cwd: string,
vaultPath: string,
): Promise<EphemeralThreadForkSnapshot> {
const source = await client.request("thread/read", { threadId: sourceThreadId, includeTurns: false });
const config = await client.request("config/read", { cwd: source.thread.cwd });
const config = await client.request("config/read", { cwd: vaultPath });
const response = await client.request("thread/fork", {
threadId: sourceThreadId,
cwd,
cwd: vaultPath,
ephemeral: true,
sandbox: "read-only",
approvalPolicy: "never",
@ -310,7 +325,6 @@ export function threadActivationSnapshotFromAppServerResponse(response: ThreadAc
});
return {
thread: threadFromThreadRecord(response.thread),
cwd: response.cwd,
approvalPolicyKnown: "approvalPolicy" in response,
sandboxPolicyKnown: "sandbox" in response || "sandboxPolicy" in response,
permissionProfileKnown: "activePermissionProfile" in response,

View file

@ -14,7 +14,6 @@ export interface RequestAdditionalContext {
key: string;
value: string;
kind: "untrusted" | "application";
attachment?: TurnContextAttachment;
}
export type CodexInputItem =
@ -28,7 +27,6 @@ export type CodexInputItem =
key: string;
value: string;
kind: RequestAdditionalContext["kind"];
attachment?: TurnContextAttachment;
};
export type CodexInput = CodexInputItem[];
@ -53,7 +51,6 @@ export function codexTextInputWithReferences(
key: context.key,
value: context.value,
kind: context.kind,
...(context.attachment ? { attachment: context.attachment } : {}),
})),
];
}
@ -61,5 +58,3 @@ export function codexTextInputWithReferences(
export function codexTextInputWithAttachments(text: string, input: readonly CodexInputItem[]): CodexInput {
return [...codexTextInput(text), ...input.filter((item) => item.type !== "text")];
}
import type { TurnContextAttachment } from "./context-manifest";

View file

@ -3,3 +3,8 @@ const PANEL_SUBMISSION_CLIENT_ID_PATTERN = /^local-(?:user|steer)-\d+-[A-Za-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";
}

View 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);
}

View file

@ -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(", ")}]`;
}

View file

@ -11,24 +11,10 @@ interface PendingRequestQueueSource {
readonly pendingMcpElicitations: readonly unknown[];
}
interface PendingRequestCountSource {
readonly pendingApprovals: number;
readonly pendingUserInputs: number;
readonly pendingMcpElicitations: number;
}
export function pendingRequestCountsFromQueues(source: PendingRequestQueueSource): PendingRequestCounts {
return pendingRequestCounts({
pendingApprovals: source.approvals.length,
pendingUserInputs: source.pendingUserInputs.length,
pendingMcpElicitations: source.pendingMcpElicitations.length,
});
}
export function pendingRequestCounts(source: PendingRequestCountSource): PendingRequestCounts {
const approvals = source.pendingApprovals;
const userInputs = source.pendingUserInputs;
const mcpElicitations = source.pendingMcpElicitations;
const approvals = source.approvals.length;
const userInputs = source.pendingUserInputs.length;
const mcpElicitations = source.pendingMcpElicitations.length;
return {
approvals,
userInputs,

View file

@ -19,7 +19,7 @@ export interface RuntimeConfigSnapshot {
readonly autoCompactTokenLimit: number | null;
}
export function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot {
function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot {
return {
profile: null,
model: null,

View file

@ -15,7 +15,6 @@ export interface CollaborationMode {
}
export interface RuntimeSettingsPatch {
cwd?: string | null;
approvalPolicy?: RuntimeApprovalPolicy | null;
approvalsReviewer?: ApprovalsReviewer | null;
permissions?: string | null;

View file

@ -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 {
return {
...diagnostics,

View file

@ -5,7 +5,6 @@ import type { Thread } from "./model";
export interface ThreadActivationSnapshot extends RuntimePermissionState, RuntimePermissionKnownState {
thread: Thread;
cwd: string;
model: string | null;
serviceTier: ServiceTier | null;
approvalsReviewer: ApprovalsReviewer | null;

View file

@ -1,21 +1,21 @@
import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../markdown/frontmatter";
import { isExternalFileHref, parseFileHref } from "../vault/file-hrefs";
import type { Link, Nodes } from "mdast";
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 type { Thread } from "./model";
import { referencedThreadMetadataFromPrompt } from "./reference";
import { threadArchiveTitle } from "./title";
import type { ThreadTranscriptEntry } from "./transcript";
interface ParsedMarkdownLink {
raw: string;
label: string;
href: string;
interface MarkdownSourceReplacement {
start: number;
end: number;
value: string;
}
export interface ArchiveExportSettings {
archiveExportFolderTemplate: string;
archiveExportFilenameTemplate: string;
export interface ArchiveMarkdownOptions {
archiveExportTags?: string;
vaultPath?: string;
vaultConfigDir?: string;
@ -25,56 +25,31 @@ export interface ArchiveThreadInput extends Thread {
transcriptEntries: readonly ThreadTranscriptEntry[];
}
export function archivedThreadMarkdown(
thread: ArchiveThreadInput,
exportedAt = new Date(),
settings?: Partial<ArchiveExportSettings>,
): string {
export function archivedThreadMarkdown(thread: ArchiveThreadInput, exportedAt = new Date(), settings: ArchiveMarkdownOptions = {}): string {
const title = threadArchiveTitle(thread);
const tags = normalizedArchiveTags(settings?.archiveExportTags ?? "");
const lines = [
const tags = normalizedArchiveTags(settings.archiveExportTags ?? "");
const frontmatter = [
"---",
`title: ${yamlFrontmatterString(title)}`,
`thread_id: ${yamlFrontmatterString(thread.id)}`,
`created: ${yamlFrontmatterString(formatDate(exportedAt))}`,
...frontmatterTagsLines(tags),
"---",
"",
`# ${title}`,
"",
...transcriptMarkdownLines(thread.transcriptEntries),
];
const markdown = `${trimTrailingBlankLines(lines).join("\n")}\n`;
return settings?.vaultPath ? normalizeExportedMarkdownLinks(markdown, settings.vaultPath, settings.vaultConfigDir) : markdown;
].join("\n");
const body = `${trimTrailingBlankLines([`# ${title}`, "", ...transcriptMarkdownLines(thread.transcriptEntries)]).join("\n")}\n`;
const normalizedBody = settings.vaultPath ? normalizeExportedMarkdownLinks(body, settings.vaultPath, settings.vaultConfigDir) : body;
return `${frontmatter}\n\n${normalizedBody}`;
}
function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
const lines = markdown.split("\n");
let inFence = false;
return lines
.map((line) => {
if (line.trimStart().startsWith("```")) {
inFence = !inFence;
return line;
}
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;
const replacements: MarkdownSourceReplacement[] = [];
visit(fromMarkdown(markdown), "link", (link) => {
const replacement = normalizedExportedMarkdownLink(link, vaultPath, vaultConfigDir);
if (replacement) replacements.push(replacement);
});
let output = markdown;
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 output;
}
@ -99,16 +74,14 @@ function markdownLinesFromTranscriptEntry(entry: ThreadTranscriptEntry): string[
switch (entry.kind) {
case "user": {
const heading = timestampedHeading("User", entry.timestamp);
const legacyReferenced = referencedThreadMetadataFromPrompt(entry.text);
const referenced = entry.referencedThread ? { text: entry.text, reference: entry.referencedThread } : legacyReferenced;
return [
heading,
"",
referenced?.text ?? entry.text,
entry.text,
"",
...(referenced
...(entry.referencedThread
? [
`> Referenced: ${referenced.reference.title} (${String(referenced.reference.includedTurns)}/${String(referenced.reference.turnLimit)} turns${referenced.reference.truncated ? ", truncated" : ""}, ${referenced.reference.threadId})`,
`> Referenced: ${entry.referencedThread.title} (${String(entry.referencedThread.includedTurns)}/${String(entry.referencedThread.turnLimit)} turns${entry.referencedThread.truncated ? ", truncated" : ""}, ${entry.referencedThread.threadId})`,
"",
]
: []),
@ -166,67 +139,45 @@ function stripMatchingQuotes(value: string): string {
return (first === `"` || first === `'`) && first === last ? value.slice(1, -1) : value;
}
function unwrappedMarkdownHref(value: string): string {
if (value.startsWith("<") && value.endsWith(">")) return value.slice(1, -1);
return value;
}
function normalizedExportedMarkdownLink(
link: Link,
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 {
if (line[start] !== "[" || line[start - 1] === "!") return null;
const parsed = parseFileHref(link.url);
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 (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;
if (!isFilesystemAbsolutePath(normalizeFilePath(parsed.path))) return null;
return {
raw: line.slice(start, end),
label: line.slice(start + 1, labelEnd),
href: line.slice(hrefStart, hrefEnd + (line[hrefStart] === "<" ? 1 : 0)),
start,
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 {
const href = unwrappedMarkdownHref(link.href.trim());
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 markdownNodeSource(node: Nodes): string {
return toMarkdown(node, { unsafe: [{ character: "|", inConstruct: ["label", "phrasing"] }] }).trimEnd();
}
function archiveExportShouldKeepAbsolute(vaultRelativePath: string, vaultConfigDir: string | null | undefined): boolean {
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 {
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
@ -240,3 +191,11 @@ function trimTrailingBlankLines(lines: string[]): string[] {
while (result[result.length - 1] === "") result.pop();
return result;
}
function yamlFrontmatterString(value: string): string {
return JSON.stringify(value);
}
function yamlFrontmatterInlineList(values: readonly string[]): string {
return `[${values.map(yamlFrontmatterString).join(", ")}]`;
}

View 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, "\\$&");
}

View file

@ -6,6 +6,11 @@ export interface Thread {
readonly createdAt: number;
readonly updatedAt: number;
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;
}

View file

@ -1,6 +1,5 @@
import { truncateUtf8, utf8ByteLength } from "../chat/context-budget";
import type { Thread } from "./model";
import { threadDisplayTitle } from "./title";
import type { TurnTranscriptSummary } from "./transcript";
export const REFERENCED_THREAD_TURN_LIMIT = 20;
@ -14,33 +13,12 @@ export interface ReferencedThreadMetadata {
truncated?: boolean;
}
interface ReferencedThreadEnvelope {
version: 1;
reference: ReferencedThreadMetadata;
visibleText: string;
}
export interface ReferencedThreadContextBundle {
value: string;
referencedThread: ReferencedThreadMetadata;
}
const REFERENCED_THREAD_CONTEXT_MAX_BYTES = 18_000;
function referencedThreadMetadata(thread: Thread, count: number): ReferencedThreadMetadata {
return {
threadId: thread.id,
title: threadDisplayTitle(thread),
includedTurns: count,
turnLimit: REFERENCED_THREAD_TURN_LIMIT,
};
}
export function referencedThreadContextBundle(thread: Thread, turns: readonly TurnTranscriptSummary[]): ReferencedThreadContextBundle {
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;
let truncatedTurn = false;
for (let index = rendered.length - 1; index >= 0; index -= 1) {
const value = rendered[index];
if (value === undefined) continue;
@ -49,7 +27,6 @@ export function referencedThreadContextBundle(thread: Thread, turns: readonly Tu
if (included.length === 0) {
const turn = turns[index];
if (turn) included.unshift(truncatedReferenceTurn(turn, index + 1, REFERENCED_THREAD_CONTEXT_MAX_BYTES));
truncatedTurn = true;
}
break;
}
@ -57,22 +34,14 @@ export function referencedThreadContextBundle(thread: Thread, turns: readonly Tu
bytes += nextBytes;
}
const omittedTurns = turns.length - included.length;
const reference = {
...referencedThreadMetadata(thread, included.length),
omittedTurns,
truncated: omittedTurns > 0 || truncatedTurn,
};
return {
value: [
"Referenced thread context for the current user input:",
`Thread: ${thread.id}`,
`Included turns: ${String(reference.includedTurns)}`,
`Omitted turns: ${String(omittedTurns)}`,
"",
...included,
].join("\n"),
referencedThread: reference,
};
return [
"Referenced thread context for the current user input:",
`Thread: ${thread.id}`,
`Included turns: ${String(included.length)}`,
`Omitted turns: ${String(omittedTurns)}`,
"",
...included,
].join("\n");
}
function renderedReferenceTurn(turn: TurnTranscriptSummary, index: number): string {
@ -107,87 +76,3 @@ function truncatedReferenceTurn(turn: TurnTranscriptSummary, index: number, maxB
suffix,
].join("");
}
export function referencedThreadMetadataFromPrompt(text: string): { text: string; reference: ReferencedThreadMetadata } | null {
const envelope = referencedThreadEnvelopeFromPrompt(text);
return envelope ? { text: envelope.visibleText, reference: envelope.reference } : null;
}
const REFERENCED_THREAD_ENVELOPE_START = "[Codex Panel referenced thread v1]";
const REFERENCED_THREAD_ENVELOPE_END = "[/Codex Panel referenced thread]";
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;
}

View file

@ -1,15 +1,25 @@
import type { ThreadTitleContext } from "./title-generation-model";
type ThreadRenameAutoNameState = { kind: "checking" } | { kind: "unavailable" } | { kind: "ready"; context: ThreadTitleContext };
export type ThreadRenameLifecycleState =
| { kind: "idle" }
| { kind: "editing"; draft: string }
| { kind: "generating"; draft: string; originalDraft: string; generationToken: number };
| { kind: "editing"; draft: string; autoName: ThreadRenameAutoNameState }
| { 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 ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
type ThreadRenameSavingState = Extract<ThreadRenameLifecycleState, { kind: "saving" }>;
export type ThreadRenameLifecycleEvent =
| { type: "started"; draft: string }
| { type: "draft-updated"; draft: string }
| { type: "auto-name-context-resolved"; context: ThreadTitleContext | null }
| { type: "cancelled" }
| { type: "save-started"; saveToken: number }
| { type: "save-failed"; saveToken: number }
| { type: "save-succeeded"; saveToken: number }
| { type: "generation-started"; generationToken: number }
| { type: "generation-succeeded"; generationToken: number; draft: string }
| { type: "generation-finished"; generationToken: number }
@ -25,25 +35,40 @@ export function transitionThreadRenameLifecycleState(
): ThreadRenameLifecycleState {
switch (event.type) {
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":
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":
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":
if (state.kind !== "editing") return state;
if (state.kind !== "editing" || state.autoName.kind !== "ready") return state;
return {
kind: "generating",
draft: state.draft,
originalDraft: state.draft,
autoName: state.autoName,
generationToken: event.generationToken,
};
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 };
case "generation-finished":
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
return { kind: "editing", draft: state.draft };
return { kind: "editing", draft: state.draft, autoName: state.autoName };
case "cleared":
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
default:
@ -58,6 +83,10 @@ export function threadRenameGenerationStillActive(
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 {
throw new Error(`Unhandled thread rename lifecycle event: ${String(event)}`);
}

View file

@ -1,4 +1,3 @@
import { shortThreadId } from "./id";
import { type Thread, threadRecencyAt } from "./model";
import { threadDisplayTitle } from "./title";
@ -16,14 +15,18 @@ export type ThreadSearchResolution =
| { kind: "none"; query: string };
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[] {
const query = normalizedThreadSearchQuery(queryText);
return threads
.map((thread, index) => {
const title = threadDisplayTitle(thread);
const score = threadSearchScore(thread, title, query);
const score = threadSearchScore(title, query);
return { thread, title, score, recencyAt: threadRecencyAt(thread), index };
})
.filter((match) => match.score !== NO_THREAD_SEARCH_MATCH)
@ -49,16 +52,21 @@ function normalizedThreadSearchQuery(queryText: string): string {
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;
const id = thread.id.toLowerCase();
const normalizedTitle = title.toLowerCase();
const shortId = shortThreadId(thread.id).toLowerCase();
if (id === query || shortId === query) return 0;
if (normalizedTitle.startsWith(query)) return 1;
if (id.startsWith(query) || shortId.startsWith(query)) return 2;
if (normalizedTitle.includes(query)) return 3;
if (id.includes(query)) return 4;
return NO_THREAD_SEARCH_MATCH;
if (normalizedTitle === query) return TITLE_EXACT_SCORE;
if (normalizedTitle.startsWith(query)) return TITLE_PREFIX_SCORE;
if (normalizedTitle.includes(query)) return TITLE_SUBSTRING_SCORE;
return fuzzySubsequenceMatches(normalizedTitle, query) ? TITLE_FUZZY_SCORE : 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;
}

View file

@ -6,9 +6,6 @@ const DEFAULT_CONTEXT_PAGE_LIMIT = 20;
const DEFAULT_CONTEXT_MAX_PAGES = 5;
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 {
userRequest: string;
assistantResponse: string;

View file

@ -2,8 +2,10 @@ import { shortThreadId } from "./id";
import type { Thread } from "./model";
const MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH = 96;
const MAX_THREAD_COMMAND_DISPLAY_TITLE_LENGTH = 96;
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 GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
export function threadMeaningfulTitle(thread: Thread): string | null {
for (const value of [thread.name, thread.preview]) {
@ -17,6 +19,10 @@ export function threadDisplayTitle(thread: Thread): string {
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 {
return threadMeaningfulTitle(thread) ?? "";
}
@ -26,9 +32,7 @@ export function threadArchiveTitle(thread: Thread): string {
}
export function threadArchiveDisplayTitle(thread: Thread): string {
const title = threadArchiveTitle(thread);
if (title.length <= MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH) return title;
return `${title.slice(0, MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH - 3).trimEnd()}...`;
return truncateThreadDisplayTitle(threadArchiveTitle(thread), MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH);
}
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 {
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()}...`;
}

387
src/execution-runtime.ts Normal file
View 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,
}),
};
}
}

View file

@ -1,5 +1,4 @@
import type { RequestId, ServerNotification, ServerRequest } from "../../../../app-server/connection/rpc-messages";
import type { AppServerQueryContextIdentity } from "../../../../app-server/query/keys";
import {
routeServerRequest,
serverRequestApprovalResponse,
@ -15,7 +14,7 @@ import {
type PendingUserInput,
} from "../../../../domain/pending-requests/model";
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 { LocalIdSource } from "../../application/local-id-source";
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";
export interface ChatInboundHandlerActions {
refreshActiveThreads: () => void;
refreshServerDiagnostics: (options?: { forceResourceProbes?: boolean }) => void;
applyAppServerResourceEvent: (event: AppServerResourceEvent) => 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;
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
}
export interface ChatInboundHandler {
handleNotification(notification: ServerNotification, sourceContext: AppServerQueryContextIdentity): void;
handleNotification(notification: ServerNotification): void;
handleServerRequest(request: ServerRequest): void;
handleAppServerLog(message: string): void;
resolveApproval(requestId: PendingRequestId, action: ApprovalAction): void;
@ -67,8 +65,8 @@ export function createChatInboundHandler(
): ChatInboundHandler {
const context: ChatInboundHandlerContext = { store, actions, localItemIds };
return {
handleNotification: (notification, sourceContext) => {
handleNotification(context, notification, sourceContext);
handleNotification: (notification) => {
handleNotification(context, notification);
},
handleServerRequest: (request) => {
handleServerRequest(context, request);
@ -108,14 +106,10 @@ function dispatch(context: ChatInboundHandlerContext, action: ChatAction): void
context.store.dispatch(action);
}
function handleNotification(
context: ChatInboundHandlerContext,
notification: ServerNotification,
sourceContext: AppServerQueryContextIdentity,
): void {
function handleNotification(context: ChatInboundHandlerContext, notification: ServerNotification): void {
const plan = planChatNotification(state(context), notification, (prefix) => localItemId(context, prefix));
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 {
@ -243,15 +237,8 @@ function localItemId(context: ChatInboundHandlerContext, prefix: string): string
return context.localItemIds.next(prefix);
}
function runNotificationEffect(
context: ChatInboundHandlerContext,
effect: ChatNotificationEffect,
sourceContext: AppServerQueryContextIdentity,
): void {
function runNotificationEffect(context: ChatInboundHandlerContext, effect: ChatNotificationEffect): void {
switch (effect.type) {
case "refresh-threads":
context.actions.refreshActiveThreads();
return;
case "refresh-server-diagnostics":
context.actions.refreshServerDiagnostics({ forceResourceProbes: effect.forceResourceProbes === true });
return;
@ -261,8 +248,8 @@ function runNotificationEffect(
case "maybe-name-thread":
context.actions.maybeNameThread(effect.threadId, effect.turnId, effect.completedTurnTranscriptSummary);
return;
case "apply-thread-catalog-event":
context.actions.applyThreadCatalogEvent(effect.event, sourceContext);
case "apply-thread-operation-event":
context.actions.applyThreadOperationEvent(effect.event);
return;
}
}

View file

@ -2,17 +2,18 @@ import type { ServerNotification } from "../../../../app-server/connection/rpc-m
import { threadFromAppServerRecord } from "../../../../app-server/services/threads";
import { threadTokenUsageFromRuntimeUsage } from "../../../../domain/runtime/metrics";
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 { activeThreadSettingsAppliedAction } from "../../application/state/actions";
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 type { TurnRuntimeEvent } from "../../application/turns/runtime-events";
import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items";
import { type DiagnosticStatusNotification, routeServerNotification, type ThreadLifecycleNotification } from "./notification-routing";
import { turnRuntimeEventsFromNotification } from "./runtime-events";
export type ChatNotificationEffect =
| { type: "refresh-threads" }
| {
type: "maybe-name-thread";
threadId: string;
@ -21,9 +22,9 @@ export type ChatNotificationEffect =
}
| { type: "refresh-server-diagnostics"; forceResourceProbes?: boolean }
| { 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 {
actions: readonly ChatAction[];
@ -45,6 +46,7 @@ export function planChatNotification(
});
switch (route.kind) {
case "inactive":
return planTrackedSubagentNotification(state, route.scope.threadId, route.notification, localItemId);
case "ignored":
case "unhandled":
return EMPTY_PLAN;
@ -65,34 +67,188 @@ function runtimeEventsPlan(
notification: Parameters<typeof turnRuntimeEventsFromNotification>[0],
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
const plan = planTurnRuntimeEvents(state, turnRuntimeEventsFromNotification(notification, localItemId));
const events = turnRuntimeEventsFromNotification(notification, localItemId);
const plan = planTurnRuntimeEvents(state, events);
return {
actions: plan.actions,
actions: [
...plan.actions,
...subagentTrackingActionsFromParentEvents(state, events),
...subagentTrackingActionsFromActivityItem(state, notification),
],
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[] {
if (activeThreadState(state)?.lifetime?.kind === "ephemeral") return [];
switch (outcome.type) {
case "turn-started":
return [
{
type: "apply-thread-catalog-event",
event: { type: "thread-touched", threadId: outcome.threadId, recencyAt: outcome.recencyAt },
},
];
case "turn-completed":
return [
{
type: "maybe-name-thread",
threadId: outcome.threadId,
turnId: outcome.turnId,
completedTurnTranscriptSummary: outcome.completedTurnTranscriptSummary,
},
{ type: "refresh-threads" },
];
}
return [
{
type: "maybe-name-thread",
threadId: outcome.threadId,
turnId: outcome.turnId,
completedTurnTranscriptSummary: outcome.completedTurnTranscriptSummary,
},
];
}
function planDiagnosticStatus(notification: DiagnosticStatusNotification): ChatNotificationPlan {
@ -135,17 +291,23 @@ function planThreadLifecycle(
case "thread/started":
return threadStartedPlan(state, notification);
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":
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":
return effectPlan({
type: "apply-thread-catalog-event",
type: "apply-thread-operation-event",
event: { type: "thread-unarchived", threadId: notification.params.threadId },
});
case "thread/name/updated":
return effectPlan({
type: "apply-thread-catalog-event",
type: "apply-thread-operation-event",
event: {
type: "thread-renamed",
threadId: notification.params.threadId,
@ -167,19 +329,25 @@ function threadStartedPlan(
notification: Extract<ThreadLifecycleNotification, { method: "thread/started" }>,
): ChatNotificationPlan {
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[] =
notification.params.thread.ephemeral || thread.provenance.kind === "subagent"
? []
: [
{
type: "apply-thread-catalog-event",
event: { type: "thread-started", thread },
type: "apply-thread-operation-event",
event: {
type: "thread-upserted",
thread,
forkedFromThreadId: notification.params.thread.forkedFromId,
},
},
];
if (activeThreadId(state) === notification.params.thread.id) {
return { actions: [{ type: "active-thread/cwd-set", cwd: notification.params.thread.cwd }], effects };
}
return { actions: [], effects };
return { actions: trackAction, effects };
}
function threadGoalPlan(

View file

@ -25,7 +25,7 @@ export type ServerNotificationRoute =
| { kind: "userVisibleNotice"; notification: UserVisibleNoticeNotification }
| { kind: "ignored"; notification: ServerNotification }
| { kind: "unhandled"; notification: ServerNotification }
| { kind: "inactive"; notification: ServerNotification };
| { kind: "inactive"; notification: ServerNotification; scope: AppServerRouteScope };
type ServerNotificationScopeExtractors = Partial<{
[Method in ServerNotificationMethod]: (notification: Extract<ServerNotification, { method: Method }>) => AppServerRouteScope;
@ -105,6 +105,9 @@ const IGNORED_SERVER_NOTIFICATION_METHODS = [
"thread/status/changed",
"thread/closed",
"rawResponseItem/completed",
"rawResponse/completed",
"thread/environment/connected",
"thread/environment/disconnected",
"command/exec/outputDelta",
"process/outputDelta",
"process/exited",
@ -157,6 +160,7 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors =
"item/autoApprovalReview/completed": threadTurnNotificationScope,
"item/completed": threadTurnNotificationScope,
"rawResponseItem/completed": threadTurnNotificationScope,
"rawResponse/completed": threadTurnNotificationScope,
"item/agentMessage/delta": threadTurnNotificationScope,
"item/plan/delta": threadTurnNotificationScope,
"command/exec/outputDelta": unscopedNotificationScope,
@ -200,13 +204,15 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors =
"windows/worldWritableWarning": unscopedNotificationScope,
"windowsSandbox/setupCompleted": unscopedNotificationScope,
"account/login/completed": unscopedNotificationScope,
"thread/environment/connected": threadOnlyNotificationScope,
"thread/environment/disconnected": threadOnlyNotificationScope,
};
export function routeServerNotification(notification: ServerNotification, scope: ActiveRouteScope): ServerNotificationRoute {
if (isThreadCatalogNotification(notification)) return { kind: "threadLifecycle", notification };
const routeScope = serverNotificationScope(notification);
if (!isAppServerRouteScopeInActiveRouteScope(routeScope, scope)) return { kind: "inactive", notification };
if (isIdleThreadStreamUpdate(notification, 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, scope: routeScope };
if (isStreamUpdateNotification(notification)) return { kind: "streamUpdate", notification };
if (isTurnLifecycleNotification(notification)) return { kind: "turnLifecycle", notification };

View file

@ -126,7 +126,6 @@ export function turnRuntimeEventsFromNotification(
type: "turnStarted",
threadId: notification.params.threadId,
turnId: notification.params.turn.id,
recencyAt: notification.params.turn.startedAt,
},
];
case "turn/completed":

View file

@ -1,5 +1,4 @@
import { RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/execution-state";
import type { ExecutionState } from "../../../domain/thread-stream/items";
import { type ExecutionState, RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/items";
export { RUNNING_EXECUTION_STATE };

View file

@ -2,13 +2,12 @@ import {
completedTurnTranscriptSummaryFromTurnRecord,
type TurnItem,
type TurnRecord,
turnUserFileReferences,
turnUserItemProjection,
} from "../../../../../app-server/protocol/turn";
import { jsonPreview } from "../../../../../domain/display/json-preview";
import type { HistoricalTurn } from "../../../../../domain/threads/history";
import type { TurnTranscriptSummary } from "../../../../../domain/threads/transcript";
import { contextAttachmentsFromManifest } from "../../../domain/thread-stream/format/context-attachments";
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 { userMessageDisplayText } from "../../../domain/thread-stream/format/user-message-text";
@ -134,8 +133,8 @@ function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStr
const projection = turnUserItemProjection(item);
const text = projection.text;
const referencedThread = projection.referencedThread;
const referencedFiles = threadStreamFileReferences(turnUserFileReferences(item, projection.manifest));
const contextAttachments = contextAttachmentsFromManifest(projection.manifest, text);
const referencedFiles = threadStreamFileReferences(projection.fileReferences);
const contextAttachments = contextAttachmentsFromHistoryContexts(projection.contexts, text);
if (referencedThread) {
return {
...turnItemSourceFields(item, turnId),

View file

@ -1,6 +1,5 @@
import type { AppServerClient } from "../../../app-server/connection/client";
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 { ComposerInputSnapshot } from "../application/composer/input-snapshot";
import { createThreadReferenceResolver, type ThreadReferenceResolver } from "./thread-reference-resolver";
@ -13,7 +12,7 @@ import {
} from "./transports/session-transports";
export interface ChatCurrentAppServerGatewayHost {
codexPath(): string;
fallbackClientAccess: AppServerClientAccess;
vaultPath: string;
currentClient(): AppServerClient | null;
}
@ -74,7 +73,7 @@ function createCurrentClientAccess(host: ChatCurrentAppServerGatewayHost): AppSe
return {
withClient: async (operation, options: AppServerClientAccessOptions = {}) => {
if (options.serverRequests?.kind === "reject") {
return withShortLivedAppServerClient(host.codexPath(), host.vaultPath, operation, options);
return host.fallbackClientAccess.withClient(operation, options);
}
const client = host.currentClient();

View file

@ -1,9 +1,10 @@
import type { AppServerRequestClient } from "../../../app-server/services/request-client";
import { readReferencedThreadTurnTranscriptSummaries } from "../../../app-server/services/threads";
import { type CodexInput, codexTextInputWithAttachments } from "../../../domain/chat/input";
import { threadReferenceMarkdown } from "../../../domain/threads/deep-link";
import { shortThreadId } from "../../../domain/threads/id";
import type { Thread } from "../../../domain/threads/model";
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadContextBundle } from "../../../domain/threads/reference";
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadContext } from "../../../domain/threads/reference";
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
import type { ThreadReferenceInput } from "../application/turns/slash-command-execution";
@ -40,28 +41,20 @@ async function referencedThreadInput(
return null;
}
const messageInput = host.prepareInput(message, snapshot);
const reference = referencedThreadContextBundle(thread, turns);
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).`);
return {
text: messageInput.text,
input: codexTextInputWithAttachments(messageInput.text, [
text: visibleText,
input: codexTextInputWithAttachments(visibleText, [
{
type: "additionalContext",
key: "codex_panel_referenced_thread",
kind: "untrusted",
value: reference.value,
attachment: {
kind: "referencedThread",
threadId: reference.referencedThread.threadId,
includedTurns: reference.referencedThread.includedTurns,
turnLimit: reference.referencedThread.turnLimit,
omittedTurns: reference.referencedThread.omittedTurns ?? 0,
truncated: reference.referencedThread.truncated ?? false,
},
value: reference,
},
...messageInput.input,
]),
referencedThread: reference.referencedThread,
};
} catch (error) {
if (host.currentClient() !== client) return null;

View file

@ -10,7 +10,6 @@ import {
readThreadGoal,
recordThreadGoalUserMessage,
resumeThread,
rollbackThread,
setThreadGoal,
startThread,
threadActivationSnapshotFromAppServerResponse,
@ -30,7 +29,7 @@ import type {
ThreadResumeSnapshot,
ThreadResumeTransport,
} 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 { ThreadSubscriptionTransport } from "../../application/threads/thread-subscription-transport";
import type { ChatTurnTransport } from "../../application/turns/turn-transport";
@ -166,17 +165,8 @@ function createChatThreadMutationTransport(host: ChatAppServerTransportHost): Th
runCurrentChatAppServerEffect(host, async (client) => {
await compactThread(client, threadId);
}),
forkThread: (threadId, lastTurnId = null) =>
runCurrentChatAppServerEffect(host, (client) => forkThread(client, threadId, host.vaultPath, lastTurnId)),
rollbackThread: (threadId) =>
runCurrentChatAppServerEffect(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)),
};
}

View file

@ -34,10 +34,6 @@ export interface ComposerContextReferenceProvider {
dispose(): void;
}
export function emptyComposerContextReferences(): ComposerContextReferences {
return { activeNote: null, selection: null, activeNoteSnapshots: [], selectionSnapshots: [] };
}
export function formatComposerContextRange(range: ComposerContextRange): string {
return `${formatComposerContextPosition(range.from)}-${formatComposerContextPosition(range.to)}`;
}

View file

@ -1,7 +0,0 @@
export interface DailyNoteReferenceCandidate {
keyword: "today" | "tomorrow" | "yesterday";
display: string;
name: string;
path: string;
linktext: string;
}

View file

@ -1,6 +1,7 @@
import type { SkillMetadata } from "../../../../domain/catalog/metadata";
import type { ComposerAttachment } from "./attachments";
import type { ActiveNoteContextReference, ComposerContextReferences, SelectionContextReference } from "./context-references";
import type { ThreadCommandTarget } from "./thread-title-argument";
export interface ComposerInputSnapshot {
readonly sourcePath: string;
@ -10,4 +11,5 @@ export interface ComposerInputSnapshot {
readonly activeNoteSnapshots: readonly ActiveNoteContextReference[];
readonly selectionSnapshots: readonly SelectionContextReference[];
readonly attachments: readonly ComposerAttachment[];
readonly threadCommandTarget?: ThreadCommandTarget;
}

View file

@ -1,6 +1,28 @@
import type { VaultFileReference } from "../../../../domain/chat/input";
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
import type { NoteCandidate } from "./suggestions";
export interface DailyNoteReferenceCandidate {
keyword: "today" | "tomorrow" | "yesterday";
display: string;
name: 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 {
candidates(sourcePath: string): readonly NoteCandidate[];

View file

@ -1,4 +1,4 @@
import { type ActivePanelOperation, activePanelOperationDecisionForFacts } from "../panel-operation-policy";
import type { ActivePanelOperation } from "../panel-operation-policy";
type SlashCommandArgsKind =
| "none"
@ -217,15 +217,6 @@ export function slashCommandRequiresConnection(command: SlashCommandName): boole
return !CONNECTION_INDEPENDENT_SLASH_COMMANDS.has(command);
}
export function slashCommandAvailableInSideChat(command: SlashCommandName): boolean {
const operation = activePanelOperationForSlashCommandSuggestion(command);
return (
!operation ||
activePanelOperationDecisionForFacts({ phase: "active", lifetime: "ephemeral", provenance: "interactive" }, operation).kind ===
"allowed"
);
}
/** Maps a parsed command to the active-panel operation it performs, if any. */
export function activePanelOperationForSlashCommand(command: SlashCommandName, args: string): ActivePanelOperation | null {
switch (command) {

View file

@ -10,6 +10,7 @@ import type { RuntimePermissionProfileSummary } from "../../../../domain/runtime
import { shortThreadId } from "../../../../domain/threads/id";
import type { Thread } from "../../../../domain/threads/model";
import { compareThreadSearchMatches, threadSearchMatches } from "../../../../domain/threads/search";
import { threadCommandDisplayTitle } from "../../../../domain/threads/title";
import {
type ActiveNoteContextReference,
activeNoteContextReferenceMarker,
@ -18,8 +19,15 @@ import {
type SelectionContextReference,
selectionContextReferenceMarker,
} 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 {
partialThreadTitleQuery,
quotedThreadTitleArgument,
THREAD_TITLE_COMMANDS,
type ThreadCommandTarget,
type ThreadTitleCommand,
} from "./thread-title-argument";
export interface ComposerSuggestion {
display: string;
@ -31,6 +39,7 @@ export interface ComposerSuggestion {
suffixOnInsert?: string;
activeNoteContext?: ActiveNoteContextReference;
selectionContext?: SelectionContextReference;
threadCommandTarget?: ThreadCommandTarget;
}
export interface ComposerSuggestionOptions {
@ -42,22 +51,6 @@ export interface ComposerSuggestionOptions {
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 {
file: NoteCandidate;
match: SearchResult;
@ -66,16 +59,12 @@ interface NoteCandidateMatch {
path: string;
}
const THREAD_SUGGESTION_COMMANDS = ["resume", "refer", "archive", "rename"] as const;
type ThreadSuggestionCommand = (typeof THREAD_SUGGESTION_COMMANDS)[number];
interface ThreadCommandSuggestionPolicy {
excludeActiveThread: boolean;
prioritizeActiveThreadForEmptyQuery: boolean;
}
const THREAD_COMMAND_SUGGESTION_POLICIES: Record<ThreadSuggestionCommand, ThreadCommandSuggestionPolicy> = {
const THREAD_COMMAND_SUGGESTION_POLICIES: Record<ThreadTitleCommand, ThreadCommandSuggestionPolicy> = {
resume: {
excludeActiveThread: true,
prioritizeActiveThreadForEmptyQuery: false,
@ -94,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;
export function parseSlashCommand(text: string): { command: SlashCommandName; args: string } | null {
@ -483,7 +472,6 @@ function activeThreadCommandSuggestions(
const { command, query, start } = completion;
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));
return threadSearchMatches(candidateThreads, query)
@ -492,13 +480,17 @@ function activeThreadCommandSuggestions(
return { ...match, activePriority };
})
.sort((a, b) => a.activePriority - b.activePriority || compareThreadSearchMatches(a, b))
.map(({ thread, title }) => ({
display: title,
detail: shortThreadId(thread.id),
replacement: thread.id,
start,
appendSpaceOnInsert: true,
}));
.map(({ thread }) => {
const title = threadCommandDisplayTitle(thread);
return {
display: title,
detail: shortThreadId(thread.id),
replacement: quotedThreadTitleArgument(title),
start,
appendSpaceOnInsert: true,
threadCommandTarget: { command, threadId: thread.id, title },
};
});
}
function shouldExcludeActiveThreadSuggestion(
@ -518,22 +510,20 @@ function activeThreadSuggestionPriority(
return policy.prioritizeActiveThreadForEmptyQuery && query.length === 0 && activeThreadId !== null && threadId === activeThreadId ? 0 : 1;
}
function activeThreadCommandCompletionQuery(
beforeCursor: string,
): { command: ThreadSuggestionCommand; query: string; start: number } | null {
function activeThreadCommandCompletionQuery(beforeCursor: string): { command: ThreadTitleCommand; query: string; start: number } | null {
const match = THREAD_SUGGESTION_COMMAND_PATTERN.exec(beforeCursor);
if (!match) return null;
const command = threadSuggestionCommand(match[1]);
const rawQuery = match[2];
if (!command || rawQuery === undefined) return null;
const query = rawQuery.trim().toLowerCase();
if (query.length > 0 && /\s$/.test(rawQuery)) return null;
const query = partialThreadTitleQuery(rawQuery);
if (query === null) return null;
return { command, query, start: beforeCursor.length - rawQuery.length };
}
function threadSuggestionCommand(value: string | undefined): ThreadSuggestionCommand | null {
return THREAD_SUGGESTION_COMMANDS.find((command) => command === value) ?? null;
function threadSuggestionCommand(value: string | undefined): ThreadTitleCommand | null {
return THREAD_TITLE_COMMANDS.find((command) => command === value) ?? null;
}
function modelOverrideSuggestions(beforeCursor: string, models: readonly ModelMetadata[]): ComposerSuggestion[] | null {

View file

@ -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;
}

View file

@ -8,6 +8,7 @@ import {
type SkillReference,
type VaultFileReference,
} from "../../../../domain/chat/input";
import { type MarkdownCodeRange, markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../domain/markdown/code-ranges";
import {
type ActiveNoteContextReference,
type ComposerContextReferences,
@ -43,10 +44,11 @@ interface PreparedComposerInputOptions {
referenceActiveNoteOnSend: boolean;
}
function parsedWikiLinks(text: string): ParsedWikiLink[] {
function parsedWikiLinks(text: string, codeRanges: readonly MarkdownCodeRange[]): ParsedWikiLink[] {
const links: ParsedWikiLink[] = [];
const seen = new Set<string>();
for (const match of text.matchAll(WIKILINK_PATTERN)) {
if (markdownCodeRangeContainsOffset(codeRanges, match.index)) continue;
const rawValue = match[1];
if (rawValue === undefined) continue;
const raw = rawValue.trim();
@ -67,14 +69,15 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
contextReferences: ComposerContextReferences,
options: PreparedComposerInputOptions,
): PreparedComposerInput {
const contextReplacement = textWithContextReferences(text, contextReferences);
const codeRanges = markdownReferenceCodeRanges(text);
const contextReplacement = textWithContextReferences(text, contextReferences, codeRanges);
const resolvedText = contextReplacement.text;
const fileReferences: VaultFileReference[] = [];
const wikilinkReferences: ObsidianReference[] = [];
const seenPaths = new Set<string>();
const activeNoteSnapshots = contextReferences.activeNoteSnapshots ?? [];
for (const link of parsedWikiLinks(resolvedText)) {
for (const link of parsedWikiLinks(resolvedText, codeRanges)) {
const fileReference = activeNoteFileReferenceForLink(link, activeNoteSnapshots) ?? resolveFileReference(link.target);
if (!fileReference || seenPaths.has(fileReference.path)) continue;
seenPaths.add(fileReference.path);
@ -94,7 +97,7 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
const skillByName = firstEnabledSkillByName(skills);
const resolvedSkills: SkillReference[] = [];
const seenSkillPaths = new Set<string>();
for (const reference of parsedSkillReferences(resolvedText)) {
for (const reference of parsedSkillReferences(resolvedText, codeRanges)) {
const skill = skillByName.get(reference.toLowerCase());
if (!skill || seenSkillPaths.has(skill.path)) continue;
seenSkillPaths.add(skill.path);
@ -120,19 +123,24 @@ function activeNoteFileReferenceForLink(link: ParsedWikiLink, snapshots: readonl
function textWithContextReferences(
text: string,
contextReferences: ComposerContextReferences,
codeRanges: readonly MarkdownCodeRange[],
): { text: string; selections: SelectionContextReference[] } {
return {
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 seen = new Set<string>();
for (const snapshot of snapshots) {
const marker = selectionContextReferenceMarker(snapshot);
if (!text.includes(marker) || seen.has(marker)) continue;
if (!includesOutsideMarkdownCode(text, marker, codeRanges) || seen.has(marker)) continue;
seen.add(marker);
selections.push(snapshot);
}
@ -171,13 +179,11 @@ function referenceKey(marker: string, path: string): string {
function obsidianContextAdditionalContext(references: readonly ObsidianReference[]): RequestAdditionalContext[] {
if (references.length === 0) return [];
const inlineExcerpts = references.filter((reference) => reference.excerpt !== undefined).length;
return [
{
key: OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY,
kind: "untrusted",
value: obsidianContextValue(references),
...(inlineExcerpts > 0 ? { attachment: { kind: "obsidian" as const, inlineExcerpts } } : {}),
},
];
}
@ -202,10 +208,12 @@ function excerptLines(references: readonly (ObsidianReference & { excerpt: strin
});
}
function parsedSkillReferences(text: string): string[] {
function parsedSkillReferences(text: string, codeRanges: readonly MarkdownCodeRange[]): string[] {
const references: string[] = [];
const seen = new Set<string>();
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];
if (name === undefined) continue;
const key = name.toLowerCase();
@ -216,6 +224,19 @@ function parsedSkillReferences(text: string): string[] {
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 {
const trimmed = raw.trim();
if (!trimmed) return null;

View file

@ -7,16 +7,16 @@ const STATUS_CONNECTION_STARTING = "Starting Codex app-server...";
const STATUS_CONNECTED = "Connected.";
const STATUS_CONNECTION_FAILED = "Connection failed.";
export interface ChatConnectionAdapter {
interface ChatConnectionAdapter {
connect(): Promise<ServerInitialization>;
isConnected(): boolean;
}
export interface ChatConnectionMetadataActions {
interface ChatConnectionMetadataActions {
refreshAppServerMetadata: () => Promise<unknown>;
}
export interface ChatConnectionDiagnosticsActions {
interface ChatConnectionDiagnosticsActions {
refreshServerDiagnostics: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise<void>;
}
@ -35,15 +35,14 @@ export interface ChatConnectionActionsHost {
setStatus: (statusText: string, phase?: ChatConnectionPhase) => void;
addSystemMessage: (text: string) => void;
configuredCommand: () => string;
refreshLiveState: () => void;
isStaleConnectionError: (error: unknown) => boolean;
isStaleResourceContextError: (error: unknown) => boolean;
isStaleRuntimeError: (error: unknown) => boolean;
notifyConnectionFailed: () => void;
}
type ChatConnectionExitHost = Pick<
ChatConnectionActionsHost,
"invalidateThreadWork" | "setStatus" | "stateStore" | "resetThreadTurnPresence" | "refreshLiveState"
"invalidateThreadWork" | "setStatus" | "stateStore" | "resetThreadTurnPresence"
>;
function handleChatConnectionExit(host: ChatConnectionExitHost): void {
@ -51,10 +50,10 @@ function handleChatConnectionExit(host: ChatConnectionExitHost): void {
host.setStatus(STATUS_CONNECTION_STOPPED, { kind: "disconnected", message: STATUS_CONNECTION_STOPPED });
host.stateStore.dispatch({ type: "connection/scoped-cleared" });
host.resetThreadTurnPresence(false);
host.refreshLiveState();
}
export interface ChatConnectionActions {
ensureInitialized(): Promise<void>;
ensureConnected(): Promise<void>;
invalidate(): void;
handleExit(): void;
@ -65,29 +64,40 @@ export interface ChatConnectionActions {
export function createChatConnectionActions(host: ChatConnectionActionsHost): ChatConnectionActions {
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 => {
generation += 1;
activeConnection = null;
};
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 = {
ensureInitialized: async () => {
if (!host.canConnect()) return;
if (activeConnection) return activeConnection.initialization;
if (host.connection.isConnected()) return;
await startConnection().initialization;
},
ensureConnected: async () => {
if (!host.canConnect()) return;
if (activeConnection) return activeConnection.promise;
if (activeConnection) return activeConnection.hydration;
if (host.connection.isConnected()) return;
const connectionGeneration = generation;
const promise = initializeConnection(host, () => isStale(connectionGeneration));
const active = { generation: connectionGeneration, promise };
activeConnection = active;
try {
await promise;
} finally {
if (activeConnection === active) {
activeConnection = null;
}
}
await startConnection().hydration;
},
invalidate,
handleExit: () => {
@ -107,7 +117,7 @@ async function refreshActiveThreads(host: ChatConnectionActionsHost): Promise<vo
await host.refreshSharedThreads();
host.refreshTabHeader();
} catch (error) {
if (host.isStaleResourceContextError(error)) return;
if (host.isStaleRuntimeError(error)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
@ -145,22 +155,20 @@ async function initializeConnection(host: ChatConnectionActionsHost, isStale: ()
} catch (error) {
if (isStale()) return;
if (host.isStaleConnectionError(error)) return;
if (host.isStaleResourceContextError(error)) return;
if (host.isStaleRuntimeError(error)) return;
const message = connectionErrorMessage(error, host.configuredCommand());
host.setStatus(STATUS_CONNECTION_FAILED, { kind: "failed", message });
host.addSystemMessage(message);
host.notifyConnectionFailed();
return;
}
await hydrateConnectedResources(host, isStale);
}
async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStale: () => boolean): Promise<void> {
try {
await host.metadata.refreshAppServerMetadata();
} catch (error) {
if (isStale() || host.isStaleResourceContextError(error)) return;
if (isStale() || host.isStaleRuntimeError(error)) return;
host.addSystemMessage(`Could not refresh Codex metadata: ${errorMessage(error)}`);
}
if (isStale()) return;
@ -168,7 +176,7 @@ async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStal
try {
await host.refreshSharedThreads();
} catch (error) {
if (isStale() || host.isStaleResourceContextError(error)) return;
if (isStale() || host.isStaleRuntimeError(error)) return;
host.addSystemMessage(`Could not refresh Codex threads: ${errorMessage(error)}`);
}
if (isStale()) return;

View file

@ -6,10 +6,7 @@ const STATUS_RECONNECTING = "Reconnecting...";
export interface ChatReconnectActionsHost {
stateStore: ChatStateStore;
invalidateConnectionWork: () => void;
invalidateThreadWork: () => void;
clearDeferredDiagnostics: () => void;
resetConnection: () => void;
resetConnectionScope: () => void;
setStatus: (statusText: string, phase?: ChatConnectionPhase) => void;
ensureConnected: () => Promise<void>;
isConnected: () => boolean;
@ -17,34 +14,35 @@ export interface ChatReconnectActionsHost {
addSystemMessage: (text: string) => void;
}
export async function reconnectPanel(
host: ChatReconnectActionsHost,
target: { resumeThreadId: string | null; isCurrent?: () => boolean } | null = null,
): Promise<boolean> {
export function createReconnectPanelAction(host: ChatReconnectActionsHost): () => Promise<boolean> {
let activeReconnect: Promise<boolean> | null = null;
return async () => {
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 panelTarget = capturePanelTargetLease(currentState);
const threadId = target
? target.resumeThreadId
: activeThreadState(currentState)?.lifetime?.kind === "ephemeral"
? null
: panelThreadId(currentState);
const isCurrent = target?.isCurrent ?? (() => true);
const threadId = activeThreadState(currentState)?.lifetime?.kind === "ephemeral" ? null : panelThreadId(currentState);
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
host.invalidateConnectionWork();
host.invalidateThreadWork();
host.clearDeferredDiagnostics();
host.resetConnection();
host.resetConnectionScope();
host.stateStore.dispatch({ type: "connection/scoped-cleared" });
host.setStatus(STATUS_RECONNECTING, { kind: "connecting" });
await host.ensureConnected();
if (!isCurrent() || !host.isConnected() || !panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
if (!host.isConnected() || !panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
if (!threadId) return true;
try {
if (!(await host.resumeThread(threadId))) return false;
if (!isCurrent() || !panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) {
return false;
}
if (!panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
return activeThreadId(host.stateStore.getState()) === threadId;
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));

View file

@ -19,7 +19,7 @@ export interface ServerMetadataActionsHost {
refreshAppServerMetadata: () => Promise<void>;
refreshSkills: () => Promise<void>;
refreshRateLimits: () => Promise<void>;
isStaleResourceContextError: (error: unknown) => boolean;
isStaleRuntimeError: (error: unknown) => boolean;
}
export interface ServerMetadataActions {
@ -106,7 +106,7 @@ async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promis
try {
await host.refreshAppServerMetadata();
} catch (error) {
if (host.isStaleResourceContextError(error)) return;
if (host.isStaleRuntimeError(error)) return;
throw error;
}
}
@ -115,7 +115,7 @@ async function refreshMetadataResource(host: ServerMetadataActionsHost, refresh:
try {
await refresh();
} catch (error) {
if (!host.isStaleResourceContextError(error)) throw error;
if (!host.isStaleRuntimeError(error)) throw error;
}
}

View file

@ -24,12 +24,13 @@ export type ActivePanelOperationDecision =
| { readonly kind: "blocked"; readonly message: string }
| { readonly kind: "resume-required" };
export type ActivePanelThreadFacts =
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;
};
@ -39,7 +40,7 @@ export function activePanelOperationDecision(state: ChatState, operation: Active
return activePanelOperationDecisionForFacts(activePanelThreadFacts(state), operation);
}
export function activePanelOperationDecisionForFacts(
function activePanelOperationDecisionForFacts(
facts: ActivePanelThreadFacts,
operation: ActivePanelOperation,
): ActivePanelOperationDecision {
@ -49,6 +50,13 @@ export function activePanelOperationDecisionForFacts(
}
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") {
@ -68,10 +76,15 @@ function activePanelThreadFacts(state: ChatState): ActivePanelThreadFacts {
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":

View file

@ -22,7 +22,6 @@ export interface PendingRequestActionsHost {
responder: PendingRequestResponder;
composerHasFocus: () => boolean;
focusComposer: () => void;
refreshLiveState: () => void;
}
export interface PendingRequestActions {
@ -121,6 +120,5 @@ function pendingUserInput(host: PendingRequestActionsHost, requestId: PendingReq
}
function commitRequestAction(host: PendingRequestActionsHost): void {
host.refreshLiveState();
host.focusComposer();
}

View file

@ -25,7 +25,6 @@ interface ResumedThreadActionParams {
interface ResumedThreadFromActiveRuntimeParams {
thread: Thread;
cwd: string;
runtime: Pick<
ActiveThreadRuntimeState,
| "model"
@ -48,7 +47,6 @@ interface ResumedThreadFromActiveRuntimeParams {
export interface ActiveThreadResumedAction extends RuntimePermissionState, RuntimePermissionKnownState {
type: "active-thread/resumed";
thread: Thread;
cwd: string;
model: string | null;
reasoningEffort: ReasoningEffort | null;
serviceTier: ServiceTier | null;
@ -67,7 +65,6 @@ export interface ActiveThreadResumedAction extends RuntimePermissionState, Runti
export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionState, RuntimePermissionKnownState {
type: "active-thread/settings-applied";
cwd: string;
model: string | null;
reasoningEffort: ReasoningEffort | null;
collaborationMode: CollaborationModeSelection;
@ -76,7 +73,6 @@ export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionStat
}
export interface ActiveThreadSettingsAppliedActionSettings extends RuntimePermissionState {
cwd: string;
model: string | null;
effort: string | null;
collaborationMode: { mode: CollaborationModeSelection };
@ -109,6 +105,10 @@ export interface ClearActiveThreadAction {
export interface ThreadListAppliedAction {
type: "thread-list/applied";
threads: readonly Thread[];
hasMore?: boolean;
isFetching?: boolean;
isFetchingNextPage?: boolean;
error?: string | null;
}
export interface DisclosureSetAction {
@ -140,7 +140,6 @@ export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromAc
return resumedThreadAction({
response: {
thread: params.thread,
cwd: params.cwd,
approvalPolicyKnown: params.runtime.approvalPolicyKnown,
sandboxPolicyKnown: params.runtime.sandboxPolicyKnown,
permissionProfileKnown: params.runtime.permissionProfileKnown,
@ -165,7 +164,6 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh
return {
type: "active-thread/resumed",
thread: response.thread,
cwd: response.cwd,
model: response.model,
reasoningEffort: response.reasoningEffort,
serviceTier: response.serviceTier,
@ -209,7 +207,6 @@ export function activeThreadSettingsAppliedAction(settings: ActiveThreadSettings
const permissions = runtimePermissionStateOrDefault(settings);
return {
type: "active-thread/settings-applied",
cwd: settings.cwd,
model: settings.model,
reasoningEffort: normalizeReasoningEffort(settings.effort),
collaborationMode: settings.collaborationMode.mode,

View file

@ -60,6 +60,14 @@ import type {
} from "./actions";
import { definedPatch, patchObject } from "./patch";
import type { ChatPendingSubmissionState, PendingSubmissionAction } from "./pending-submission";
import {
type ChatSubagentActivityState,
initialSubagentActivityState,
isSubagentActivityAction,
reduceSubagentActivitySlice,
type SubagentActivityAction,
subagentActivityWithParentTurn,
} from "./subagent-activity";
import {
type ChatThreadStreamState,
initialChatThreadStreamState,
@ -103,15 +111,19 @@ interface ChatConnectionState {
interface ChatThreadListState {
readonly listedThreads: readonly Thread[];
readonly hasMore: boolean;
readonly isFetching: boolean;
readonly isFetchingNextPage: boolean;
readonly error: string | null;
}
export interface ChatActiveThreadState {
readonly id: string;
readonly title?: string | null;
readonly cwd: string | null;
readonly goal: ThreadGoal | null;
readonly tokenUsage: ThreadTokenUsage | null;
readonly lifetime: ActiveThreadLifetime | null;
readonly canAcceptDirectInput: boolean | null;
readonly provenance: Thread["provenance"] | null;
}
@ -145,6 +157,7 @@ interface ChatStateShape {
runtime: ChatRuntimeState;
turn: ChatTurnState;
threadStream: ChatThreadStreamState;
subagentActivity: ChatSubagentActivityState;
pendingSubmission: ChatPendingSubmissionState | null;
requests: ChatRequestState;
composer: ChatComposerState;
@ -168,9 +181,7 @@ type ConnectionAction =
type ThreadListAction = ThreadListAppliedAction;
type ActiveThreadAction =
| { type: "active-thread/cwd-set"; cwd: string | null }
| { type: "active-thread/token-usage-set"; tokenUsage: ThreadTokenUsage | null };
type ActiveThreadAction = { type: "active-thread/token-usage-set"; tokenUsage: ThreadTokenUsage | null };
type RuntimeAction =
| { type: "runtime/model-requested"; model: string }
@ -275,6 +286,7 @@ type ChatSliceAction =
| RuntimeAction
| RequestAction
| ThreadStreamAction
| SubagentActivityAction
| ComposerAction
| UiAction;
@ -287,6 +299,7 @@ export function createChatState(): ChatState {
runtime: initialChatRuntimeState(),
turn: initialTurnState(),
threadStream: initialThreadStreamState(),
subagentActivity: initialSubagentActivityState(),
pendingSubmission: null,
requests: initialRequestState(),
composer: initialComposerState(),
@ -448,10 +461,10 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
thread: {
id: action.thread.id,
title: (action.thread.name ?? action.thread.preview) || null,
cwd: action.cwd,
goal: null,
tokenUsage: null,
lifetime: action.lifetime ?? { kind: "persistent" },
canAcceptDirectInput: action.thread.canAcceptDirectInput ?? null,
provenance: action.thread.provenance,
},
},
@ -486,13 +499,8 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
}
function reduceActiveThreadSettingsAppliedTransition(state: ChatState, action: ActiveThreadSettingsAppliedAction): ChatState {
const activeThread = activeThreadState(state);
if (!activeThread) return state;
if (!activeThreadState(state)) return state;
return patchChatState(state, {
panelThread: {
kind: "active",
thread: { ...activeThread, cwd: action.cwd },
},
runtime: {
...state.runtime,
active: {
@ -558,6 +566,7 @@ function reduceTurnStartedTransition(state: ChatState, action: TurnStartedAction
threadStream: action.items
? threadStreamWithActiveTurnItems(state.threadStream, action.turnId, action.items)
: threadStreamStartActiveSegment(state.threadStream, action.turnId, []),
subagentActivity: initialSubagentActivityState(action.turnId),
});
}
@ -567,6 +576,7 @@ function reduceTurnCompletedTransition(state: ChatState, action: TurnCompletedAc
return patchChatState(state, {
turn: { lifecycle },
threadStream: threadStreamWithItems(state.threadStream, action.items),
subagentActivity: initialSubagentActivityState(),
connection: { ...state.connection, statusText: `Turn ${action.status}.` },
});
}
@ -586,6 +596,7 @@ function reduceTurnOptimisticStartedTransition(state: ChatState, action: TurnOpt
turn: { lifecycle },
pendingSubmission: action.pendingSubmissionId ? null : state.pendingSubmission,
threadStream: threadStreamStartActiveSegment(state.threadStream, null, [action.item]),
subagentActivity: initialSubagentActivityState(),
});
}
@ -598,6 +609,7 @@ function reduceTurnStartAcknowledgedTransition(state: ChatState, action: TurnSta
return patchChatState(state, {
turn: { lifecycle },
threadStream: threadStreamWithActiveTurnItems(state.threadStream, action.turnId, action.items),
subagentActivity: subagentActivityWithParentTurn(state.subagentActivity, action.turnId),
});
}
@ -607,6 +619,7 @@ function reduceTurnStartFailedTransition(state: ChatState, action: TurnStartFail
return patchChatState(state, {
turn: { lifecycle },
threadStream: threadStreamWithItems(state.threadStream, action.items),
subagentActivity: initialSubagentActivityState(),
});
}
@ -640,6 +653,7 @@ function clearTurnScopedState(state: ChatState): ChatState {
lifecycle: transitionChatTurnLifecycleState(state.turn.lifecycle, { type: "cleared" }),
},
threadStream: threadStreamWithItems(state.threadStream, threadStreamItems(state.threadStream)),
subagentActivity: initialSubagentActivityState(),
requests: initialRequestState(),
ui: clearAllRequestDisclosures(state.ui),
});
@ -724,6 +738,9 @@ function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState
pendingSubmission: state.pendingSubmission,
requests: isRequestAction(action) ? reduceRequestSlice(state.requests, action) : state.requests,
threadStream: isThreadStreamAction(action) ? reduceThreadStreamSlice(state.threadStream, action) : state.threadStream,
subagentActivity: isSubagentActivityAction(action)
? reduceSubagentActivitySlice(state.subagentActivity, action)
: state.subagentActivity,
composer: reduceComposerSlice(state.composer, action),
ui: isUiAction(action) ? reduceUiSlice(state.ui, action) : state.ui,
});
@ -751,14 +768,18 @@ function reduceConnectionSlice(state: ChatConnectionState, action: ChatSliceActi
function reduceThreadListSlice(state: ChatThreadListState, action: ChatSliceAction): ChatThreadListState {
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 {
if (state.kind !== "active") return state;
switch (action.type) {
case "active-thread/cwd-set":
return patchObject(state, { thread: patchObject(state.thread, { cwd: action.cwd }) });
case "active-thread/token-usage-set":
return patchObject(state, { thread: patchObject(state.thread, { tokenUsage: action.tokenUsage }) });
default:
@ -854,7 +875,7 @@ function initialConnectionState(): ChatConnectionState {
}
function initialThreadListState(): ChatThreadListState {
return { listedThreads: [] };
return { listedThreads: [], hasMore: false, isFetching: false, isFetchingNextPage: false, error: null };
}
function initialPanelThreadState(): ChatPanelThreadState {
@ -878,10 +899,10 @@ function createActiveThreadState(id: string): ChatActiveThreadState {
return {
id,
title: null,
cwd: null,
goal: null,
tokenUsage: null,
lifetime: null,
canAcceptDirectInput: null,
provenance: null,
};
}

View 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;
}

View file

@ -5,21 +5,28 @@ import {
type ThreadRenameLifecycleEvent,
type ThreadRenameLifecycleState,
threadRenameGenerationStillActive,
threadRenameSaveStillActive,
transitionThreadRenameLifecycleState,
} from "../../../../domain/threads/rename-lifecycle";
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
import type { DisclosureSetAction } from "./actions";
import { patchObject } from "./patch";
export type ChatRenameUiState = { readonly kind: "idle" } | (ThreadRenameActiveState & { readonly threadId: string });
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
export type ChatRenameSavingUiState = Extract<ChatRenameUiState, { kind: "saving" }>;
type ChatRenameUiAction = Extract<
UiAction,
{
type:
| "ui/rename-started"
| "ui/rename-draft-updated"
| "ui/rename-auto-name-context-resolved"
| "ui/rename-cancelled"
| "ui/rename-save-started"
| "ui/rename-save-failed"
| "ui/rename-save-succeeded"
| "ui/rename-generation-started"
| "ui/rename-generation-succeeded"
| "ui/rename-generation-finished"
@ -71,7 +78,11 @@ export type UiAction =
| { type: "ui/archive-confirm-set"; threadId: string | null }
| { type: "ui/rename-started"; 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-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-succeeded"; threadId: string; generationToken: number; draft: string }
| { 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/rename-started":
case "ui/rename-draft-updated":
case "ui/rename-auto-name-context-resolved":
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-succeeded":
case "ui/rename-generation-finished":
@ -123,7 +138,11 @@ export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState
return patchObject(state, { archiveConfirmThreadId: action.threadId });
case "ui/rename-started":
case "ui/rename-draft-updated":
case "ui/rename-auto-name-context-resolved":
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-succeeded":
case "ui/rename-generation-finished":
@ -172,6 +191,10 @@ export function renameGenerationStillActive(
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 {
if (state.disclosures.approvalDetails.size === 0) return state;
return patchObject(state, {
@ -253,11 +276,23 @@ function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string)
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
switch (action.type) {
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":
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":
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":
return transitionScopedChatRenameUiState(state, action.threadId, {
type: "generation-started",
@ -304,12 +339,14 @@ function chatRenameLifecycleStateWithoutThreadId(state: ChatRenameUiState): Thre
function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState, { kind: "idle" }>): ThreadRenameActiveState {
switch (state.kind) {
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":
return {
kind: "generating",
draft: state.draft,
originalDraft: state.originalDraft,
autoName: state.autoName,
generationToken: state.generationToken,
};
}

View file

@ -17,13 +17,12 @@ export interface ThreadGoalSyncHost {
localItemIds: LocalIdSource;
addSystemMessage: (text: string) => void;
addGoalEvent: (item: GoalThreadStreamItem) => void;
refreshLiveState: () => void;
}
export interface GoalActionsHost extends ThreadGoalSyncHost {
goalTransport: ThreadGoalTransport;
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<ThreadStartOutcome>;
ensureRestoredThreadLoaded?: () => Promise<boolean>;
ensureRestoredThreadLoaded: () => Promise<boolean>;
}
interface GoalActionsContext extends GoalActionsHost {
@ -253,7 +252,6 @@ function applyGoalIfActive(
const item = options.reportChange ? goalChangeItem(host.localItemIds.next("goal"), activeThread.goal, goal) : null;
host.stateStore.dispatch({ type: "active-thread/goal-set", goal });
if (item) host.addGoalEvent(item);
host.refreshLiveState();
return true;
}
@ -271,7 +269,7 @@ async function prepareGoalMutation(host: GoalActionsContext): Promise<boolean> {
host.addSystemMessage(decision.message);
return false;
}
if (!host.ensureRestoredThreadLoaded || !(await host.ensureRestoredThreadLoaded())) 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);

View file

@ -3,7 +3,7 @@ import type { ThreadTitleContext } from "../../../../domain/threads/title-genera
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
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";
interface RenameEditState {
@ -16,7 +16,8 @@ export interface ThreadRenameEditorActionsHost {
ensureConnected: () => Promise<void>;
addSystemMessage: (text: string) => void;
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 {
@ -26,15 +27,22 @@ export interface ThreadRenameEditorActions {
start(threadId: string): void;
updateDraft(threadId: string, value: string): void;
cancel(threadId: string): void;
cancelAutoName(threadId: string): void;
save(threadId: string, value: string): Promise<void>;
autoNameDraft(threadId: string): Promise<void>;
}
export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions {
let nextRenameGenerationToken = 1;
let nextRenameSaveToken = 1;
let activeGeneration: { threadId: string; generationToken: number; controller: AbortController } | null = null;
let activeContextPreparation: { threadId: string } | null = null;
const action = {
invalidate(): void {
activeGeneration?.controller.abort();
activeGeneration = null;
activeContextPreparation = null;
dispatch(host, { type: "ui/rename-cleared" });
},
@ -52,9 +60,13 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
},
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);
if (!thread) return;
abortActiveGeneration();
dispatch(host, { type: "ui/rename-started", threadId, draft: threadRenameDraftTitle(thread) });
void prepareAutoName(threadId);
},
updateDraft(threadId: string, value: string): void {
@ -62,29 +74,38 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
},
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 });
},
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> {
const current = renameState(host);
if (current.kind === "idle" || current.threadId !== threadId || current.kind === "generating") return;
const editingState = current;
if (current.kind !== "editing" || current.threadId !== threadId) return;
const saveToken = nextRenameSaveToken;
dispatch(host, { type: "ui/rename-save-started", threadId, saveToken });
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
nextRenameSaveToken += 1;
try {
await host.ensureConnected();
if (renameState(host) !== editingState) return;
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
const result = await host.renameThread(threadId, value);
if (!result) {
if (renameState(host) === editingState) action.cancel(threadId);
return;
}
if (renameState(host) === editingState) {
dispatch(host, { type: "ui/rename-cleared" });
}
await host.renameThread(threadId, value);
dispatch(host, { type: "ui/rename-save-succeeded", threadId, saveToken });
} catch (error) {
if (renameState(host) !== editingState) return;
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
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);
if (current.kind !== "editing" || current.threadId !== threadId) return;
const editingState = current;
await host.ensureConnected();
if (renameState(host) !== editingState) return;
dispatch(host, {
type: "ui/rename-generation-started",
threadId,
generationToken: nextRenameGenerationToken,
});
const generationToken = nextRenameGenerationToken;
if (!renameGenerationStillActive(renameState(host), threadId, generationToken)) return;
const generating = renameState(host);
if (!renameGenerationStillActive(generating, threadId, generationToken)) return;
nextRenameGenerationToken += 1;
const controller = new AbortController();
activeGeneration = { threadId, generationToken, controller };
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 });
} catch (error) {
if (renameGenerationStillActive(renameState(host), threadId, generationToken)) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
} finally {
clearGeneration(generationToken);
finishAutoNameDraftGeneration(host, threadId, generationToken);
}
},
};
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 {

View file

@ -1,4 +1,5 @@
import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics";
import type { Thread } from "../../../../domain/threads/model";
import { effectCompletedInCurrentContext } from "../effect-outcome";
import { resumedThreadAction } from "../state/actions";
import { capturePanelTargetLease, type PanelTargetLease, panelTargetLeaseIsCurrent } from "../state/panel-target";
@ -18,8 +19,8 @@ export interface ResumeActionsHost {
closing: () => boolean;
resetThreadTurnPresence: (hadTurns: boolean) => void;
notifyActiveThreadIdentityChanged: () => void;
recordResumedThread: (thread: Thread) => void;
addSystemMessage: (text: string) => void;
refreshLiveState: () => void;
syncThreadGoal: (threadId: string) => Promise<void>;
recoverTokenUsageFromRollout?: (path: string) => Promise<ThreadTokenUsage | null>;
}
@ -63,6 +64,7 @@ async function resumeThread(
const adoptedPanelTarget = applyResumedThread(host, effect.value, initialPanelTarget.revision);
if (!adoptedPanelTarget) return false;
currentPanelTarget = adoptedPanelTarget;
host.recordResumedThread(effect.value.activation.thread);
options?.onAdopted?.();
recoverResumedThreadTokenUsage(host, effect.value.activation.thread.id, effect.value.rolloutPath, resume, adoptedPanelTarget);
if (effect.value.initialHistoryPage) {
@ -77,7 +79,6 @@ async function resumeThread(
if (renderFallbackMessage) {
host.addSystemMessage(`Resumed thread ${effect.value.activation.thread.id}`);
}
host.refreshLiveState();
return true;
} catch (error) {
if (isStaleResume(host, resume, currentPanelTarget)) return false;
@ -94,7 +95,6 @@ function applyResumedThread(
const state = host.stateStore.dispatch(
resumedThreadAction({
response: response.activation,
listedThreads: host.stateStore.getState().threadList.listedThreads,
expectedPanelTargetRevision,
}),
);
@ -120,7 +120,6 @@ function recoverResumedThreadTokenUsage(
const activeThread = activeThreadState(state);
if (!activeThread || activeThread.id !== threadId || activeThread.tokenUsage !== null) return;
host.stateStore.dispatch({ type: "active-thread/token-usage-set", tokenUsage });
host.refreshLiveState();
})
.catch(() => undefined);
}

View file

@ -2,9 +2,8 @@ import { inheritedForkThreadName, type Thread } from "../../../../domain/threads
import { activeThreadRuntimeState } from "../../domain/runtime/state";
import { effectCompleted, effectCompletedInCurrentContext } from "../effect-outcome";
import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy";
import { resumedThreadActionFromActiveRuntime } from "../state/actions";
import { capturePanelTargetLease, type PanelTargetLease, panelTargetLeaseIsCurrent } from "../state/panel-target";
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
import { activeThreadId, type ChatState } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import { threadStreamRollbackCandidate, threadStreamTurnsAfterTurnId } from "../state/thread-stream";
import { chatTurnBusy } from "../turns/turn-state";
@ -23,10 +22,21 @@ export interface ThreadManagementActionsHost {
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<void>;
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
notifyActiveThreadIdentityChanged: () => void;
refreshAfterThreadMutation: () => Promise<void>;
recordForkedThread: (thread: Thread) => void;
openThreadInCurrentPanel: (threadId: string, onAdopted: () => void) => Promise<CurrentPanelAdoption>;
beginThreadForkPublication: (sourceThreadId: string) => ThreadForkPublication;
threadHasPendingOrRunningPanel: (threadId: string) => boolean;
}
type CurrentPanelAdoption =
| { readonly adopted: false }
| {
readonly adopted: true;
readonly activityPublication: { publish(commit: () => void): void };
};
interface ThreadForkPublication {
record(thread: Thread): void;
finish(options?: { sourceArchived?: boolean }): void;
}
interface ThreadManagementOperations {
@ -94,6 +104,10 @@ async function archiveThread(host: ThreadManagementActionsHost, threadId: string
}
async function archiveThreadFromPanel(host: ThreadManagementActionsHost, threadId: string, saveMarkdown?: boolean): Promise<boolean> {
if (host.threadHasPendingOrRunningPanel(threadId)) {
host.addSystemMessage("Finish or interrupt the thread before archiving it.");
return false;
}
if (chatTurnBusy(threadManagementState(host))) {
host.addSystemMessage("Finish or interrupt the current turn before archiving threads.");
return false;
@ -129,16 +143,22 @@ async function forkThreadFromTurn(
host.addSystemMessage("Could not find the selected turn to fork.");
return;
}
let publication: ThreadForkPublication | null = null;
let publicationFinished = false;
let activityPublication: { publish(commit: () => void): void } | null = null;
try {
const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads);
if (!(await host.threadTransport.ensureConnected())) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
const effect = turnId ? await host.threadTransport.forkThread(threadId, turnId) : await host.threadTransport.forkThread(threadId);
publication = host.beginThreadForkPublication(threadId);
const effect = turnId
? await host.threadTransport.forkThread(threadId, { position: { kind: "through-turn", turnId } })
: await host.threadTransport.forkThread(threadId);
if (!effectCompleted(effect)) return;
const forkedThread = effect.value;
const forkedThreadId = forkedThread.id;
host.recordForkedThread(forkedThread);
publication.record(forkedThread);
if (!effectCompletedInCurrentContext(effect)) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
if (sourceName) {
@ -152,17 +172,32 @@ async function forkThreadFromTurn(
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
}
if (archiveSource) {
if (!(await archiveThreadFromPanel(host, threadId))) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
let adoption: CurrentPanelAdoption;
try {
await host.openThreadInCurrentPanel(forkedThreadId);
adoption = await host.openThreadInCurrentPanel(forkedThreadId, () => undefined);
} catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
const message = error instanceof Error ? error.message : String(error);
host.addSystemMessage(`Archived thread ${threadId}, but could not open forked thread ${forkedThreadId}: ${message}`);
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in the current panel: ${message}`);
return;
}
if (!adoption.adopted) {
if (threadManagementScopeStillTargetsOriginalPanel(host, scope)) {
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in the current panel.`);
}
return;
}
activityPublication = adoption.activityPublication;
const sourceArchived = await archiveReplacedSource(host, threadId, forkedThreadId, {
failureMessage: "Forked the thread, but could not archive the previous version",
});
activityPublication.publish(() => publication?.finish({ sourceArchived }));
activityPublication = null;
publicationFinished = true;
return;
}
publication.finish();
publicationFinished = true;
try {
await host.openThreadInNewView(forkedThreadId);
} catch (error) {
@ -173,6 +208,11 @@ async function forkThreadFromTurn(
} catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
} finally {
if (!publicationFinished) {
if (activityPublication) activityPublication.publish(() => publication?.finish());
else publication?.finish();
}
}
}
@ -200,50 +240,99 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
host.addSystemMessage("No completed turn to roll back.");
return;
}
const runtime = activeThreadRuntimeState(threadManagementState(host).runtime);
const runtimeOverrides = {
...(runtime.model ? { model: runtime.model } : {}),
reasoningEffort: runtime.reasoningEffort,
...(runtime.serviceTierKnown ? { serviceTier: runtime.serviceTier } : {}),
...(runtime.approvalPolicyKnown && runtime.approvalPolicy ? { approvalPolicy: runtime.approvalPolicy } : {}),
...(runtime.approvalsReviewer ? { approvalsReviewer: runtime.approvalsReviewer } : {}),
...(runtime.permissionProfileKnown && runtime.activePermissionProfile
? { permissions: runtime.activePermissionProfile.id }
: runtime.sandboxPolicyKnown && runtime.sandboxPolicy
? { sandboxPolicy: runtime.sandboxPolicy }
: {}),
};
let publication: ThreadForkPublication | null = null;
let publicationFinished = false;
let activityPublication: { publish(commit: () => void): void } | null = null;
try {
host.setStatus(STATUS_ROLLBACK_STARTING);
if (!(await host.threadTransport.ensureConnected())) return;
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
const effect = await host.threadTransport.rollbackThread(threadId);
if (!effectCompleted(effect)) return;
if (!effectCompletedInCurrentContext(effect)) {
await host.refreshAfterThreadMutation();
return;
}
const snapshot = effect.value;
if (!threadManagementScopeStillTargetsPanel(host, scope)) {
await host.refreshAfterThreadMutation();
return;
}
threadManagementDispatch(
host,
resumedThreadActionFromActiveRuntime({
thread: snapshot.thread,
cwd: snapshot.cwd,
runtime: activeThreadRuntimeState(threadManagementState(host).runtime),
listedThreads: threadManagementState(host).threadList.listedThreads,
expectedPanelTargetRevision: scope.panelTarget.revision,
}),
);
threadManagementDispatch(host, {
type: "thread-stream/items-replaced",
items: snapshot.items,
historyCursor: null,
loadingHistory: false,
publication = host.beginThreadForkPublication(threadId);
const effect = await host.threadTransport.forkThread(threadId, {
position: { kind: "before-turn", turnId: candidate.turnId },
deferGoalContinuation: true,
runtime: runtimeOverrides,
});
host.setComposerText(candidate.text);
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
host.setStatus(STATUS_ROLLBACK_COMPLETE);
host.notifyActiveThreadIdentityChanged();
await host.refreshAfterThreadMutation();
if (!effectCompleted(effect)) return;
const forkedThread = effect.value;
if (effectCompletedInCurrentContext(effect)) publication.record(forkedThread);
else return;
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
const adoption = await host.openThreadInCurrentPanel(forkedThread.id, () => {
host.setComposerText(candidate.text);
});
if (!adoption.adopted) {
if (threadManagementScopeStillTargetsPanel(host, scope)) {
host.addSystemMessage("The rolled-back version was created but could not be opened in this panel. Open it from thread history.");
host.setStatus(STATUS_ROLLBACK_FAILED);
}
return;
}
activityPublication = adoption.activityPublication;
if (activeThreadId(threadManagementState(host)) === forkedThread.id) {
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
host.setStatus(STATUS_ROLLBACK_COMPLETE);
}
const sourceArchived = await archiveReplacedSource(host, threadId, forkedThread.id, {
saveMarkdown: false,
failureMessage: "Rolled back the latest turn, but could not archive the previous version",
});
activityPublication.publish(() => publication?.finish({ sourceArchived }));
activityPublication = null;
publicationFinished = true;
} catch (error) {
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
host.setStatus(STATUS_ROLLBACK_FAILED);
} finally {
if (!publicationFinished) {
if (activityPublication) activityPublication.publish(() => publication?.finish());
else publication?.finish();
}
}
}
async function archiveReplacedSource(
host: ThreadManagementActionsHost,
sourceThreadId: string,
replacementThreadId: string,
options: { readonly saveMarkdown?: boolean; readonly failureMessage: string },
): Promise<boolean> {
try {
const archiveOptions = options.saveMarkdown === undefined ? {} : { saveMarkdown: options.saveMarkdown };
if (await host.operations.archiveThread(sourceThreadId, archiveOptions)) return true;
reportReplacementArchiveFailure(host, replacementThreadId, options.failureMessage, "archive was not completed");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
reportReplacementArchiveFailure(host, replacementThreadId, options.failureMessage, message);
}
return false;
}
function reportReplacementArchiveFailure(
host: ThreadManagementActionsHost,
replacementThreadId: string,
failureMessage: string,
detail: string,
): void {
if (activeThreadId(threadManagementState(host)) !== replacementThreadId) return;
host.addSystemMessage(`${failureMessage}: ${detail}`);
}
function activePanelOperationBlocked(host: ThreadManagementActionsHost, threadId: string, operation: ActivePanelOperation): boolean {
const state = threadManagementState(host);
if (activeThreadId(state) !== threadId) return false;
@ -257,10 +346,6 @@ function threadManagementState(host: ThreadManagementActionsHost): ChatState {
return host.stateStore.getState();
}
function threadManagementDispatch(host: ThreadManagementActionsHost, action: ChatAction): void {
host.stateStore.dispatch(action);
}
function captureThreadManagementPanelScope(host: ThreadManagementActionsHost, targetThreadId: string): ThreadManagementPanelScope {
return {
targetThreadId,

View file

@ -1,16 +1,31 @@
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import type { RuntimeApprovalPolicy, RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions";
import type { ApprovalsReviewer, ServiceTier } from "../../../../domain/runtime/policy";
import type { Thread } from "../../../../domain/threads/model";
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
import type { EffectOutcome } from "../effect-outcome";
export interface ThreadRollbackSnapshot {
thread: Thread;
cwd: string;
items: ThreadStreamItem[];
type ThreadForkPosition =
| { readonly kind: "through-turn"; readonly turnId: string }
| { readonly kind: "before-turn"; readonly turnId: string };
interface ThreadForkOptions {
readonly position?: ThreadForkPosition;
readonly deferGoalContinuation?: boolean;
readonly runtime?: ThreadForkRuntimeOverrides;
}
interface ThreadForkRuntimeOverrides {
readonly model?: string;
readonly reasoningEffort?: ReasoningEffort | null;
readonly serviceTier?: ServiceTier | null;
readonly approvalPolicy?: RuntimeApprovalPolicy;
readonly approvalsReviewer?: ApprovalsReviewer;
readonly permissions?: string;
readonly sandboxPolicy?: RuntimeSandboxPolicy;
}
export interface ThreadMutationTransport {
ensureConnected(): Promise<boolean>;
compactThread(threadId: string): Promise<EffectOutcome<void>>;
forkThread(threadId: string, lastTurnId?: string | null): Promise<EffectOutcome<Thread>>;
rollbackThread(threadId: string): Promise<EffectOutcome<ThreadRollbackSnapshot>>;
forkThread(threadId: string, options?: ThreadForkOptions): Promise<EffectOutcome<Thread>>;
}

View file

@ -12,6 +12,7 @@ export interface ThreadNavigationActionsHost {
identity: ActiveThreadIdentitySync;
closeForThreadSelection: () => void;
focusThreadInOpenView: (threadId: string) => Promise<boolean>;
openThreadFromHistory: (threadId: string, originSwitchable: boolean) => Promise<void>;
resumeThread: (threadId: string, intent: ActiveChatResume, options?: ResumeThreadOptions) => Promise<boolean>;
resumeWork: ChatResumeWorkTracker;
addSystemMessage: (text: string) => void;
@ -65,9 +66,10 @@ export function createThreadNavigationActions(host: ThreadNavigationActionsHost)
},
selectThread,
async selectThreadFromToolbar(threadId) {
if (!canSwitchToThread(host.stateStore.getState(), threadId)) return;
const originSwitchable = canSwitchToThread(host.stateStore.getState(), threadId);
host.closeForThreadSelection();
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
await selectThread(threadId);
await host.openThreadFromHistory(threadId, originSwitchable);
},
};
}

View file

@ -21,7 +21,15 @@ export interface ComposerSubmitActionsHost {
composer: {
readonly draft: string;
readonly trimmedDraft: string;
setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean; preserveContext?: boolean }): void;
setDraft(
text: string,
options?: {
clearSuggestions?: boolean;
focus?: boolean;
preserveContext?: boolean;
threadCommandTarget?: ComposerInputSnapshot["threadCommandTarget"] | null;
},
): void;
captureInputSnapshot(): ComposerInputSnapshot;
};
slashCommandExecutor: {
@ -69,12 +77,12 @@ export async function submitComposer(host: ComposerSubmitActionsHost): Promise<v
const state = submissionStateSnapshot(chatState);
if (host.stateStore.getState().pendingSubmission) return;
const operationDecision = activePanelOperationDecision(chatState, "submit");
if (operationDecision.kind === "blocked") {
host.status.addSystemMessage(operationDecision.message);
if (state.busy && state.activeThreadId && state.activeTurnId && (draft.length === 0 || operationDecision.kind === "blocked")) {
await interruptTurn(host, panelTarget);
return;
}
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
await interruptTurn(host, panelTarget);
if (operationDecision.kind === "blocked") {
host.status.addSystemMessage(operationDecision.message);
return;
}
await sendMessage(host, draft, originalDraft, panelTarget);
@ -125,7 +133,6 @@ async function sendMessage(
text: result.sendText,
inputSnapshot,
...(result.sendInput !== undefined ? { codexInputOverride: result.sendInput } : {}),
...(result.referencedThread !== undefined ? { referencedThread: result.referencedThread } : {}),
...(result.sendInput !== undefined ? { preserveComposerContextOnFailure: true } : {}),
...(pendingWeb ? { pendingSubmissionId: pendingWeb.id, failureDraft: originalDraft } : {}),
});
@ -133,7 +140,11 @@ async function sendMessage(
if (pendingWeb) {
if (pendingWebSubmissionIsCurrent(host, pendingWeb.id)) rollbackPendingWebSubmission(host, pendingWeb.id, originalDraft);
} else {
host.composer.setDraft(originalDraft, { focus: true, clearSuggestions: true });
host.composer.setDraft(originalDraft, {
focus: true,
clearSuggestions: true,
...(inputSnapshot.threadCommandTarget ? { threadCommandTarget: inputSnapshot.threadCommandTarget } : {}),
});
}
}
}
@ -171,7 +182,11 @@ async function executeSlashCommandAndRestoreOnFailure(
if (!isCurrent()) return { failed: true };
if (pendingWebSubmissionId && !pendingWebSubmissionIsCurrent(host, pendingWebSubmissionId)) return { failed: true };
if (pendingWebSubmissionId) cancelPendingWebSubmission(host, pendingWebSubmissionId);
host.composer.setDraft(originalText, { focus: true, clearSuggestions: true });
host.composer.setDraft(originalText, {
focus: true,
clearSuggestions: true,
...(inputSnapshot.threadCommandTarget ? { threadCommandTarget: inputSnapshot.threadCommandTarget } : {}),
});
host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
return { failed: true };
}

View file

@ -15,7 +15,6 @@ interface LocalUserDialogueParams {
text: string;
copyText?: string;
turnId?: string;
referencedThread?: ThreadStreamDialogueItem["referencedThread"];
referencedFiles?: readonly ThreadStreamFileReference[];
contextAttachments?: ThreadStreamDialogueItem["contextAttachments"];
}
@ -64,7 +63,6 @@ function localUserDialogueItem(params: LocalUserDialogueParams): ThreadStreamDia
provenance: localUserDialogueProvenance(params.clientId ?? params.id, params.interaction),
...(params.clientId ? { clientId: params.clientId } : {}),
...(params.turnId ? { turnId: params.turnId } : {}),
...(params.referencedThread ? { referencedThread: params.referencedThread } : {}),
...(referencedFiles.length > 0 ? { referencedFiles: [...referencedFiles] } : {}),
...(contextAttachments.length > 0 ? { contextAttachments: [...contextAttachments] } : {}),
};
@ -87,7 +85,6 @@ export function localUserDialogueItemFromInput(params: LocalUserDialogueFromInpu
text: userMessageDisplayText(params.text, params.codexInput),
copyText: params.text,
...(params.turnId ? { turnId: params.turnId } : {}),
...(params.referencedThread ? { referencedThread: params.referencedThread } : {}),
referencedFiles: fileReferencesFromInput([...params.codexInput]),
contextAttachments: contextAttachmentsFromInput(params.codexInput),
});

View file

@ -24,7 +24,7 @@ interface PlanImplementationState {
threadStream: Pick<ChatThreadStreamState, "stableItems" | "activeSegment">;
}
export function implementPlanTargetFromState(state: ChatState): PlanImplementationTarget | null {
function implementPlanTargetFromState(state: ChatState): PlanImplementationTarget | null {
return implementPlanTarget({
activeThread: activeThreadState(state),
modeAllowed: activePanelOperationDecision(state, "implement-plan").kind === "allowed",

View file

@ -6,14 +6,12 @@ import { threadStreamItems } from "../state/thread-stream";
import type { TurnRuntimeEvent } from "./runtime-events";
import { activeTurnId, pendingTurnStart as pendingTurnStartForState } from "./turn-state";
export type TurnRuntimeOutcome =
| { type: "turn-started"; threadId: string; turnId: string; recencyAt: number | null }
| {
type: "turn-completed";
threadId: string;
turnId: string;
completedTurnTranscriptSummary: TurnRuntimeEventCompletedTurnTranscriptSummary;
};
export interface TurnRuntimeOutcome {
type: "turn-completed";
threadId: string;
turnId: string;
completedTurnTranscriptSummary: TurnRuntimeEventCompletedTurnTranscriptSummary;
}
type TurnRuntimeEventCompletedTurnTranscriptSummary = Extract<
TurnRuntimeEvent,
@ -116,14 +114,7 @@ function turnStartedPlan(state: ChatState, event: Extract<TurnRuntimeEvent, { ty
items: threadStreamItemsWithPendingPromptSubmitHooks(state, event.turnId),
},
],
outcomes: [
{
type: "turn-started",
threadId: event.threadId,
turnId: event.turnId,
recencyAt: event.recencyAt,
},
],
outcomes: [],
};
}

View file

@ -59,7 +59,6 @@ export type TurnRuntimeEvent =
type: "turnStarted";
threadId: string;
turnId: string;
recencyAt: number | null;
}
| {
type: "turnCompleted";

View file

@ -4,7 +4,6 @@ import type { CodexInput } from "../../../../domain/chat/input";
import type { ThreadGoal } from "../../../../domain/threads/goal";
import { shortThreadId } from "../../../../domain/threads/id";
import type { Thread } from "../../../../domain/threads/model";
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
import { resolveThreadSearchQuery } from "../../../../domain/threads/search";
import { threadDisplayTitle } from "../../../../domain/threads/title";
import { modelOverrideMessage, permissionProfileOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels";
@ -18,6 +17,7 @@ import {
slashCommandSubcommandDefinition,
slashCommandSubcommands,
} from "../composer/slash-commands";
import { parseThreadTitleArgument, type ThreadCommandTarget, type ThreadTitleCommand } from "../composer/thread-title-argument";
import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions";
import type { GoalActions } from "../threads/goal-actions";
import type { ThreadManagementActions } from "../threads/thread-management-actions";
@ -67,6 +67,7 @@ export interface SlashCommandExecutionPorts {
export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts {
activeThreadId: string | null;
listedThreads: readonly Thread[];
threadCommandTarget?: ThreadCommandTarget;
referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ThreadReferenceInput | null>;
readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot, isCurrent?: () => boolean) => Promise<WebUrlInput>;
supportedReasoningEfforts: () => readonly ReasoningEffort[];
@ -77,14 +78,12 @@ export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts
export interface SlashCommandExecutionResult {
sendText?: string;
sendInput?: CodexInput;
referencedThread?: ReferencedThreadMetadata;
composerDraft?: string;
}
export interface ThreadReferenceInput {
text: string;
input: CodexInput;
referencedThread: ReferencedThreadMetadata;
}
export interface WebUrlInput {
@ -108,7 +107,12 @@ export async function executeSlashCommand(
await context.startNewThread();
return;
case "resume": {
const thread = resolveThreadArgument(args, context.listedThreads);
const query = parseThreadOnlyArgs(args, { allowEmpty: true });
if (query === null) {
context.addSystemMessage(usageError(command, "requires a quoted title when the title contains spaces"));
return;
}
const thread = resolveThreadArgument(command, query, context.listedThreads, context.threadCommandTarget);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
@ -125,7 +129,7 @@ export async function executeSlashCommand(
context.addSystemMessage(usageError(command, "requires a thread and a message"));
return;
}
const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads, {
const thread = resolveThreadArgument(command, parsed.threadQuery, context.listedThreads, context.threadCommandTarget, {
excludedThreadId: context.activeThreadId,
allowExactExcludedThread: true,
});
@ -143,7 +147,7 @@ export async function executeSlashCommand(
}
const reference = await context.referThread(thread.thread, parsed.message, context.inputSnapshot);
if (!reference) return;
return { sendText: reference.text, sendInput: reference.input, referencedThread: reference.referencedThread };
return { sendText: reference.text, sendInput: reference.input };
}
case "web": {
const parsed = parseWebCommandArgs(args);
@ -193,7 +197,12 @@ export async function executeSlashCommand(
await context.threadActions.compactThread(context.activeThreadId);
return;
case "archive": {
const thread = resolveThreadArgument(args, context.listedThreads);
const query = parseThreadOnlyArgs(args);
if (query === null) {
context.addSystemMessage(usageError(command, "requires one thread title"));
return;
}
const thread = resolveThreadArgument(command, query, context.listedThreads, context.threadCommandTarget);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
@ -207,7 +216,7 @@ export async function executeSlashCommand(
context.addSystemMessage(usageError(command, "requires a thread and a name"));
return;
}
const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads);
const thread = resolveThreadArgument(command, parsed.threadQuery, context.listedThreads, context.threadCommandTarget);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
@ -478,11 +487,9 @@ function parseReferArgs(args: string): { threadQuery: string; message: string }
}
function parseThreadAndTextArgs(args: string): { threadQuery: string; text: string } | null {
const match = /^(\S+)\s+([\s\S]*\S)\s*$/.exec(args);
if (!match) return null;
const threadQuery = match[1];
const text = match[2];
return threadQuery !== undefined && text !== undefined ? { threadQuery, text } : null;
const parsed = parseThreadTitleArgument(args);
const text = parsed?.rest.trim();
return parsed?.title.trim() && text ? { threadQuery: parsed.title, text } : null;
}
function parseThreadAndNameArgs(args: string): { threadQuery: string; text: string } | null {
@ -500,28 +507,45 @@ export function parseWebCommandArgs(args: string): { url: string; message: strin
return url !== undefined ? { url, message } : null;
}
function resolveThreadArgument(args: string, threads: readonly Thread[], options: ThreadResolutionOptions = {}): ThreadResolution {
function parseThreadOnlyArgs(args: string, options: { allowEmpty?: boolean } = {}): string | null {
if (!args.trim()) return options.allowEmpty ? "" : null;
const parsed = parseThreadTitleArgument(args);
return parsed?.title.trim() && !parsed.rest ? parsed.title : null;
}
function resolveThreadArgument(
command: ThreadTitleCommand,
args: string,
threads: readonly Thread[],
completedTarget: ThreadCommandTarget | undefined,
options: ThreadResolutionOptions = {},
): ThreadResolution {
const query = args.trim();
const exactExcludedThread = options.allowExactExcludedThread
? threads.find((thread) => thread.id === options.excludedThreadId && threadIdMatchesExactly(thread.id, query))
: null;
if (exactExcludedThread) return { ok: true, thread: exactExcludedThread };
if (completedTarget?.command === command && completedTarget.title === query) {
const completedThread = threads.find((thread) => thread.id === completedTarget.threadId);
return completedThread
? { ok: true, thread: completedThread }
: { ok: false, message: `Completed thread is no longer available: ${completedTarget.title}` };
}
const searchThreads = options.excludedThreadId ? threads.filter((thread) => thread.id !== options.excludedThreadId) : threads;
const resolution = resolveThreadSearchQuery(searchThreads, query);
if (resolution.kind === "match") return { ok: true, thread: resolution.match.thread };
if (resolution.kind === "multiple") {
const matches = resolution.matches.map((match) => threadResolutionLabel(match.thread)).join(", ");
return { ok: false, message: `Multiple matching threads: ${matches}` };
}
if (resolution.kind === "multiple") return multipleThreadResolution(resolution.matches.map((match) => match.thread));
const exactExcludedThread = options.allowExactExcludedThread
? threads.find(
(thread) => thread.id === options.excludedThreadId && threadDisplayTitle(thread).trim().toLowerCase() === query.toLowerCase(),
)
: null;
if (exactExcludedThread) return { ok: true, thread: exactExcludedThread };
return { ok: false, message: query ? `No matching thread: ${query}` : "No recent threads to resume." };
}
function multipleThreadResolution(threads: readonly Thread[]): ThreadResolution {
const matches = threads.map(threadResolutionLabel).join(", ");
return { ok: false, message: `Multiple matching threads: ${matches}` };
}
function threadResolutionLabel(thread: Thread): string {
return `${threadDisplayTitle(thread)} (${shortThreadId(thread.id)})`;
}
function threadIdMatchesExactly(threadId: string, query: string): boolean {
const normalizedQuery = query.trim().toLowerCase();
return threadId.toLowerCase() === normalizedQuery || shortThreadId(threadId).toLowerCase() === normalizedQuery;
}

View file

@ -50,6 +50,7 @@ export async function executeSlashCommandWithState(
},
activeThreadId: state.activeThreadId,
listedThreads: state.listedThreads,
...(inputSnapshot?.threadCommandTarget ? { threadCommandTarget: inputSnapshot.threadCommandTarget } : {}),
referThread: host.referThread,
readWebUrl: host.readWebUrl,
...(inputSnapshot !== undefined ? { inputSnapshot } : {}),

View file

@ -1,5 +1,4 @@
import { type CodexInput, codexTextInput } from "../../../../domain/chat/input";
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
import type { ComposerInputSnapshot } from "../composer/input-snapshot";
import type { LocalIdSource } from "../local-id-source";
import { activePanelOperationDecision } from "../panel-operation-policy";
@ -30,7 +29,15 @@ export interface TurnSubmissionActionsHost {
resetThreadTurnPresence: (hadTurns: boolean) => void;
applyPendingThreadSettings: () => Promise<boolean>;
prepareInput: (text: string, snapshot: ComposerInputSnapshot) => { text: string; input: CodexInput };
setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean; preserveContext?: boolean }) => void;
setDraft: (
text: string,
options?: {
focus?: boolean;
clearSuggestions?: boolean;
preserveContext?: boolean;
threadCommandTarget?: ComposerInputSnapshot["threadCommandTarget"] | null;
},
) => void;
setStatus: (status: string) => void;
addSystemMessage: (text: string) => void;
}
@ -51,7 +58,6 @@ export interface TurnSubmissionRequest {
text: string;
inputSnapshot?: ComposerInputSnapshot;
codexInputOverride?: CodexInput;
referencedThread?: ReferencedThreadMetadata;
preserveComposerContextOnFailure?: boolean;
pendingSubmissionId?: string;
failureDraft?: string;
@ -77,7 +83,7 @@ async function sendTurnText(
localItemIds: LocalIdSource,
request: TurnSubmissionRequest,
): Promise<boolean> {
const { text, inputSnapshot, codexInputOverride, referencedThread } = request;
const { text, inputSnapshot, codexInputOverride } = request;
let panelTarget = capturePanelTargetLease(host.stateStore.getState());
const prepared = codexInputOverride
? { text, input: codexInputOverride }
@ -107,7 +113,7 @@ async function sendTurnText(
if (pendingRequestIsCurrent(host, request)) host.addSystemMessage(plan.message);
return false;
case "steer":
return await steerCurrentTurn(host, localItemIds, plan, text, prepared, request, referencedThread);
return await steerCurrentTurn(host, localItemIds, plan, text, prepared, request);
case "start-thread-then-turn":
if (!commitPendingRequest(host, request)) return false;
{
@ -155,7 +161,6 @@ async function sendTurnText(
...(request.pendingSubmissionId ? { clientId: clientUserMessageId } : {}),
text: prepared.text,
codexInput: prepared.input,
referencedThread,
});
host.stateStore.dispatch({
type: "turn/optimistic-started",
@ -259,7 +264,6 @@ async function steerCurrentTurn(
text: string,
prepared: { text: string; input: CodexInput },
request: TurnSubmissionRequest,
referencedThread?: ReferencedThreadMetadata,
): Promise<boolean> {
if (!pendingRequestIsCurrent(host, request)) return false;
if (!commitPendingRequest(host, request)) return false;
@ -289,7 +293,6 @@ async function steerCurrentTurn(
...(request.pendingSubmissionId ? { clientId: localSteerId, interaction: "steer" as const } : {}),
text: prepared.text,
turnId: plan.turnId,
referencedThread,
codexInput: prepared.input,
});
host.stateStore.dispatch(
@ -337,6 +340,7 @@ function restoreSubmittedDraft(
host.setDraft(request.failureDraft ?? text, {
...options,
...(request.preserveComposerContextOnFailure ? { preserveContext: true } : {}),
...(request.inputSnapshot?.threadCommandTarget ? { threadCommandTarget: request.inputSnapshot.threadCommandTarget } : {}),
});
}

View file

@ -67,7 +67,7 @@ export function createMcpElicitationResultItem(
...definedProp("turnId", elicitation.params.turnId ?? undefined),
provenance: { source: "localUser", channel: "response", interaction: "userInputResponse", sourceId: String(elicitation.requestId) },
executionState: accepted ? "completed" : "failed",
questions: mcpElicitationResultQuestions(elicitation, accepted ? content : null),
questions: mcpElicitationResultQuestions(elicitation, accepted, accepted ? content : null),
};
}
@ -98,6 +98,7 @@ function mcpElicitationResultText(elicitation: PendingMcpElicitation, action: Mc
function mcpElicitationResultQuestions(
elicitation: PendingMcpElicitation,
accepted: boolean,
content: Record<string, McpElicitationContentValue> | null,
): readonly ThreadStreamUserInputQuestionResult[] {
if (elicitation.params.mode === "url") {
@ -106,7 +107,7 @@ function mcpElicitationResultQuestions(
id: "url",
header: "URL",
question: elicitation.params.message,
...(content ? { answer: elicitation.params.url } : {}),
...(accepted ? { answer: elicitation.params.url } : {}),
},
];
}

View file

@ -1,4 +0,0 @@
import type { ExecutionState } from "./items";
export type ThreadStreamExecutionState = Exclude<ExecutionState, null>;
export const RUNNING_EXECUTION_STATE: ThreadStreamExecutionState = "running";

View file

@ -1,5 +1,4 @@
import { RUNNING_EXECUTION_STATE } from "../execution-state";
import type { ThreadStreamItem, ThreadStreamItemKind } from "../items";
import { RUNNING_EXECUTION_STATE, type ThreadStreamItem, type ThreadStreamItemKind } from "../items";
export const STREAMED_COMMAND_RUNNING_TEXT = "Command running";
export const STREAMED_MCP_PROGRESS_LABEL = "mcp progress";

View file

@ -1,4 +1,3 @@
import type { TurnContextManifest } from "../../../../../domain/chat/context-manifest";
import type { CodexInputItem } from "../../../../../domain/chat/input";
import type { ThreadStreamContextAttachment } from "../items";
@ -6,20 +5,23 @@ export const WEB_CONTEXT_KEY = "codex_panel_web_context";
export function contextAttachmentsFromInput(input: readonly CodexInputItem[]): ThreadStreamContextAttachment[] {
return input.flatMap((item) => {
if (item.type !== "additionalContext" || (item.attachment?.kind !== "web" && item.key !== WEB_CONTEXT_KEY)) return [];
if (item.type !== "additionalContext" || item.key !== WEB_CONTEXT_KEY) return [];
const source = webContextSource(item.value);
return [{ label: "Web page", ...(source ? { detail: source } : {}) }];
});
}
export function contextAttachmentsFromManifest(manifest: TurnContextManifest | null, visibleText: string): ThreadStreamContextAttachment[] {
export function contextAttachmentsFromHistoryContexts(
contexts: readonly { kind: "web" | "obsidian"; truncated: boolean; inlineExcerpts?: number }[],
visibleText: string,
): ThreadStreamContextAttachment[] {
const attachments: ThreadStreamContextAttachment[] = [];
const web = manifest?.contexts.find((context) => context.kind === "web");
const web = contexts.find((context) => context.kind === "web");
if (web) {
const source = visibleWebSource(visibleText);
attachments.push({ label: web.truncated ? "Web page (truncated)" : "Web page", ...(source ? { detail: source } : {}) });
}
const obsidian = manifest?.contexts.find((context) => context.kind === "obsidian" && context.truncated);
const obsidian = contexts.find((context) => context.kind === "obsidian" && context.truncated);
if (obsidian) {
attachments.push({ label: obsidian.inlineExcerpts ? "Obsidian excerpt (truncated)" : "Obsidian context (truncated)" });
}

View file

@ -1,4 +1,5 @@
type TextRange = [number, number];
import { markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../../domain/markdown/code-ranges";
interface UserMessageDisplayInputItem {
type: string;
name?: string;
@ -12,7 +13,7 @@ export function userMessageDisplayText(text: string, input: readonly UserMessage
const codeRanges = markdownCodeRanges(text);
return text.replace(pattern, (match: string, prefix: string, name: string, offset: number) => {
const dollarIndex = offset + prefix.length;
return isIndexInRanges(dollarIndex, codeRanges) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`;
return markdownCodeRangeContainsOffset(codeRanges, dollarIndex) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`;
});
}
@ -36,64 +37,6 @@ function markdownCodeSpan(text: string): string {
return `${delimiter} ${text} ${delimiter}`;
}
function markdownCodeRanges(text: string): TextRange[] {
return [...markdownFenceRanges(text), ...markdownInlineCodeRanges(text)].sort((a, b) => a[0] - b[0]);
}
function markdownFenceRanges(text: string): TextRange[] {
const ranges: TextRange[] = [];
let active: { marker: string; start: number } | null = null;
let offset = 0;
for (const line of text.matchAll(/[^\n]*(?:\n|$)/g)) {
const value = line[0];
if (value.length === 0) break;
const fence = /^(?: {0,3})(`{3,}|~{3,})/.exec(value);
if (fence) {
const marker = fence[1];
if (!marker) continue;
if (!active) {
active = { marker, start: offset };
} else if (marker.startsWith(active.marker.charAt(0)) && marker.length >= active.marker.length) {
ranges.push([active.start, offset + value.length]);
active = null;
}
}
offset += value.length;
}
if (active) ranges.push([active.start, text.length]);
return ranges;
}
function markdownInlineCodeRanges(text: string): TextRange[] {
const ranges: TextRange[] = [];
const fenceRanges = markdownFenceRanges(text);
let index = 0;
while (index < text.length) {
if (isIndexInRanges(index, fenceRanges) || text[index] !== "`") {
index += 1;
continue;
}
const match = /`+/.exec(text.slice(index));
if (!match) {
index += 1;
continue;
}
const delimiter = match[0];
const end = text.indexOf(delimiter, index + delimiter.length);
if (end < 0) {
index += delimiter.length;
continue;
}
ranges.push([index, end + delimiter.length]);
index = end + delimiter.length;
}
return ranges;
}
function isIndexInRanges(index: number, ranges: readonly TextRange[]): boolean {
return ranges.some(([start, end]) => index >= start && index < end);
}
function escapeRegExp(value: string): string {
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
}

View file

@ -19,6 +19,8 @@ export type ThreadStreamItemKind =
| "reviewResult";
type ThreadStreamRole = "user" | "assistant" | "system" | "tool";
export type ExecutionState = "running" | "completed" | "failed" | null;
export type ThreadStreamExecutionState = Exclude<ExecutionState, null>;
export const RUNNING_EXECUTION_STATE: ThreadStreamExecutionState = "running";
type DialogueState = "streaming" | "completed";
interface ThreadStreamBase {

View file

@ -12,12 +12,19 @@ import { threadStreamIsCoordinationProgress } from "./predicates";
import type { ThreadStreamSemanticClassification } from "./types";
const ACTIVE_AGENT_PREVIEW_LIMIT = 96;
const ACTIVE_AGENT_ROW_LIMIT = 3;
type AgentRunState = "running" | "completed" | "failed";
export interface ActiveTurnItemsContext {
activeTurnId: string | null;
items: readonly ThreadStreamItem[];
activeItems?: readonly ThreadStreamItem[] | undefined;
subagentActivities?: ReadonlyMap<string, ActiveSubagentActivity> | undefined;
}
export interface ActiveSubagentActivity {
readonly executionState: "running" | "completed" | "failed";
readonly messagePreview: string | null;
}
export type ActiveTurnLiveItem =
@ -32,13 +39,13 @@ export type ActiveTurnLiveItem =
};
export function activeTurnLiveItems(
input: Pick<ActiveTurnItemsContext, "items" | "activeItems">,
input: Pick<ActiveTurnItemsContext, "items" | "activeItems" | "subagentActivities">,
activeTurnId: string,
): ActiveTurnLiveItem[] {
const items = input.activeItems ?? input.items;
const semanticItems = threadStreamSemanticClassifications(items);
const agentSummaryAnchorId = activeAgentRunSummaryAnchorId(semanticItems, activeTurnId);
const agentSummary = agentSummaryAnchorId ? activeAgentRunSummary(items, activeTurnId) : null;
const agentSummary = agentSummaryAnchorId ? activeAgentRunSummary(items, activeTurnId, input.subagentActivities) : null;
return semanticItems.flatMap((classification): ActiveTurnLiveItem[] => {
const { item } = classification;
@ -56,7 +63,11 @@ export function threadStreamItemsWithoutActiveTaskProgress(items: readonly Threa
return items.filter((item) => !threadStreamItemIsActiveTaskProgress(item, activeTurnId));
}
function activeAgentRunSummary(items: readonly ThreadStreamItem[], activeTurnId: string | null): AgentRunSummary | null {
function activeAgentRunSummary(
items: readonly ThreadStreamItem[],
activeTurnId: string | null,
subagentActivities?: ReadonlyMap<string, ActiveSubagentActivity>,
): AgentRunSummary | null {
if (!activeTurnId) return null;
const agentStatuses = new Map<string, AgentStateSummary>();
@ -72,6 +83,23 @@ function activeAgentRunSummary(items: readonly ThreadStreamItem[], activeTurnId:
}
}
}
for (const [threadId, activity] of subagentActivities ?? []) {
const current = agentStatuses.get(threadId);
if (!current) {
agentStatuses.set(threadId, {
threadId,
status: activity.executionState,
executionState: activity.executionState,
message: null,
});
continue;
}
agentStatuses.set(threadId, {
...current,
status: activity.executionState === current.executionState ? current.status : activity.executionState,
executionState: activity.executionState,
});
}
if (agentStatuses.size === 0) return null;
@ -84,14 +112,17 @@ function activeAgentRunSummary(items: readonly ThreadStreamItem[], activeTurnId:
if (summary.running === 0 && summary.failed === 0) return null;
summary.agents = agents
const runningAgents = agents
.filter((agent) => agentRunState(agent) === "running")
.sort((a, b) => a.threadId.localeCompare(b.threadId))
.map((agent) => ({
threadId: agent.threadId,
status: agent.status,
messagePreview: agentMessagePreview(agent.message, ACTIVE_AGENT_PREVIEW_LIMIT),
}));
messagePreview:
subagentActivities?.get(agent.threadId)?.messagePreview ?? agentMessagePreview(agent.message, ACTIVE_AGENT_PREVIEW_LIMIT),
}))
.sort((a, b) => Number(Boolean(b.messagePreview)) - Number(Boolean(a.messagePreview)) || a.threadId.localeCompare(b.threadId));
summary.agents = runningAgents.slice(0, ACTIVE_AGENT_ROW_LIMIT);
summary.additionalAgents = runningAgents.length - summary.agents.length;
return summary;
}

View file

@ -6,8 +6,7 @@ export function upsertThreadStreamItemById(items: readonly ThreadStreamItem[], n
const index = items.findIndex((item) => item.id === next.id);
if (index === -1) return [...items, next];
const copy = [...items];
const previous = copy[index];
if (previous === undefined) return [...items];
const previous = items[index] as ThreadStreamItem;
copy[index] = {
...previous,
...next,
@ -20,7 +19,7 @@ export function upsertThreadStreamItemById(items: readonly ThreadStreamItem[], n
function mergeOutput(previous: ThreadStreamItem, next: ThreadStreamItem): string | undefined {
const previousOutput = "output" in previous ? previous.output : undefined;
const nextOutput = "output" in next ? next.output : undefined;
return nextOutput && nextOutput.length > 0 ? nextOutput : previousOutput;
return nextOutput || previousOutput;
}
function mergeChanges(previous: ThreadStreamItem, next: ThreadStreamItem): readonly ThreadStreamFileChange[] | undefined {

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