mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7cf9ee69d | ||
|
|
9c8b0c3aca | ||
|
|
8088c39fd6 | ||
|
|
1279f1d556 | ||
|
|
0defbc1dc3 | ||
|
|
76c6253ae0 | ||
|
|
5ec25687cd | ||
|
|
33a1557aed | ||
|
|
cbc2ce06e9 | ||
|
|
82466aaa9f | ||
|
|
d3e3a83a0d | ||
|
|
ee611d65b6 | ||
|
|
5ce4562eed | ||
|
|
21c7761403 | ||
|
|
682fc54778 | ||
|
|
e06b691d5d | ||
|
|
022eb33234 | ||
|
|
161b2c2378 | ||
|
|
52dfdb1913 | ||
|
|
bdaa6b65aa | ||
|
|
1900f43049 | ||
|
|
29b6f2a86f | ||
|
|
638c208609 | ||
|
|
7b1144dafe | ||
|
|
940bc6243b | ||
|
|
51b83768c7 | ||
|
|
cdbaa8d29c | ||
|
|
b2229a28b9 | ||
|
|
d888c8f126 | ||
|
|
102ef06c30 | ||
|
|
5cb424b2e6 | ||
|
|
fd9dfb0cc8 | ||
|
|
03380cfad0 | ||
|
|
6ce0e1e91f | ||
|
|
64c2515b94 |
38
CHANGELOG.md
38
CHANGELOG.md
@@ -1,12 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.50 - 2026-04-07
|
||||
|
||||
### Added
|
||||
- Context window meter — live token usage indicator for Claude Code, Codex, and OpenCode shows how much of the context window has been consumed, with color thresholds at 70% and 90%.
|
||||
- Open in editor — open the current workspace directory in Cursor, VS Code, Zed, or the system file manager directly from the toolbar. Remembers your preferred editor.
|
||||
- Side-by-side diff layout — toggle between unified and split-column diff views in the Changes pane, with a whitespace visibility toggle.
|
||||
- Spoken messages — voice-mode speak tool calls now render inline in the conversation as labeled spoken messages instead of raw tool call blocks.
|
||||
- Plan approval actions — plan permission cards now show provider-defined action buttons (e.g. "Implement", "Deny") instead of hardcoded accept/reject.
|
||||
- Background git fetch — the daemon periodically fetches from origin so the Changes pane shows accurate ahead/behind counts without manual refreshes.
|
||||
|
||||
### Improved
|
||||
- File explorer and diff pane expanded/collapsed state persists across tab switches and rehydration.
|
||||
- Workspace list and updates are served instantly on connect; reconciliation happens in the background, eliminating the initial loading delay.
|
||||
- Provider list in Settings now includes a Refresh button and shows inline error details.
|
||||
- Workspace tabs close optimistically — the tab disappears immediately while the daemon archives the agent in the background.
|
||||
- Reload agent action moved away from the close button to prevent accidental taps.
|
||||
|
||||
### Fixed
|
||||
- WorkingIndicator no longer remounts on every stream update on native.
|
||||
- Silero VAD state is now reset between voice turns, preventing LSTM drift that could cause false speech detections in long sessions.
|
||||
- OpenCode context window meter updates correctly after the first turn.
|
||||
- Garbled overlapping text in plan card markdown.
|
||||
- Worktree branch tracking now prefers `origin/{branch}` over the local branch ref, fixing stale diff baselines.
|
||||
- Session ID reset on query restart prevents an overwrite crash when restarting an agent quickly.
|
||||
- Copilot ACP permission prompts are now bypassed in autopilot mode.
|
||||
- Direct connection and pairing modal content now displays correctly on tablets.
|
||||
- `wait_for_finish` errors from agents are now surfaced to the caller instead of silently swallowed.
|
||||
- Workspace diff stats preserved across rehydration instead of resetting to zero.
|
||||
- Diff toolbar toggle buttons polished for consistent sizing and alignment.
|
||||
|
||||
## 0.1.49 - 2026-04-07
|
||||
|
||||
### Fixed
|
||||
- Provider/model selector hydration on app connect — session state now seeds daemon feature flags immediately, so provider snapshots and model lists load reliably on first open instead of waiting for a later status refresh.
|
||||
- Running agent model picker now stays scoped to the agent's current provider instead of exposing all providers from the shared snapshot cache.
|
||||
- Provider snapshot warm-up now happens at the session layer instead of per workspace screen, so model data is prefetched consistently across entry points.
|
||||
- Removed the remaining legacy provider/model fetch fallbacks in the app so draft and running-agent flows both use the same real-time provider snapshot path.
|
||||
- Models and providers now load reliably on first app connect instead of requiring a second status refresh.
|
||||
- Model picker on running agents now only shows models from the agent's own provider, not every provider on the server.
|
||||
- Model data is now prefetched consistently regardless of which screen you open first.
|
||||
- Draft and running-agent flows now share the same provider data path, eliminating stale model lists from legacy fallbacks.
|
||||
|
||||
## 0.1.48 - 2026-04-05
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
- **Always run typecheck after every change.**
|
||||
- **NEVER make breaking changes to WebSocket or message schemas.** The mobile app in the App Store always lags behind the daemon, and daemons in the wild lag behind new app releases. Both directions must work. Every schema change MUST be backward-compatible:
|
||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- Never change a field from optional to required.
|
||||
- Never remove a field — deprecate it (keep accepting it, stop sending it).
|
||||
|
||||
@@ -11,6 +11,12 @@ There are two supported ways to ship from `main`:
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
Before running any stable patch release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- Make sure local `npm run typecheck` passes on that commit.
|
||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
```bash
|
||||
npm run release:patch
|
||||
```
|
||||
@@ -24,6 +30,7 @@ Use the direct stable path when the current `main` changes are ready to become t
|
||||
## Manual step-by-step
|
||||
|
||||
```bash
|
||||
npm run typecheck # Verify the exact commit you intend to release
|
||||
npm run release:check # Typecheck, build, dry-run pack
|
||||
npm run version:all:patch # Bump version, create commit + tag
|
||||
npm run release:publish # Publish to npm
|
||||
@@ -131,7 +138,7 @@ Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||
> Review the diff between the latest release tag and HEAD. Focus on:
|
||||
>
|
||||
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||
> 2. **Backward compatibility** — mobile apps lag behind desktop/daemon updates by days. Users will update desktop and daemon immediately but keep running the old app. Flag anything that requires both sides to update in lockstep.
|
||||
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
||||
>
|
||||
> Diff: `git diff <latest-release-tag>..HEAD`
|
||||
@@ -150,6 +157,8 @@ In other words, RCs are checkpoints along the way; the changelog only records th
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
|
||||
68
docs/plan-approval-normalization.md
Normal file
68
docs/plan-approval-normalization.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Plan Approval Normalization
|
||||
|
||||
## Goal
|
||||
|
||||
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
|
||||
|
||||
## Compatibility Constraints
|
||||
|
||||
- Older clients must remain compatible with newer daemons.
|
||||
- All new wire fields must be optional.
|
||||
- Existing plan permissions without action metadata must still render and work.
|
||||
- Existing question permissions must keep their current behavior.
|
||||
|
||||
## Design
|
||||
|
||||
### Shared abstraction
|
||||
|
||||
Add optional permission action definitions to the shared permission request/response types.
|
||||
|
||||
- Permission requests may include `actions`.
|
||||
- Permission responses may include `selectedActionId`.
|
||||
- `kind: "plan"` remains the normalized concept for plan approval.
|
||||
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
|
||||
|
||||
### Claude
|
||||
|
||||
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
|
||||
|
||||
- Always expose `Reject`.
|
||||
- Always expose `Implement`.
|
||||
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
|
||||
- Resolve the selected action entirely inside `respondToPermission()`.
|
||||
|
||||
### Codex
|
||||
|
||||
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
|
||||
|
||||
- Emit a plan permission with `Reject` and `Implement` actions.
|
||||
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
|
||||
- On `Reject`, resolve without starting a follow-up turn.
|
||||
- Keep the implementation prompt and state transitions inside the Codex provider.
|
||||
|
||||
### Manager and state sync
|
||||
|
||||
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
|
||||
|
||||
- Refresh current mode
|
||||
- Refresh pending permissions
|
||||
- Refresh runtime info
|
||||
- Refresh features
|
||||
- Persist refreshed state
|
||||
|
||||
### UI
|
||||
|
||||
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
|
||||
|
||||
- If `actions` are absent, fall back to legacy buttons.
|
||||
- Plan cards should use `Implement` as the default primary label.
|
||||
- Do not add provider-specific rendering branches.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Shared schema/type tests for optional `actions` and `selectedActionId`
|
||||
2. App tests for generic plan-action rendering
|
||||
3. Claude tests for third action when resuming from a more permissive mode
|
||||
4. Codex tests for synthetic plan approval and automatic implementation follow-up
|
||||
5. Manager tests for post-permission state refresh
|
||||
6. `npm run typecheck`
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-epuepM6DMEpOfzvPiS6K/qKJqGM89FqdsKHovWi2LS8=";
|
||||
npmDepsHash = "sha256-TEsFzJRgVubRnjAy7OO6Xkn6HY7CRO3LVxsVdbw3IH4=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34906,16 +34906,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.49",
|
||||
"@getpaseo/highlight": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.50",
|
||||
"@getpaseo/highlight": "0.1.50",
|
||||
"@getpaseo/server": "0.1.50",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35032,11 +35032,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"@getpaseo/relay": "0.1.50",
|
||||
"@getpaseo/server": "0.1.50",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35077,11 +35077,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"@getpaseo/cli": "0.1.50",
|
||||
"@getpaseo/server": "0.1.50",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35115,7 +35115,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35316,7 +35316,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35342,7 +35342,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35358,14 +35358,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.49",
|
||||
"@getpaseo/relay": "0.1.49",
|
||||
"@getpaseo/highlight": "0.1.50",
|
||||
"@getpaseo/relay": "0.1.50",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35764,7 +35764,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
BIN
packages/app/assets/images/editor-apps/cursor.png
Normal file
BIN
packages/app/assets/images/editor-apps/cursor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
packages/app/assets/images/editor-apps/file-explorer.png
Normal file
BIN
packages/app/assets/images/editor-apps/file-explorer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
packages/app/assets/images/editor-apps/finder.png
Normal file
BIN
packages/app/assets/images/editor-apps/finder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
packages/app/assets/images/editor-apps/vscode.png
Normal file
BIN
packages/app/assets/images/editor-apps/vscode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
packages/app/assets/images/editor-apps/zed.png
Normal file
BIN
packages/app/assets/images/editor-apps/zed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.49",
|
||||
"@getpaseo/highlight": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.50",
|
||||
"@getpaseo/highlight": "0.1.50",
|
||||
"@getpaseo/server": "0.1.50",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -28,6 +28,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
maxHeight: "85%",
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
@@ -54,11 +56,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
desktopScroll: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopContent: {
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
flexGrow: 1,
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DraftAgentStatusBar,
|
||||
type DraftAgentStatusBarProps,
|
||||
} from "./agent-status-bar";
|
||||
import { ContextWindowMeter } from "./context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -135,6 +136,8 @@ export function AgentInputArea({
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
return {
|
||||
status: agent?.status ?? null,
|
||||
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
||||
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -636,6 +639,25 @@ export function AgentInputArea({
|
||||
</View>
|
||||
);
|
||||
|
||||
const hasContextWindowMeter =
|
||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||
typeof agentState.contextWindowUsedTokens === "number";
|
||||
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||
const contextWindowUsedTokens = hasContextWindowMeter
|
||||
? agentState.contextWindowUsedTokens
|
||||
: null;
|
||||
|
||||
const beforeVoiceContent = (
|
||||
<View style={styles.contextWindowMeterSlot}>
|
||||
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
||||
<ContextWindowMeter
|
||||
maxTokens={contextWindowMaxTokens}
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
const leftContent =
|
||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||
<DraftAgentStatusBar {...statusControls} />
|
||||
@@ -715,6 +737,7 @@ export function AgentInputArea({
|
||||
disabled={isSubmitLoading}
|
||||
isInputActive={isInputActive}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
@@ -790,6 +813,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
contextWindowMeterSlot: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -859,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
model: currentAgent.model,
|
||||
features: currentAgent.features,
|
||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||
lastUsage: currentAgent.lastUsage,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
AssistantMessage,
|
||||
SpeakMessage,
|
||||
UserMessage,
|
||||
ActivityLog,
|
||||
ToolCall,
|
||||
@@ -38,7 +39,10 @@ import {
|
||||
import { PlanCard } from "./plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentPermissionResponse,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
@@ -356,6 +360,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
if (payload.source === "agent") {
|
||||
const data = payload.data;
|
||||
|
||||
if (
|
||||
data.name === "speak" &&
|
||||
data.detail.type === "unknown" &&
|
||||
typeof data.detail.input === "string" &&
|
||||
data.detail.input.trim()
|
||||
) {
|
||||
return (
|
||||
<SpeakMessage
|
||||
message={data.detail.input}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolCall
|
||||
toolName={data.name}
|
||||
@@ -694,6 +713,29 @@ function PermissionRequestCard({
|
||||
const isPlanRequest = request.kind === "plan";
|
||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||
const description = request.description ?? "";
|
||||
const resolvedActions = useMemo((): AgentPermissionAction[] => {
|
||||
if (request.kind === "question") {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(request.actions) && request.actions.length > 0) {
|
||||
return request.actions;
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Deny",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "accept",
|
||||
label: isPlanRequest ? "Implement" : "Accept",
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
},
|
||||
];
|
||||
}, [isPlanRequest, request]);
|
||||
|
||||
const planMarkdown = useMemo(() => {
|
||||
if (!request) {
|
||||
@@ -734,11 +776,11 @@ function PermissionRequestCard({
|
||||
isPending: isResponding,
|
||||
} = permissionMutation;
|
||||
|
||||
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
|
||||
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
resetPermissionMutation();
|
||||
setRespondingAction(null);
|
||||
setRespondingActionId(null);
|
||||
}, [permission.request.id, resetPermissionMutation]);
|
||||
const handleResponse = useCallback(
|
||||
(response: AgentPermissionResponse) => {
|
||||
@@ -752,6 +794,24 @@ function PermissionRequestCard({
|
||||
},
|
||||
[permission.agentId, permission.request.id, respondToPermission],
|
||||
);
|
||||
const handleActionPress = useCallback(
|
||||
(action: AgentPermissionAction) => {
|
||||
setRespondingActionId(action.id);
|
||||
if (action.behavior === "allow") {
|
||||
handleResponse({
|
||||
behavior: "allow",
|
||||
selectedActionId: action.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
selectedActionId: action.id,
|
||||
message: "Denied by user",
|
||||
});
|
||||
},
|
||||
[handleResponse],
|
||||
);
|
||||
|
||||
if (request.kind === "question") {
|
||||
return (
|
||||
@@ -778,64 +838,48 @@ function PermissionRequestCard({
|
||||
!isMobile && permissionStyles.optionsContainerDesktop,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
testID="permission-request-deny"
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("deny");
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
message: "Denied by user",
|
||||
});
|
||||
}}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "deny" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
|
||||
Deny
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{resolvedActions.map((action) => {
|
||||
const isDanger = action.variant === "danger" || action.behavior === "deny";
|
||||
const isPrimary = action.variant === "primary";
|
||||
const isRespondingAction = respondingActionId === action.id;
|
||||
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const iconColor = textColor;
|
||||
const Icon = action.behavior === "allow" ? Check : X;
|
||||
const testID =
|
||||
action.behavior === "deny"
|
||||
? "permission-request-deny"
|
||||
: action.id === "accept" || action.id === "implement"
|
||||
? "permission-request-accept"
|
||||
: `permission-request-action-${action.id}`;
|
||||
|
||||
<Pressable
|
||||
testID="permission-request-accept"
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("accept");
|
||||
handleResponse({ behavior: "allow" });
|
||||
}}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "accept" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Check size={14} color={theme.colors.foreground} />
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
|
||||
Accept
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
return (
|
||||
<Pressable
|
||||
key={action.id}
|
||||
testID={testID}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => handleActionPress(action)}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{isRespondingAction ? (
|
||||
<ActivityIndicator size="small" color={textColor} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Icon size={14} color={iconColor} />
|
||||
<Text style={[permissionStyles.optionText, { color: textColor }]}>
|
||||
{action.label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
153
packages/app/src/components/context-window-meter.tsx
Normal file
153
packages/app/src/components/context-window-meter.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
type ContextWindowMeterProps = {
|
||||
maxTokens: number;
|
||||
usedTokens: number;
|
||||
};
|
||||
|
||||
const SVG_SIZE = 20;
|
||||
const CENTER = SVG_SIZE / 2;
|
||||
const RADIUS = 7;
|
||||
const STROKE_WIDTH = 2.25;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
function isValidMaxTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function isValidUsedTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
|
||||
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
|
||||
return null;
|
||||
}
|
||||
return (usedTokens / maxTokens) * 100;
|
||||
}
|
||||
|
||||
function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
function getMeterColors(
|
||||
percentage: number,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): { progress: string; track: string } {
|
||||
const track = theme.colors.surface3;
|
||||
if (percentage > 90) {
|
||||
return { progress: theme.colors.destructive, track };
|
||||
}
|
||||
if (percentage >= 70) {
|
||||
return { progress: theme.colors.palette.amber[500], track };
|
||||
}
|
||||
return { progress: theme.colors.foregroundMuted, track };
|
||||
}
|
||||
|
||||
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
|
||||
if (percentage === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={styles.container}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||
>
|
||||
<Svg
|
||||
width={SVG_SIZE}
|
||||
height={SVG_SIZE}
|
||||
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipContent}>
|
||||
<Text style={styles.tooltipTitle}>Context window</Text>
|
||||
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
|
||||
<Text
|
||||
style={styles.tooltipDetail}
|
||||
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
svg: {
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
},
|
||||
tooltipContent: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
tooltipDetail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -118,7 +118,6 @@ export function FileExplorerPane({
|
||||
);
|
||||
|
||||
const {
|
||||
workspaceStateKey: actionsWorkspaceStateKey,
|
||||
requestDirectoryListing,
|
||||
requestFileDownloadToken,
|
||||
selectExplorerEntry,
|
||||
@@ -129,6 +128,16 @@ export function FileExplorerPane({
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
const expandedPathsArray = usePanelStore((state) =>
|
||||
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
||||
);
|
||||
const setExpandedPathsForWorkspace = usePanelStore(
|
||||
(state) => state.setExpandedPathsForWorkspace,
|
||||
);
|
||||
const expandedPaths = useMemo(
|
||||
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
||||
[expandedPathsArray],
|
||||
);
|
||||
|
||||
const directories = explorerState?.directories ?? new Map();
|
||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||
@@ -144,7 +153,6 @@ export function FileExplorerPane({
|
||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
||||
);
|
||||
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
@@ -154,8 +162,7 @@ export function FileExplorerPane({
|
||||
|
||||
useEffect(() => {
|
||||
hasInitializedRef.current = false;
|
||||
setExpandedPaths(new Set(["."]));
|
||||
}, [actionsWorkspaceStateKey]);
|
||||
}, [workspaceStateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasWorkspaceScope) {
|
||||
@@ -169,23 +176,35 @@ export function FileExplorerPane({
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}, [hasWorkspaceScope, requestDirectoryListing]);
|
||||
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (persistedPaths) {
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== ".") {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||
|
||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||
useEffect(() => {
|
||||
if (!selectedEntryPath || !hasWorkspaceScope) {
|
||||
if (!selectedEntryPath || !workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const parentDir = getParentDirectory(selectedEntryPath);
|
||||
const ancestors = getAncestorDirectories(parentDir);
|
||||
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
ancestors.forEach((path) => next.add(path));
|
||||
return next;
|
||||
});
|
||||
|
||||
ancestors.forEach((path) => {
|
||||
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
||||
if (newPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), ...newPaths],
|
||||
);
|
||||
newPaths.forEach((path) => {
|
||||
if (!directories.has(path)) {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
@@ -193,34 +212,46 @@ export function FileExplorerPane({
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
||||
}, [
|
||||
directories,
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
requestDirectoryListing,
|
||||
selectedEntryPath,
|
||||
setExpandedPathsForWorkspace,
|
||||
]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!hasWorkspaceScope) {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpanded = expandedPaths.has(entry.path);
|
||||
const nextExpanded = !isExpanded;
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isExpanded) {
|
||||
next.delete(entry.path);
|
||||
} else {
|
||||
next.add(entry.path);
|
||||
if (isExpanded) {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||
);
|
||||
} else {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), entry.path],
|
||||
);
|
||||
if (!directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (nextExpanded && !directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||
[
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
directories,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
32
packages/app/src/components/icons/editor-app-icons.tsx
Normal file
32
packages/app/src/components/icons/editor-app-icons.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Image, type ImageSourcePropType } from "react-native";
|
||||
import type { EditorTargetId } from "@server/shared/messages";
|
||||
|
||||
interface EditorAppIconProps {
|
||||
editorId: EditorTargetId;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const EDITOR_APP_IMAGES: Record<EditorTargetId, ImageSourcePropType> = {
|
||||
cursor: require("../../../assets/images/editor-apps/cursor.png"),
|
||||
vscode: require("../../../assets/images/editor-apps/vscode.png"),
|
||||
zed: require("../../../assets/images/editor-apps/zed.png"),
|
||||
finder: require("../../../assets/images/editor-apps/finder.png"),
|
||||
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
export function EditorAppIcon({
|
||||
editorId,
|
||||
size = 16,
|
||||
}: EditorAppIconProps) {
|
||||
return (
|
||||
<Image
|
||||
source={EDITOR_APP_IMAGES[editorId]}
|
||||
style={{ width: size, height: size }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,8 @@ export interface MessageInputProps {
|
||||
isInputActive?: boolean;
|
||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||
leftContent?: React.ReactNode;
|
||||
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
||||
beforeVoiceContent?: React.ReactNode;
|
||||
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
||||
rightContent?: React.ReactNode;
|
||||
voiceServerId?: string;
|
||||
@@ -201,6 +203,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
disabled = false,
|
||||
isInputActive = true,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
rightContent,
|
||||
voiceServerId,
|
||||
voiceAgentId,
|
||||
@@ -1015,6 +1018,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||
<View style={styles.rightButtonGroup}>
|
||||
{beforeVoiceContent}
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleVoicePress}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Copy,
|
||||
TriangleAlertIcon,
|
||||
Scissors,
|
||||
MicVocal,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
||||
import Animated, {
|
||||
@@ -913,6 +914,65 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
});
|
||||
|
||||
interface SpeakMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
headerLabel: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
text: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
export const SpeakMessage = memo(function SpeakMessage({
|
||||
message,
|
||||
timestamp,
|
||||
disableOuterSpacing,
|
||||
}: SpeakMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="speak-message"
|
||||
style={[
|
||||
speakMessageStylesheet.container,
|
||||
!resolvedDisableOuterSpacing && speakMessageStylesheet.containerSpacing,
|
||||
]}
|
||||
>
|
||||
<View style={speakMessageStylesheet.header}>
|
||||
<MicVocal size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
|
||||
</View>
|
||||
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface ActivityLogProps {
|
||||
type: "system" | "info" | "success" | "error" | "artifact";
|
||||
message: string;
|
||||
|
||||
@@ -78,9 +78,17 @@ function createPlanMarkdownRules() {
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
|
||||
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -313,7 +313,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
keyExtractor={(item) => item.id}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined}
|
||||
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||
contentContainerStyle={baseListContentContainerStyle}
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
|
||||
@@ -44,6 +44,7 @@ type TooltipContextValue = {
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
enabled: boolean;
|
||||
openOnPress: boolean;
|
||||
delayDuration: number;
|
||||
};
|
||||
|
||||
@@ -107,6 +108,18 @@ function measureElement(element: View): Promise<Rect> {
|
||||
});
|
||||
}
|
||||
|
||||
function isMobileTooltipEnvironment(): boolean {
|
||||
if (Platform.OS !== "web") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
}
|
||||
|
||||
function computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
@@ -214,12 +227,8 @@ export function Tooltip({
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
const isWeb = Platform.OS === "web";
|
||||
const isMobileWeb =
|
||||
isWeb &&
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
|
||||
const isMobile = isMobileTooltipEnvironment();
|
||||
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
|
||||
|
||||
const value = useMemo<TooltipContextValue>(
|
||||
() => ({
|
||||
@@ -227,9 +236,10 @@ export function Tooltip({
|
||||
setOpen: setIsOpen,
|
||||
triggerRef,
|
||||
enabled,
|
||||
openOnPress: isMobile,
|
||||
delayDuration,
|
||||
}),
|
||||
[isOpen, setIsOpen, enabled, delayDuration],
|
||||
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
|
||||
);
|
||||
|
||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||
@@ -323,9 +333,17 @@ export function TooltipTrigger({
|
||||
const handlePress = useCallback(
|
||||
(e: any) => {
|
||||
onPress?.(e);
|
||||
if (!ctx.enabled || disabled) {
|
||||
return;
|
||||
}
|
||||
if (ctx.openOnPress) {
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
},
|
||||
[close, onPress],
|
||||
[clearOpenTimer, close, ctx, disabled, onPress],
|
||||
);
|
||||
|
||||
const triggerProps = {
|
||||
@@ -492,7 +510,7 @@ export function TooltipContent({
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onRequestClose={() => ctx.setOpen(false)}
|
||||
>
|
||||
<View pointerEvents="box-none" style={styles.overlay}>
|
||||
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
@@ -513,7 +531,7 @@ export function TooltipContent({
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type Agent,
|
||||
type SessionState,
|
||||
type WorkspaceDescriptor,
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
@@ -68,6 +69,22 @@ export type {
|
||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
|
||||
|
||||
function hasAgentUsageChanged(
|
||||
incomingUsage: Agent["lastUsage"] | undefined,
|
||||
currentUsage: Agent["lastUsage"] | undefined,
|
||||
): boolean {
|
||||
const keys: Array<keyof NonNullable<Agent["lastUsage"]>> = [
|
||||
"inputTokens",
|
||||
"outputTokens",
|
||||
"cachedInputTokens",
|
||||
"totalCostUsd",
|
||||
"contextWindowMaxTokens",
|
||||
"contextWindowUsedTokens",
|
||||
];
|
||||
|
||||
return keys.some((key) => incomingUsage?.[key] !== currentUsage?.[key]);
|
||||
}
|
||||
|
||||
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
|
||||
|
||||
interface BufferedAudioChunk {
|
||||
@@ -310,6 +327,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>();
|
||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
let cursor: string | null = null;
|
||||
let includeSubscribe = options?.subscribe ?? false;
|
||||
|
||||
@@ -325,7 +343,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||
workspaces.set(workspace.id, workspace);
|
||||
workspaces.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing: existingWorkspaces?.get(workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
@@ -350,6 +374,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgents(serverId, (prev) => {
|
||||
const current = prev.get(agent.id);
|
||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||
const hasUsageUpdate = hasAgentUsageChanged(agent.lastUsage, current.lastUsage);
|
||||
if (hasUsageUpdate) {
|
||||
const next = new Map(prev);
|
||||
next.set(agent.id, {
|
||||
...current,
|
||||
lastUsage: agent.lastUsage,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
|
||||
74
packages/app/src/hooks/use-changes-preferences.test.ts
Normal file
74
packages/app/src/hooks/use-changes-preferences.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
describe("use-changes-preferences", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
});
|
||||
|
||||
it("defaults to unified layout with visible whitespace", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual(mod.DEFAULT_CHANGES_PREFERENCES);
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"@paseo:changes-preferences",
|
||||
JSON.stringify(mod.DEFAULT_CHANGES_PREFERENCES),
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates the legacy wrap-lines toggle into the new preferences object", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "diff-wrap-lines") {
|
||||
return "true";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
layout: "unified",
|
||||
wrapLines: true,
|
||||
hideWhitespace: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads persisted layout and whitespace preferences", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:changes-preferences") {
|
||||
return JSON.stringify({
|
||||
layout: "split",
|
||||
hideWhitespace: true,
|
||||
wrapLines: false,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
layout: "split",
|
||||
hideWhitespace: true,
|
||||
wrapLines: false,
|
||||
});
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
89
packages/app/src/hooks/use-changes-preferences.ts
Normal file
89
packages/app/src/hooks/use-changes-preferences.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
|
||||
const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
|
||||
const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
|
||||
|
||||
const changesPreferencesSchema = z.object({
|
||||
layout: z.enum(["unified", "split"]).optional(),
|
||||
wrapLines: z.boolean().optional(),
|
||||
hideWhitespace: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export interface ChangesPreferences {
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
hideWhitespace: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
|
||||
layout: "unified",
|
||||
wrapLines: false,
|
||||
hideWhitespace: false,
|
||||
};
|
||||
|
||||
async function loadLegacyWrapLinesPreference(): Promise<boolean | null> {
|
||||
const legacyValue = await AsyncStorage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY);
|
||||
if (legacyValue === "true") {
|
||||
return true;
|
||||
}
|
||||
if (legacyValue === "false") {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadChangesPreferencesFromStorage(): Promise<ChangesPreferences> {
|
||||
const stored = await AsyncStorage.getItem(CHANGES_PREFERENCES_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored));
|
||||
if (parsed.success) {
|
||||
return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
|
||||
}
|
||||
}
|
||||
|
||||
const legacyWrapLines = await loadLegacyWrapLinesPreference();
|
||||
const next = {
|
||||
...DEFAULT_CHANGES_PREFERENCES,
|
||||
...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}),
|
||||
} satisfies ChangesPreferences;
|
||||
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface UseChangesPreferencesReturn {
|
||||
preferences: ChangesPreferences;
|
||||
isLoading: boolean;
|
||||
updatePreferences: (updates: Partial<ChangesPreferences>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useChangesPreferences(): UseChangesPreferencesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: CHANGES_PREFERENCES_QUERY_KEY,
|
||||
queryFn: loadChangesPreferencesFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
async (updates: Partial<ChangesPreferences>) => {
|
||||
const prev =
|
||||
queryClient.getQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY) ??
|
||||
DEFAULT_CHANGES_PREFERENCES;
|
||||
const next = { ...prev, ...updates };
|
||||
queryClient.setQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
preferences: data ?? DEFAULT_CHANGES_PREFERENCES,
|
||||
isLoading: isPending,
|
||||
updatePreferences,
|
||||
};
|
||||
}
|
||||
@@ -13,8 +13,9 @@ function checkoutDiffQueryKey(
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const;
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
@@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,12 +37,16 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
function normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string } {
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
|
||||
export function useCheckoutDiffQuery({
|
||||
@@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
}: UseCheckoutDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const hookInstanceId = useId();
|
||||
const normalizedCompare = useMemo(
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
|
||||
[mode, baseRef],
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||
[mode, baseRef, ignoreWhitespace],
|
||||
);
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
|
||||
[serverId, cwd, mode, baseRef],
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
@@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({
|
||||
const payload = await client.getCheckoutDiff(cwd, {
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
@@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({
|
||||
cwd,
|
||||
compareMode,
|
||||
compareBaseRef ?? "",
|
||||
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||
].join(":");
|
||||
let cancelled = false;
|
||||
|
||||
@@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({
|
||||
{
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
},
|
||||
{ subscriptionId },
|
||||
)
|
||||
@@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({
|
||||
serverId,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
compareIgnoreWhitespace,
|
||||
queryKey,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
20
packages/app/src/hooks/use-preferred-editor.test.ts
Normal file
20
packages/app/src/hooks/use-preferred-editor.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePreferredEditorId } from "./use-preferred-editor";
|
||||
|
||||
describe("resolvePreferredEditorId", () => {
|
||||
it("keeps the stored editor when it is still available", () => {
|
||||
expect(resolvePreferredEditorId(["cursor", "vscode"], "vscode")).toBe("vscode");
|
||||
});
|
||||
|
||||
it("falls back to the first available editor when the stored one is missing", () => {
|
||||
expect(resolvePreferredEditorId(["zed", "finder"], "cursor")).toBe("zed");
|
||||
});
|
||||
|
||||
it("falls back when a platform-specific file manager target is unavailable", () => {
|
||||
expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer");
|
||||
});
|
||||
|
||||
it("returns null when no editors are available", () => {
|
||||
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
|
||||
});
|
||||
});
|
||||
57
packages/app/src/hooks/use-preferred-editor.ts
Normal file
57
packages/app/src/hooks/use-preferred-editor.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { EditorTargetIdSchema, type EditorTargetId } from "@server/shared/messages";
|
||||
|
||||
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
|
||||
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
|
||||
|
||||
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
|
||||
const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
const parsed = EditorTargetIdSchema.safeParse(stored);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function resolvePreferredEditorId(
|
||||
availableEditorIds: readonly EditorTargetId[],
|
||||
storedEditorId: EditorTargetId | null | undefined,
|
||||
): EditorTargetId | null {
|
||||
if (
|
||||
storedEditorId &&
|
||||
availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId)
|
||||
) {
|
||||
return storedEditorId;
|
||||
}
|
||||
return availableEditorIds[0] ?? null;
|
||||
}
|
||||
|
||||
export function usePreferredEditor() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: PREFERRED_EDITOR_QUERY_KEY,
|
||||
queryFn: loadPreferredEditor,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updatePreferredEditor = useCallback(
|
||||
async (editorId: EditorTargetId | null) => {
|
||||
queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId);
|
||||
if (editorId) {
|
||||
await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId);
|
||||
return;
|
||||
}
|
||||
await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
preferredEditorId: data ?? null,
|
||||
isLoading: isPending,
|
||||
updatePreferredEditor,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
@@ -356,6 +360,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
}
|
||||
void (async () => {
|
||||
const next = new Map<string, WorkspaceDescriptor>();
|
||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
let cursor: string | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
@@ -365,7 +370,13 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = toWorkspaceDescriptor(entry);
|
||||
next.set(workspace.id, workspace);
|
||||
next.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing: existingWorkspaces?.get(workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
break;
|
||||
|
||||
@@ -62,12 +62,12 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section definitions
|
||||
@@ -432,63 +432,70 @@ interface ProvidersSectionProps {
|
||||
|
||||
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(routeServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||
const [entries, setEntries] = useState<ProviderSnapshotEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected) {
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
client
|
||||
.getProvidersSnapshot()
|
||||
.then((result) => {
|
||||
if (!cancelled) setEntries(result.entries);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setEntries([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, isConnected]);
|
||||
|
||||
const hasServer = routeServerId.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Providers</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
|
||||
{hasServer && isConnected ? (
|
||||
<Pressable
|
||||
onPress={refresh}
|
||||
disabled={isFetching}
|
||||
style={[
|
||||
settingsStyles.sectionHeaderLink,
|
||||
isFetching ? { opacity: 0.5 } : null,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.primary,
|
||||
fontSize: theme.fontSize.xs,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{!hasServer || !isConnected ? (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
|
||||
</View>
|
||||
) : loading ? (
|
||||
) : isLoading ? (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
|
||||
const entry = entries.find((e) => e.provider === def.id);
|
||||
const entry = entries?.find((e) => e.provider === def.id);
|
||||
const status = entry?.status ?? "unavailable";
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
const providerError =
|
||||
status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
|
||||
? entry.error.trim()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View key={def.id} style={styles.audioRow}>
|
||||
<View style={[styles.audioRowContent, { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }]}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
<Text style={styles.audioRowTitle}>{def.label}</Text>
|
||||
<View style={styles.audioRowContent}>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
<Text style={styles.audioRowTitle}>{def.label}</Text>
|
||||
</View>
|
||||
{providerError ? (
|
||||
<Text style={styles.aboutErrorText} numberOfLines={3}>
|
||||
{providerError}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.providerActions}>
|
||||
<StatusBadge
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("workspace bulk close helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => {
|
||||
it("closes all tabs immediately and fires one mixed closeItems RPC in the background", async () => {
|
||||
const groups = classifyBulkClosableTabs([
|
||||
makeAgentTab("a1"),
|
||||
makeTerminalTab("t1"),
|
||||
@@ -91,7 +91,7 @@ describe("workspace bulk close helpers", () => {
|
||||
requestId: "req-1",
|
||||
}));
|
||||
|
||||
const result = await closeBulkWorkspaceTabs({
|
||||
await closeBulkWorkspaceTabs({
|
||||
groups,
|
||||
client: { closeItems },
|
||||
closeTab: async (tabId, action) => {
|
||||
@@ -109,23 +109,21 @@ describe("workspace bulk close helpers", () => {
|
||||
agentIds: ["a1"],
|
||||
terminalIds: ["t1", "t2"],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
||||
terminals: [
|
||||
{ terminalId: "t1", success: true },
|
||||
{ terminalId: "t2", success: false },
|
||||
],
|
||||
requestId: "req-1",
|
||||
});
|
||||
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
|
||||
expect(closedTabIds).toEqual([
|
||||
"agent_a1",
|
||||
"terminal_t1",
|
||||
"terminal_t2",
|
||||
"file_/repo/README.md",
|
||||
]);
|
||||
expect(cleanupCalls).toEqual([
|
||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||
{ tabId: "terminal_t2", target: { kind: "terminal", terminalId: "t2" } },
|
||||
{ tabId: "file_/repo/README.md" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still closes passive tabs when the mixed closeItems RPC fails", async () => {
|
||||
it("still closes all tabs when the mixed closeItems RPC fails", async () => {
|
||||
const groups = classifyBulkClosableTabs([
|
||||
makeAgentTab("a1"),
|
||||
makeTerminalTab("t1"),
|
||||
@@ -135,7 +133,7 @@ describe("workspace bulk close helpers", () => {
|
||||
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
|
||||
const warn = vi.fn();
|
||||
|
||||
const result = await closeBulkWorkspaceTabs({
|
||||
await closeBulkWorkspaceTabs({
|
||||
groups,
|
||||
client: {
|
||||
closeItems: async () => {
|
||||
@@ -153,9 +151,14 @@ describe("workspace bulk close helpers", () => {
|
||||
logLabel: "others",
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeNull();
|
||||
expect(closedTabIds).toEqual(["file_/repo/README.md"]);
|
||||
expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]);
|
||||
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
|
||||
expect(cleanupCalls).toEqual([
|
||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||
{ tabId: "file_/repo/README.md" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,6 @@ export type BulkClosableTabGroups = {
|
||||
otherTabs: Array<{ tabId: string }>;
|
||||
};
|
||||
|
||||
type CloseItemsPayload = Awaited<ReturnType<DaemonClient["closeItems"]>>;
|
||||
|
||||
interface CloseWorkspaceTabWithCleanupInput {
|
||||
tabId: string;
|
||||
target?: WorkspaceTabDescriptor["target"];
|
||||
@@ -68,47 +66,27 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups):
|
||||
return `This will archive ${agentTabs.length} agent(s).`;
|
||||
}
|
||||
|
||||
function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set<string> {
|
||||
return new Set(payload?.agents.map((agent) => agent.agentId) ?? []);
|
||||
}
|
||||
|
||||
function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set<string> {
|
||||
return new Set(
|
||||
payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeBulkWorkspaceTabs(
|
||||
input: CloseBulkWorkspaceTabsInput,
|
||||
): Promise<CloseItemsPayload | null> {
|
||||
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
|
||||
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
|
||||
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
|
||||
let payload: CloseItemsPayload | null = null;
|
||||
|
||||
if (hasDestructiveTabs && client) {
|
||||
try {
|
||||
payload = await client.closeItems({
|
||||
void client
|
||||
.closeItems({
|
||||
agentIds: groups.agentTabs.map((tab) => tab.agentId),
|
||||
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
||||
})
|
||||
.catch((error) => {
|
||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
|
||||
});
|
||||
} catch (error) {
|
||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
|
||||
}
|
||||
} else if (hasDestructiveTabs) {
|
||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
|
||||
error: new Error("Daemon client not available"),
|
||||
});
|
||||
}
|
||||
|
||||
const successfulAgentIds = toSuccessfulAgentIds(payload);
|
||||
const successfulTerminalIds = toSuccessfulTerminalIds(payload);
|
||||
|
||||
for (const { tabId, agentId } of groups.agentTabs) {
|
||||
if (!successfulAgentIds.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
await closeTab(tabId, async () => {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "agent", agentId },
|
||||
@@ -117,10 +95,7 @@ export async function closeBulkWorkspaceTabs(
|
||||
}
|
||||
|
||||
for (const { tabId, terminalId } of groups.terminalTabs) {
|
||||
if (!successfulTerminalIds.has(terminalId)) {
|
||||
continue;
|
||||
}
|
||||
await closeTab(tabId, async () => {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
@@ -129,10 +104,8 @@ export async function closeBulkWorkspaceTabs(
|
||||
}
|
||||
|
||||
for (const { tabId } of groups.otherTabs) {
|
||||
await closeTab(tabId, async () => {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({ tabId });
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type {
|
||||
EditorTargetDescriptorPayload,
|
||||
EditorTargetId,
|
||||
} from "@server/shared/messages";
|
||||
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
resolvePreferredEditorId,
|
||||
usePreferredEditor,
|
||||
} from "@/hooks/use-preferred-editor";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function WorkspaceOpenInEditorButton({
|
||||
serverId,
|
||||
cwd,
|
||||
}: WorkspaceOpenInEditorButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||
|
||||
const shouldLoadEditors =
|
||||
Platform.OS === "web" &&
|
||||
Boolean(client && isConnected) &&
|
||||
cwd.trim().length > 0 &&
|
||||
isAbsolutePath(cwd);
|
||||
|
||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||
queryKey: ["available-editors", serverId],
|
||||
enabled: shouldLoadEditors,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const payload = await client.listAvailableEditors();
|
||||
return payload.error ? [] : payload.editors;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const availableEditors = availableEditorsQuery.data ?? [];
|
||||
const availableEditorIds = useMemo(
|
||||
() => availableEditors.map((editor: EditorTargetDescriptorPayload) => editor.id),
|
||||
[availableEditors],
|
||||
);
|
||||
const effectivePreferredEditorId = useMemo(
|
||||
() => resolvePreferredEditorId(availableEditorIds, preferredEditorId),
|
||||
[availableEditorIds, preferredEditorId],
|
||||
);
|
||||
const primaryOption =
|
||||
availableEditors.find(
|
||||
(editor: EditorTargetDescriptorPayload) => editor.id === effectivePreferredEditorId,
|
||||
) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) {
|
||||
return;
|
||||
}
|
||||
void updatePreferredEditor(effectivePreferredEditorId).catch(() => undefined);
|
||||
}, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]);
|
||||
|
||||
const openMutation = useMutation({
|
||||
mutationFn: async (editorId: EditorTargetId) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.openInEditor(cwd, editorId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return editorId;
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open in editor");
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenEditor = useCallback(
|
||||
(editorId: EditorTargetId) => {
|
||||
void updatePreferredEditor(editorId).catch(() => undefined);
|
||||
openMutation.mutate(editorId);
|
||||
},
|
||||
[openMutation, updatePreferredEditor],
|
||||
);
|
||||
|
||||
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.splitButton}>
|
||||
<Pressable
|
||||
testID="workspace-open-in-editor-primary"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.splitButtonPrimary,
|
||||
(hovered || pressed) && styles.splitButtonPrimaryHovered,
|
||||
openMutation.isPending && styles.splitButtonPrimaryDisabled,
|
||||
]}
|
||||
onPress={() => handleOpenEditor(primaryOption.id)}
|
||||
disabled={openMutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
|
||||
>
|
||||
{openMutation.isPending ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foreground}
|
||||
style={styles.splitButtonSpinnerOnly}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.splitButtonContent}>
|
||||
<EditorAppIcon
|
||||
editorId={primaryOption.id}
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={styles.splitButtonText}>Open</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{availableEditors.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-open-in-editor-caret"
|
||||
style={({ hovered, pressed, open }) => [
|
||||
styles.splitButtonCaret,
|
||||
(hovered || pressed || open) && styles.splitButtonCaretHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Choose editor"
|
||||
>
|
||||
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
minWidth={148}
|
||||
maxWidth={176}
|
||||
testID="workspace-open-in-editor-menu"
|
||||
>
|
||||
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
|
||||
<DropdownMenuItem
|
||||
key={editor.id}
|
||||
testID={`workspace-open-in-editor-item-${editor.id}`}
|
||||
leading={
|
||||
<EditorAppIcon
|
||||
editorId={editor.id}
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
editor.id === effectivePreferredEditorId
|
||||
? <Check size={16} color={theme.colors.foregroundMuted} />
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => handleOpenEditor(editor.id)}
|
||||
>
|
||||
{editor.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
splitButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
},
|
||||
splitButtonPrimary: {
|
||||
paddingLeft: theme.spacing[3],
|
||||
paddingRight: 10,
|
||||
paddingVertical: theme.spacing[1],
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
splitButtonPrimaryHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
splitButtonPrimaryDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
splitButtonText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.5,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
splitButtonContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
splitButtonSpinnerOnly: {
|
||||
transform: [{ scale: 0.8 }],
|
||||
},
|
||||
splitButtonCaret: {
|
||||
width: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderLeftWidth: theme.borderWidth[1],
|
||||
borderLeftColor: theme.colors.borderAccent,
|
||||
},
|
||||
splitButtonCaretHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -48,6 +48,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { SplitContainer } from "@/components/split-container";
|
||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
@@ -78,7 +79,7 @@ import {
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
@@ -1213,10 +1214,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return;
|
||||
}
|
||||
|
||||
await killTerminalAsync(terminalId);
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
@@ -1226,13 +1223,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
||||
};
|
||||
});
|
||||
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
});
|
||||
}
|
||||
|
||||
void killTerminalAsync(terminalId).catch(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||
});
|
||||
});
|
||||
},
|
||||
[
|
||||
@@ -1253,18 +1255,22 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Archive agent?",
|
||||
message: "This closes the tab and archives the agent.",
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
const agent =
|
||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||
|
||||
if (agent?.status !== "idle") {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Archive agent?",
|
||||
message: "This closes the tab and archives the agent.",
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await archiveAgent({ serverId: normalizedServerId, agentId });
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
@@ -1273,6 +1279,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
target: { kind: "agent", agentId },
|
||||
});
|
||||
}
|
||||
|
||||
void archiveAgent({ serverId: normalizedServerId, agentId });
|
||||
});
|
||||
},
|
||||
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
|
||||
@@ -1417,7 +1425,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return;
|
||||
}
|
||||
|
||||
const closeItemsPayload = await closeBulkWorkspaceTabs({
|
||||
await closeBulkWorkspaceTabs({
|
||||
client,
|
||||
groups,
|
||||
closeTab,
|
||||
@@ -1433,31 +1441,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
},
|
||||
});
|
||||
|
||||
if (closeItemsPayload) {
|
||||
for (const terminal of closeItemsPayload.terminals) {
|
||||
if (!terminal.success) {
|
||||
continue;
|
||||
}
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
terminals: current.terminals.filter((entry) => entry.id !== terminal.terminalId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedServerId) {
|
||||
applyArchivedAgentCloseResults({
|
||||
queryClient,
|
||||
serverId: normalizedServerId,
|
||||
results: closeItemsPayload.agents,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
|
||||
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||
@@ -1466,10 +1449,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
client,
|
||||
closeTab,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
normalizedServerId,
|
||||
persistenceKey,
|
||||
queryClient,
|
||||
terminalsQueryKey,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2045,6 +2025,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
right={
|
||||
<View style={styles.headerRight}>
|
||||
{!isMobile ? (
|
||||
<WorkspaceOpenInEditorButton
|
||||
serverId={normalizedServerId}
|
||||
cwd={normalizedWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && isGitCheckout ? (
|
||||
<>
|
||||
<WorkspaceGitActions
|
||||
|
||||
@@ -39,10 +39,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Reload agent",
|
||||
"Close to the left",
|
||||
"Close to the right",
|
||||
"Close other tabs",
|
||||
"Reload agent",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
@@ -66,10 +66,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Reload agent",
|
||||
"Close tabs above",
|
||||
"Close tabs below",
|
||||
"Close other tabs",
|
||||
"Reload agent",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -135,17 +135,6 @@ export function buildWorkspaceTabMenuEntries(
|
||||
void onCopyAgentId(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "reload-agent",
|
||||
label: "Reload agent",
|
||||
icon: "rotate-cw",
|
||||
tooltip: "Reload agent to update skills, MCPs or login status.",
|
||||
testID: `${menuTestIDBase}-reload-agent`,
|
||||
onSelect: () => {
|
||||
void onReloadAgent(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "separator",
|
||||
key: "copy-separator",
|
||||
@@ -185,6 +174,20 @@ export function buildWorkspaceTabMenuEntries(
|
||||
void onCloseOtherTabs(tab.tabId);
|
||||
},
|
||||
});
|
||||
if (tab.target.kind === "agent") {
|
||||
const { agentId } = tab.target;
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "reload-agent",
|
||||
label: "Reload agent",
|
||||
icon: "rotate-cw",
|
||||
tooltip: "Reload agent to update skills, MCPs or login status.",
|
||||
testID: `${menuTestIDBase}-reload-agent`,
|
||||
onSelect: () => {
|
||||
void onReloadAgent(agentId);
|
||||
},
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close",
|
||||
|
||||
@@ -66,6 +66,8 @@ interface PanelState {
|
||||
// File explorer settings (shared between mobile/desktop)
|
||||
explorerTab: ExplorerTab;
|
||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||
expandedPathsByWorkspace: Record<string, string[]>;
|
||||
diffExpandedPathsByWorkspace: Record<string, string[]>;
|
||||
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
||||
sidebarWidth: number;
|
||||
explorerWidth: number;
|
||||
@@ -85,6 +87,8 @@ interface PanelState {
|
||||
// File explorer settings actions
|
||||
setExplorerTab: (tab: ExplorerTab) => void;
|
||||
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
|
||||
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
|
||||
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
|
||||
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
||||
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
@@ -142,6 +146,8 @@ export const usePanelStore = create<PanelState>()(
|
||||
// File explorer defaults
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: {},
|
||||
expandedPathsByWorkspace: {},
|
||||
diffExpandedPathsByWorkspace: {},
|
||||
activeExplorerCheckout: null,
|
||||
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
@@ -261,6 +267,17 @@ export const usePanelStore = create<PanelState>()(
|
||||
}
|
||||
return nextState;
|
||||
}),
|
||||
setExpandedPathsForWorkspace: (workspaceKey, paths) =>
|
||||
set((state) => ({
|
||||
expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths },
|
||||
})),
|
||||
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
|
||||
set((state) => ({
|
||||
diffExpandedPathsByWorkspace: {
|
||||
...state.diffExpandedPathsByWorkspace,
|
||||
[workspaceKey]: paths,
|
||||
},
|
||||
})),
|
||||
activateExplorerTabForCheckout: (checkout) =>
|
||||
set((state) => ({
|
||||
activeExplorerCheckout: checkout,
|
||||
@@ -295,7 +312,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 8,
|
||||
version: 10,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
migrate: (persistedState, version) => {
|
||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||
@@ -371,6 +388,22 @@ export const usePanelStore = create<PanelState>()(
|
||||
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||
}
|
||||
|
||||
if (
|
||||
version < 9 ||
|
||||
typeof state.expandedPathsByWorkspace !== "object" ||
|
||||
!state.expandedPathsByWorkspace
|
||||
) {
|
||||
state.expandedPathsByWorkspace = {};
|
||||
}
|
||||
|
||||
if (
|
||||
version < 10 ||
|
||||
typeof state.diffExpandedPathsByWorkspace !== "object" ||
|
||||
!state.diffExpandedPathsByWorkspace
|
||||
) {
|
||||
state.diffExpandedPathsByWorkspace = {};
|
||||
}
|
||||
|
||||
state.activeExplorerCheckout = null;
|
||||
|
||||
return state as PanelState;
|
||||
@@ -380,6 +413,8 @@ export const usePanelStore = create<PanelState>()(
|
||||
desktop: state.desktop,
|
||||
explorerTab: state.explorerTab,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
|
||||
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
explorerWidth: state.explorerWidth,
|
||||
explorerSortOption: state.explorerSortOption,
|
||||
|
||||
53
packages/app/src/stores/session-store.test.ts
Normal file
53
packages/app/src/stores/session-store.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./session-store";
|
||||
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "remote:github.com/getpaseo/paseo",
|
||||
projectDisplayName: input.projectDisplayName ?? "getpaseo/paseo",
|
||||
projectRootPath: input.projectRootPath ?? "/tmp/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
activityAt: input.activityAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergeWorkspaceSnapshotWithExisting", () => {
|
||||
it("preserves the last known diff stat when a snapshot only has baseline null data", () => {
|
||||
const existing = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
});
|
||||
const incoming = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: null,
|
||||
});
|
||||
|
||||
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual({
|
||||
...incoming,
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the incoming diff stat when the server provides a known value", () => {
|
||||
const existing = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
});
|
||||
const incoming = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: { additions: 0, deletions: 0 },
|
||||
});
|
||||
|
||||
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual(incoming);
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,21 @@ export function normalizeWorkspaceDescriptor(
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeWorkspaceSnapshotWithExisting(input: {
|
||||
incoming: WorkspaceDescriptor;
|
||||
existing?: WorkspaceDescriptor | null;
|
||||
}): WorkspaceDescriptor {
|
||||
const { incoming, existing } = input;
|
||||
if (!existing || existing.id !== incoming.id) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
diffStat: incoming.diffStat ?? existing.diffStat,
|
||||
};
|
||||
}
|
||||
|
||||
export type ExplorerEntryKind = "file" | "directory";
|
||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
||||
|
||||
@@ -111,6 +111,7 @@ const lightSemanticColors = {
|
||||
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
|
||||
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
|
||||
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
|
||||
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
|
||||
surfaceWorkspace: "#ffffff", // Workspace main background
|
||||
|
||||
@@ -185,6 +186,7 @@ const darkSemanticColors = {
|
||||
surface2: "#272A29", // Elevated: badges, inputs, sheets
|
||||
surface3: "#434645", // Highest elevation
|
||||
surface4: "#595B5B", // Extra emphasis
|
||||
surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||
surfaceSidebar: "#141716", // Sidebar background (darker than main)
|
||||
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
|
||||
|
||||
@@ -279,6 +281,10 @@ const commonTheme = {
|
||||
"4xl": 34,
|
||||
},
|
||||
|
||||
lineHeight: {
|
||||
diff: 22,
|
||||
},
|
||||
|
||||
iconSize: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
|
||||
81
packages/app/src/utils/diff-layout.test.ts
Normal file
81
packages/app/src/utils/diff-layout.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSplitDiffRows } from "./diff-layout";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
|
||||
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
|
||||
return {
|
||||
path: "example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: lines.filter((line) => line.type === "add").length,
|
||||
deletions: lines.filter((line) => line.type === "remove").length,
|
||||
status: "ok",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 10,
|
||||
oldCount: 4,
|
||||
newStart: 10,
|
||||
newCount: 5,
|
||||
lines,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSplitDiffRows", () => {
|
||||
it("pairs replacement runs by index", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,2 +10,2 @@" },
|
||||
{ type: "remove", content: "before one" },
|
||||
{ type: "remove", content: "before two" },
|
||||
{ type: "add", content: "after one" },
|
||||
{ type: "add", content: "after two" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[1]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: { type: "remove", content: "before one", lineNumber: 10 },
|
||||
right: { type: "add", content: "after one", lineNumber: 10 },
|
||||
});
|
||||
expect(rows[2]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: { type: "remove", content: "before two", lineNumber: 11 },
|
||||
right: { type: "add", content: "after two", lineNumber: 11 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unmatched additions on the right side only", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,1 +10,2 @@" },
|
||||
{ type: "remove", content: "before" },
|
||||
{ type: "add", content: "after one" },
|
||||
{ type: "add", content: "after two" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows[2]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: null,
|
||||
right: { type: "add", content: "after two", lineNumber: 11 },
|
||||
});
|
||||
});
|
||||
|
||||
it("duplicates context rows on both sides", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,1 +10,1 @@" },
|
||||
{ type: "context", content: "same line" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows[1]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: { type: "context", content: "same line", lineNumber: 10 },
|
||||
right: { type: "context", content: "same line", lineNumber: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
147
packages/app/src/utils/diff-layout.ts
Normal file
147
packages/app/src/utils/diff-layout.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
|
||||
export interface SplitDiffDisplayLine {
|
||||
type: DiffLine["type"];
|
||||
content: string;
|
||||
tokens?: DiffLine["tokens"];
|
||||
lineNumber: number | null;
|
||||
}
|
||||
|
||||
export type SplitDiffRow =
|
||||
| {
|
||||
kind: "header";
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
kind: "pair";
|
||||
left: SplitDiffDisplayLine | null;
|
||||
right: SplitDiffDisplayLine | null;
|
||||
};
|
||||
|
||||
function toDisplayLine(input: {
|
||||
line: DiffLine;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
side: "left" | "right";
|
||||
}): SplitDiffDisplayLine | null {
|
||||
const { line, oldLineNumber, newLineNumber, side } = input;
|
||||
if (line.type === "header") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.type === "remove") {
|
||||
if (side !== "left") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "remove",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: oldLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
if (line.type === "add") {
|
||||
if (side !== "right") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "add",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: newLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "context",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: side === "left" ? oldLineNumber : newLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
||||
const rows: SplitDiffRow[] = [];
|
||||
|
||||
for (const hunk of file.hunks) {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
rows.push({
|
||||
kind: "header",
|
||||
content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@",
|
||||
});
|
||||
|
||||
let pendingRemovals: Array<{ line: DiffLine; oldLineNumber: number }> = [];
|
||||
let pendingAdditions: Array<{ line: DiffLine; newLineNumber: number }> = [];
|
||||
|
||||
const flushPendingRows = () => {
|
||||
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
|
||||
for (let index = 0; index < pairCount; index += 1) {
|
||||
const removal = pendingRemovals[index] ?? null;
|
||||
const addition = pendingAdditions[index] ?? null;
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: removal
|
||||
? toDisplayLine({
|
||||
line: removal.line,
|
||||
oldLineNumber: removal.oldLineNumber,
|
||||
newLineNumber: null,
|
||||
side: "left",
|
||||
})
|
||||
: null,
|
||||
right: addition
|
||||
? toDisplayLine({
|
||||
line: addition.line,
|
||||
oldLineNumber: null,
|
||||
newLineNumber: addition.newLineNumber,
|
||||
side: "right",
|
||||
})
|
||||
: null,
|
||||
});
|
||||
}
|
||||
pendingRemovals = [];
|
||||
pendingAdditions = [];
|
||||
};
|
||||
|
||||
for (const line of hunk.lines.slice(1)) {
|
||||
if (line.type === "remove") {
|
||||
pendingRemovals.push({ line, oldLineNumber: oldLineNo });
|
||||
oldLineNo += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.type === "add") {
|
||||
pendingAdditions.push({ line, newLineNumber: newLineNo });
|
||||
newLineNo += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushPendingRows();
|
||||
|
||||
if (line.type === "context") {
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: toDisplayLine({
|
||||
line,
|
||||
oldLineNumber: oldLineNo,
|
||||
newLineNumber: newLineNo,
|
||||
side: "left",
|
||||
}),
|
||||
right: toDisplayLine({
|
||||
line,
|
||||
oldLineNumber: oldLineNo,
|
||||
newLineNumber: newLineNo,
|
||||
side: "right",
|
||||
}),
|
||||
});
|
||||
oldLineNo += 1;
|
||||
newLineNo += 1;
|
||||
}
|
||||
}
|
||||
|
||||
flushPendingRows();
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"@getpaseo/relay": "0.1.50",
|
||||
"@getpaseo/server": "0.1.50",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -12,8 +12,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"@getpaseo/cli": "0.1.50",
|
||||
"@getpaseo/server": "0.1.50",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -64,8 +64,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.49",
|
||||
"@getpaseo/relay": "0.1.49",
|
||||
"@getpaseo/highlight": "0.1.50",
|
||||
"@getpaseo/relay": "0.1.50",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
|
||||
@@ -34,6 +34,8 @@ import type {
|
||||
PaseoWorktreeListResponse,
|
||||
PaseoWorktreeArchiveResponse,
|
||||
ProjectIconResponse,
|
||||
ListAvailableEditorsResponseMessage,
|
||||
OpenInEditorResponseMessage,
|
||||
OpenProjectResponseMessage,
|
||||
ArchiveWorkspaceResponseMessage,
|
||||
ListCommandsResponse,
|
||||
@@ -54,6 +56,7 @@ import type {
|
||||
TerminalInput,
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
EditorTargetId,
|
||||
} from "../shared/messages.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
@@ -472,8 +475,11 @@ export type InspectScheduleOptions = {
|
||||
id: string;
|
||||
requestId?: string;
|
||||
};
|
||||
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
|
||||
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
|
||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
||||
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
|
||||
|
||||
export type FetchAgentResult = {
|
||||
agent: AgentSnapshotPayload;
|
||||
@@ -611,7 +617,10 @@ export class DaemonClient {
|
||||
private connectionState: ConnectionState = { status: "idle" };
|
||||
private checkoutDiffSubscriptions = new Map<
|
||||
string,
|
||||
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
|
||||
{
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
}
|
||||
>();
|
||||
private terminalDirectorySubscriptions = new Set<string>();
|
||||
private terminalSlots = new Map<string, number>();
|
||||
@@ -1315,6 +1324,34 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listAvailableEditors(requestId?: string): Promise<ListAvailableEditorsPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "list_available_editors_request",
|
||||
},
|
||||
responseType: "list_available_editors_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async openInEditor(
|
||||
path: string,
|
||||
editorId: EditorTargetId,
|
||||
requestId?: string,
|
||||
): Promise<OpenInEditorPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "open_in_editor_request",
|
||||
path,
|
||||
editorId,
|
||||
},
|
||||
responseType: "open_in_editor_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async archiveWorkspace(
|
||||
workspaceId: string,
|
||||
requestId?: string,
|
||||
@@ -2146,20 +2183,22 @@ export class DaemonClient {
|
||||
private normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string } {
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
if (!trimmedBaseRef) {
|
||||
return { mode: "base" };
|
||||
return { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
return { mode: "base", baseRef: trimmedBaseRef };
|
||||
return { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace };
|
||||
}
|
||||
|
||||
async getCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string },
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
requestId?: string,
|
||||
): Promise<CheckoutDiffPayload> {
|
||||
const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`;
|
||||
@@ -2185,7 +2224,7 @@ export class DaemonClient {
|
||||
|
||||
async subscribeCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string },
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
options?: { subscriptionId?: string; requestId?: string },
|
||||
): Promise<SubscribeCheckoutDiffPayload> {
|
||||
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
|
||||
|
||||
@@ -171,6 +171,9 @@ function sanitizePermissionRequest(
|
||||
if (sanitized.suggestions === undefined) {
|
||||
delete sanitized.suggestions;
|
||||
}
|
||||
if (sanitized.actions === undefined) {
|
||||
delete sanitized.actions;
|
||||
}
|
||||
if (sanitized.metadata === undefined) {
|
||||
delete sanitized.metadata;
|
||||
}
|
||||
|
||||
@@ -2890,6 +2890,213 @@ describe("AgentManager", () => {
|
||||
expect(updatedAgent?.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class RefreshingPermissionSession extends TestAgentSession {
|
||||
private featureState: AgentFeature[] = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: true }),
|
||||
];
|
||||
private modeId = "auto";
|
||||
private pending = [
|
||||
{
|
||||
id: "perm-plan-1",
|
||||
provider: "codex" as const,
|
||||
name: "CodexPlanApproval",
|
||||
kind: "plan" as const,
|
||||
input: { plan: "- Implement the feature" },
|
||||
},
|
||||
];
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return this.featureState;
|
||||
}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: "gpt-5.4",
|
||||
modeId: this.modeId,
|
||||
extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" },
|
||||
};
|
||||
}
|
||||
|
||||
override async getCurrentMode() {
|
||||
return this.modeId;
|
||||
}
|
||||
|
||||
override getPendingPermissions() {
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
override async respondToPermission(): Promise<void> {
|
||||
this.modeId = "auto";
|
||||
this.pending = [];
|
||||
this.featureState = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: false }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class RefreshingPermissionClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new RefreshingPermissionSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new RefreshingPermissionClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000133",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const agent = manager.getAgent(snapshot.id);
|
||||
if (!agent) {
|
||||
throw new Error("Expected managed agent");
|
||||
}
|
||||
agent.pendingPermissions.set("perm-plan-1", {
|
||||
id: "perm-plan-1",
|
||||
provider: "codex",
|
||||
name: "CodexPlanApproval",
|
||||
kind: "plan",
|
||||
input: { plan: "- Implement the feature" },
|
||||
});
|
||||
|
||||
await manager.respondToPermission(snapshot.id, "perm-plan-1", {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement",
|
||||
});
|
||||
|
||||
const updated = manager.getAgent(snapshot.id);
|
||||
expect(updated?.pendingPermissions.size).toBe(0);
|
||||
expect(updated?.features).toEqual([
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: false }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
]);
|
||||
expect(updated?.runtimeInfo).toMatchObject({
|
||||
model: "gpt-5.4",
|
||||
extra: { collaborationMode: "Code" },
|
||||
});
|
||||
|
||||
const persisted = await storage.get(snapshot.id);
|
||||
expect(persisted?.features).toEqual([
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: false }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("respondToPermission emits refreshed state before permission_resolved", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-permission-order-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class OrderedPermissionSession extends TestAgentSession {
|
||||
private featureState: AgentFeature[] = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
];
|
||||
private modeId = "plan";
|
||||
private pending = [
|
||||
{
|
||||
id: "perm-order-1",
|
||||
provider: "codex" as const,
|
||||
name: "ExitPlanMode",
|
||||
kind: "plan" as const,
|
||||
input: { plan: "- Do the work" },
|
||||
},
|
||||
];
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return this.featureState;
|
||||
}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: "gpt-5.4",
|
||||
modeId: this.modeId,
|
||||
};
|
||||
}
|
||||
|
||||
override async getCurrentMode() {
|
||||
return this.modeId;
|
||||
}
|
||||
|
||||
override getPendingPermissions() {
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
override async respondToPermission(): Promise<void> {
|
||||
this.pushEvent({
|
||||
type: "permission_resolved",
|
||||
provider: this.provider,
|
||||
requestId: "perm-order-1",
|
||||
resolution: { behavior: "allow" },
|
||||
});
|
||||
this.modeId = "acceptEdits";
|
||||
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
|
||||
this.pending = [];
|
||||
}
|
||||
}
|
||||
|
||||
class OrderedPermissionClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new OrderedPermissionSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new OrderedPermissionClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000134",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const seen: string[] = [];
|
||||
manager.subscribe((event) => {
|
||||
if ("agentId" in event && event.agentId !== snapshot.id) {
|
||||
return;
|
||||
}
|
||||
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
||||
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
|
||||
seen.push(`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`);
|
||||
return;
|
||||
}
|
||||
if (event.type === "agent_stream" && event.event.type === "permission_resolved") {
|
||||
seen.push(`resolved:${event.event.requestId}`);
|
||||
}
|
||||
});
|
||||
|
||||
await manager.respondToPermission(snapshot.id, "perm-order-1", {
|
||||
behavior: "allow",
|
||||
});
|
||||
|
||||
const refreshedStateIndex = seen.findIndex((entry) => entry === "state:acceptEdits:false");
|
||||
const resolvedIndex = seen.findIndex((entry) => entry === "resolved:perm-order-1");
|
||||
expect(refreshedStateIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(resolvedIndex).toBeGreaterThan(refreshedStateIndex);
|
||||
});
|
||||
|
||||
test("close during in-flight stream does not clear persistence sessionId", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -172,6 +172,11 @@ type ManagedAgentBase = {
|
||||
features?: AgentFeature[];
|
||||
currentModeId: string | null;
|
||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||
bufferedPermissionResolutions: Map<
|
||||
string,
|
||||
Extract<AgentStreamEvent, { type: "permission_resolved" }>
|
||||
>;
|
||||
inFlightPermissionResponses: Set<string>;
|
||||
pendingReplacement: boolean;
|
||||
timeline: AgentTimelineItem[];
|
||||
timelineRows: AgentTimelineRow[];
|
||||
@@ -1437,18 +1442,31 @@ export class AgentManager {
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
await agent.session.respondToPermission(requestId, response);
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
agent.inFlightPermissionResponses.add(requestId);
|
||||
|
||||
// Update currentModeId - the session may have changed mode internally
|
||||
// (e.g., plan approval changes mode from "plan" to "acceptEdits")
|
||||
try {
|
||||
agent.currentModeId = await agent.session.getCurrentMode();
|
||||
} catch {
|
||||
// Ignore errors from getCurrentMode - mode tracking is best effort
|
||||
}
|
||||
await agent.session.respondToPermission(requestId, response);
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
|
||||
this.emitState(agent);
|
||||
try {
|
||||
await this.refreshSessionState(agent);
|
||||
} catch {
|
||||
// Ignore refresh errors - state sync after permission approval is best effort.
|
||||
}
|
||||
|
||||
this.touchUpdatedAt(agent);
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent);
|
||||
|
||||
const bufferedResolution = agent.bufferedPermissionResolutions.get(requestId);
|
||||
if (bufferedResolution) {
|
||||
agent.bufferedPermissionResolutions.delete(requestId);
|
||||
this.dispatchStream(agent.id, bufferedResolution);
|
||||
}
|
||||
} finally {
|
||||
agent.inFlightPermissionResponses.delete(requestId);
|
||||
agent.bufferedPermissionResolutions.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||
@@ -1799,6 +1817,8 @@ export class AgentManager {
|
||||
availableModes: [],
|
||||
currentModeId: null,
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
inFlightPermissionResponses: new Set(),
|
||||
pendingReplacement: false,
|
||||
activeForegroundTurnId: null,
|
||||
foregroundTurnWaiters: new Set(),
|
||||
@@ -2011,6 +2031,7 @@ export class AgentManager {
|
||||
agent.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
this.syncFeaturesFromSession(agent);
|
||||
await this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
@@ -2087,6 +2108,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
let timelineRow: AgentTimelineRow | null = null;
|
||||
let shouldDispatchEvent = true;
|
||||
|
||||
switch (event.type) {
|
||||
case "thread_started":
|
||||
@@ -2259,6 +2281,11 @@ export class AgentManager {
|
||||
break;
|
||||
case "permission_resolved":
|
||||
agent.pendingPermissions.delete(event.requestId);
|
||||
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
|
||||
agent.bufferedPermissionResolutions.set(event.requestId, event);
|
||||
shouldDispatchEvent = false;
|
||||
break;
|
||||
}
|
||||
this.emitState(agent);
|
||||
break;
|
||||
default:
|
||||
@@ -2270,7 +2297,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
// Skip dispatching individual stream events during history replay.
|
||||
if (!options?.fromHistory) {
|
||||
if (!options?.fromHistory && shouldDispatchEvent) {
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
event,
|
||||
@@ -2365,9 +2392,7 @@ export class AgentManager {
|
||||
// Keep attention as an edge-triggered unread signal, not a level signal.
|
||||
this.checkAndSetAttention(agent);
|
||||
|
||||
if (agent.session?.features) {
|
||||
agent.features = agent.session.features;
|
||||
}
|
||||
this.syncFeaturesFromSession(agent);
|
||||
|
||||
this.dispatch({
|
||||
type: "agent_state",
|
||||
@@ -2375,6 +2400,12 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
private syncFeaturesFromSession(agent: ManagedAgent): void {
|
||||
if ("session" in agent && agent.session?.features) {
|
||||
agent.features = agent.session.features;
|
||||
}
|
||||
}
|
||||
|
||||
private checkAndSetAttention(agent: ManagedAgent): void {
|
||||
const previousStatus = this.previousStatuses.get(agent.id);
|
||||
const currentStatus = agent.lifecycle;
|
||||
|
||||
@@ -260,6 +260,34 @@ describe("toAgentPayload", () => {
|
||||
expect(permissionA.title).toBe("Run command");
|
||||
});
|
||||
|
||||
it("omits usage when any numeric usage field is NaN", () => {
|
||||
const fields = [
|
||||
"inputTokens",
|
||||
"cachedInputTokens",
|
||||
"outputTokens",
|
||||
"totalCostUsd",
|
||||
"contextWindowMaxTokens",
|
||||
"contextWindowUsedTokens",
|
||||
] as const;
|
||||
|
||||
for (const field of fields) {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 5,
|
||||
outputTokens: 20,
|
||||
totalCostUsd: 0.5,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 100_000,
|
||||
[field]: Number.NaN,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
expect(payload.lastUsage).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("produces null title and current mode even without overrides", () => {
|
||||
const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null });
|
||||
const payload = toAgentPayload(agent);
|
||||
@@ -303,6 +331,56 @@ describe("toAgentPayload", () => {
|
||||
expect(payload).not.toHaveProperty("lastUsage");
|
||||
});
|
||||
|
||||
it("preserves context window usage fields when they are valid numbers", () => {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 42_000,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload.lastUsage).toEqual({
|
||||
inputTokens: 10,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 42_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits lastUsage when context window usage fields are invalid", () => {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
contextWindowMaxTokens: "200000" as unknown as number,
|
||||
contextWindowUsedTokens: NaN,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload).not.toHaveProperty("lastUsage");
|
||||
});
|
||||
|
||||
it("keeps existing lastUsage behavior when context window fields are absent", () => {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
totalCostUsd: 1.25,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload.lastUsage).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
totalCostUsd: 1.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes features in the snapshot payload", () => {
|
||||
const features = [createFeature()];
|
||||
const agent = createManagedAgent({ features });
|
||||
|
||||
@@ -61,6 +61,7 @@ export function toStoredAgentRecord(
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
runtimeInfo,
|
||||
features: agent.features,
|
||||
persistence,
|
||||
requiresAttention: agent.attention.requiresAttention,
|
||||
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
|
||||
@@ -166,6 +167,7 @@ function sanitizePendingPermissions(
|
||||
...request,
|
||||
input: sanitizeMetadata(request.input),
|
||||
suggestions: sanitizeMetadataArray(request.suggestions),
|
||||
actions: request.actions?.map((action) => ({ ...action })),
|
||||
metadata: sanitizeMetadata(request.metadata),
|
||||
}));
|
||||
}
|
||||
@@ -259,29 +261,41 @@ function sanitizeUsage(value: unknown): AgentUsage | undefined {
|
||||
}
|
||||
const result: AgentUsage = {};
|
||||
const inputTokens = sanitized.inputTokens;
|
||||
if (typeof inputTokens === "number") {
|
||||
if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) {
|
||||
result.inputTokens = inputTokens;
|
||||
} else if (inputTokens !== undefined && inputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const cachedInputTokens = sanitized.cachedInputTokens;
|
||||
if (typeof cachedInputTokens === "number") {
|
||||
if (typeof cachedInputTokens === "number" && Number.isFinite(cachedInputTokens)) {
|
||||
result.cachedInputTokens = cachedInputTokens;
|
||||
} else if (cachedInputTokens !== undefined && cachedInputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const outputTokens = sanitized.outputTokens;
|
||||
if (typeof outputTokens === "number") {
|
||||
if (typeof outputTokens === "number" && Number.isFinite(outputTokens)) {
|
||||
result.outputTokens = outputTokens;
|
||||
} else if (outputTokens !== undefined && outputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const totalCostUsd = sanitized.totalCostUsd;
|
||||
if (typeof totalCostUsd === "number") {
|
||||
if (typeof totalCostUsd === "number" && Number.isFinite(totalCostUsd)) {
|
||||
result.totalCostUsd = totalCostUsd;
|
||||
} else if (totalCostUsd !== undefined && totalCostUsd !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const contextWindowMaxTokens = sanitized.contextWindowMaxTokens;
|
||||
if (typeof contextWindowMaxTokens === "number" && Number.isFinite(contextWindowMaxTokens)) {
|
||||
result.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||
} else if (contextWindowMaxTokens !== undefined && contextWindowMaxTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const contextWindowUsedTokens = sanitized.contextWindowUsedTokens;
|
||||
if (typeof contextWindowUsedTokens === "number" && Number.isFinite(contextWindowUsedTokens)) {
|
||||
result.contextWindowUsedTokens = contextWindowUsedTokens;
|
||||
} else if (contextWindowUsedTokens !== undefined && contextWindowUsedTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,8 @@ export type AgentUsage = {
|
||||
cachedInputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCostUsd?: number;
|
||||
contextWindowMaxTokens?: number;
|
||||
contextWindowUsedTokens?: number;
|
||||
};
|
||||
|
||||
export const TOOL_CALL_ICON_NAMES = [
|
||||
@@ -328,6 +330,14 @@ export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" |
|
||||
|
||||
export type AgentPermissionUpdate = AgentMetadata;
|
||||
|
||||
export type AgentPermissionAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
behavior: "allow" | "deny";
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
intent?: "implement" | "implement_resume" | "dismiss";
|
||||
};
|
||||
|
||||
export type AgentPermissionRequest = {
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
@@ -338,17 +348,20 @@ export type AgentPermissionRequest = {
|
||||
input?: AgentMetadata;
|
||||
detail?: ToolCallDetail;
|
||||
suggestions?: AgentPermissionUpdate[];
|
||||
actions?: AgentPermissionAction[];
|
||||
metadata?: AgentMetadata;
|
||||
};
|
||||
|
||||
export type AgentPermissionResponse =
|
||||
| {
|
||||
behavior: "allow";
|
||||
selectedActionId?: string;
|
||||
updatedInput?: AgentMetadata;
|
||||
updatedPermissions?: AgentPermissionUpdate[];
|
||||
}
|
||||
| {
|
||||
behavior: "deny";
|
||||
selectedActionId?: string;
|
||||
message?: string;
|
||||
interrupt?: boolean;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { AgentStatusSchema } from "../messages.js";
|
||||
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
@@ -56,6 +56,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
extra: z.record(z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
features: z.array(AgentFeatureSchema).optional(),
|
||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||
requiresAttention: z.boolean().optional(),
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
|
||||
@@ -233,6 +233,9 @@ function sanitizePermissionRequest(
|
||||
if (sanitized.suggestions === undefined) {
|
||||
delete sanitized.suggestions;
|
||||
}
|
||||
if (sanitized.actions === undefined) {
|
||||
delete sanitized.actions;
|
||||
}
|
||||
if (sanitized.metadata === undefined) {
|
||||
delete sanitized.metadata;
|
||||
}
|
||||
|
||||
@@ -251,6 +251,51 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh during an in-flight refresh is a no-op", async () => {
|
||||
const fetchModels = deferred<AgentModelDefinition[]>();
|
||||
const fetchModes = deferred<AgentMode[]>();
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async () => fetchModels.promise,
|
||||
fetchModes: async () => fetchModes.promise,
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
const changes: ProviderSnapshotEntry[][] = [];
|
||||
manager.on("change", (entries) => changes.push(entries));
|
||||
|
||||
manager.refresh("/tmp/project");
|
||||
|
||||
expect(manager.getSnapshot("/tmp/project")).toEqual([
|
||||
{ provider: "codex", status: "loading" },
|
||||
]);
|
||||
|
||||
manager.refresh("/tmp/project");
|
||||
manager.refresh("/tmp/project");
|
||||
manager.refresh("/tmp/project");
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
|
||||
fetchModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||
fetchModes.resolve([createMode("auto")]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")).toMatchObject({
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
models: [createModel("codex", "gpt-5.2")],
|
||||
modes: [createMode("auto")],
|
||||
});
|
||||
});
|
||||
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
|
||||
const codexModels = deferred<AgentModelDefinition[]>();
|
||||
const { registry, handles } = createRegistry([
|
||||
|
||||
@@ -32,8 +32,7 @@ export class ProviderSnapshotManager {
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
const entries = this.snapshots.get(cwdKey);
|
||||
if (!entries) {
|
||||
const loadingEntries = this.createLoadingEntries();
|
||||
this.snapshots.set(cwdKey, loadingEntries);
|
||||
const loadingEntries = this.resetSnapshotToLoading(cwdKey);
|
||||
void this.warmUp(cwd);
|
||||
return entriesToArray(loadingEntries);
|
||||
}
|
||||
@@ -42,7 +41,11 @@ export class ProviderSnapshotManager {
|
||||
|
||||
refresh(cwd?: string): void {
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
this.snapshots.set(cwdKey, this.createLoadingEntries());
|
||||
if (this.warmUps.has(cwdKey)) {
|
||||
return;
|
||||
}
|
||||
this.resetSnapshotToLoading(cwdKey);
|
||||
this.emitChange(cwdKey);
|
||||
void this.warmUp(cwd);
|
||||
}
|
||||
|
||||
@@ -170,6 +173,15 @@ export class ProviderSnapshotManager {
|
||||
return created;
|
||||
}
|
||||
|
||||
private resetSnapshotToLoading(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||
snapshot.clear();
|
||||
for (const [provider, entry] of this.createLoadingEntries()) {
|
||||
snapshot.set(provider, entry);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private getProviderIds(): AgentProvider[] {
|
||||
return AGENT_PROVIDER_IDS.filter((provider) => this.providerRegistry[provider]);
|
||||
}
|
||||
|
||||
@@ -747,4 +747,55 @@ describe("ACPAgentSession", () => {
|
||||
});
|
||||
expect((session as any).activeForegroundTurnId).toBeNull();
|
||||
});
|
||||
|
||||
test("auto-approves Copilot ACP permissions in autopilot mode without emitting prompt events", async () => {
|
||||
const session = new ACPAgentSession(
|
||||
{
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
},
|
||||
{
|
||||
provider: "copilot",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["copilot", "--acp"],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const events: Array<{ type: string }> = [];
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string });
|
||||
});
|
||||
|
||||
const response = await session.requestPermission({
|
||||
toolCall: {
|
||||
toolCallId: "tool-1",
|
||||
title: "Edit file",
|
||||
kind: "edit",
|
||||
status: "pending",
|
||||
} as any,
|
||||
options: [
|
||||
{ optionId: "allow-once", name: "Allow Once", kind: "allow_once" },
|
||||
{ optionId: "reject-once", name: "Reject Once", kind: "reject_once" },
|
||||
],
|
||||
} as any);
|
||||
|
||||
expect(response).toEqual({
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: "allow-once",
|
||||
},
|
||||
});
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
expect(events.find((event) => event.type === "permission_requested")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,9 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
|
||||
terminal: true,
|
||||
};
|
||||
|
||||
const COPILOT_AUTOPILOT_MODE =
|
||||
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
|
||||
|
||||
type ACPAgentClientOptions = {
|
||||
provider: string;
|
||||
logger: Logger;
|
||||
@@ -1084,6 +1087,18 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
async requestPermission(
|
||||
params: RequestPermissionRequest,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
if (shouldAutoApprovePermissionRequest(this.provider, this.currentMode)) {
|
||||
const selectedOption = selectPermissionOption(params.options, { behavior: "allow" });
|
||||
return selectedOption
|
||||
? {
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: selectedOption.optionId,
|
||||
},
|
||||
}
|
||||
: { outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
let toolSnapshot =
|
||||
this.toolCalls.get(params.toolCall.toolCallId) ??
|
||||
@@ -1939,6 +1954,10 @@ function mapPermissionRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function shouldAutoApprovePermissionRequest(provider: string, currentMode: string | null): boolean {
|
||||
return provider === "copilot" && currentMode === COPILOT_AUTOPILOT_MODE;
|
||||
}
|
||||
|
||||
function selectPermissionOption(
|
||||
options: PermissionOption[],
|
||||
response: AgentPermissionResponse,
|
||||
|
||||
@@ -835,6 +835,82 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => {
|
||||
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
|
||||
sdkQueryFactory.mockImplementation(() => queryMock);
|
||||
|
||||
const session = await createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
try {
|
||||
await session.setMode("bypassPermissions");
|
||||
await session.setMode("plan");
|
||||
|
||||
const internal = session as unknown as {
|
||||
handlePermissionRequest: (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const pendingResolution = internal.handlePermissionRequest(
|
||||
"ExitPlanMode",
|
||||
{ plan: "- Implement the approved plan" },
|
||||
{},
|
||||
);
|
||||
|
||||
const requestEvent = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested" && event.request.kind === "plan",
|
||||
);
|
||||
|
||||
expect(requestEvent).toBeDefined();
|
||||
expect(requestEvent?.request.actions).toEqual([
|
||||
{
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "implement",
|
||||
label: "Implement",
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
intent: "implement",
|
||||
},
|
||||
{
|
||||
id: "implement_resume",
|
||||
label: "Implement with Bypass",
|
||||
behavior: "allow",
|
||||
variant: "secondary",
|
||||
intent: "implement_resume",
|
||||
},
|
||||
]);
|
||||
|
||||
if (!requestEvent) {
|
||||
throw new Error("Expected plan permission request");
|
||||
}
|
||||
|
||||
await session.respondToPermission(requestEvent.request.id, {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement_resume",
|
||||
});
|
||||
|
||||
await expect(pendingResolution).resolves.toMatchObject({
|
||||
behavior: "allow",
|
||||
updatedInput: { plan: "- Implement the approved plan" },
|
||||
});
|
||||
expect(queryMock.setPermissionMode).toHaveBeenLastCalledWith("bypassPermissions");
|
||||
expect(await session.getCurrentMode()).toBe("bypassPermissions");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
|
||||
@@ -112,6 +112,28 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
expect(mapBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips meta user entries from Claude skill loading", () => {
|
||||
const entry = {
|
||||
type: "user",
|
||||
isMeta: true,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Base directory for this skill: /tmp/skill\n\n# Orchestrate\n\nYou are an end-to-end implementation orchestrator.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue([]);
|
||||
const result = convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mapBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips interrupt placeholder transcript noise", () => {
|
||||
const interruptEntry = {
|
||||
type: "user",
|
||||
@@ -261,3 +283,359 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
expect(defaultModel?.id).toBe("claude-opus-4-6");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeAgentSession context window usage", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
async function createSessionForTest(): Promise<any> {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
return client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
function createQueryFactoryForTurns(turns: Array<Array<Record<string, unknown>>>) {
|
||||
return vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const queuedMessages: Array<Record<string, unknown>> = [];
|
||||
const waiters: Array<() => void> = [];
|
||||
let turnIndex = 0;
|
||||
let closed = false;
|
||||
|
||||
function wakeNextWaiter() {
|
||||
const waiter = waiters.shift();
|
||||
waiter?.();
|
||||
}
|
||||
|
||||
function enqueue(message: Record<string, unknown>) {
|
||||
queuedMessages.push(message);
|
||||
wakeNextWaiter();
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
for await (const _prompt of prompt) {
|
||||
const turnMessages = turns[turnIndex] ?? [];
|
||||
turnIndex += 1;
|
||||
for (const message of turnMessages) {
|
||||
enqueue(message);
|
||||
}
|
||||
}
|
||||
closed = true;
|
||||
wakeNextWaiter();
|
||||
})();
|
||||
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
while (queuedMessages.length === 0 && !closed) {
|
||||
await new Promise<void>((resolve) => {
|
||||
waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
if (queuedMessages.length === 0) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
return { done: false, value: queuedMessages.shift() };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => {
|
||||
closed = true;
|
||||
wakeNextWaiter();
|
||||
return undefined;
|
||||
}),
|
||||
close: vi.fn(() => {
|
||||
closed = true;
|
||||
wakeNextWaiter();
|
||||
}),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => []),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
const usage = session.convertUsage(
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
cache_read_input_tokens: 5,
|
||||
output_tokens: 7,
|
||||
},
|
||||
total_cost_usd: 0.12,
|
||||
},
|
||||
{
|
||||
"claude-sonnet-4-6": { contextWindow: 200_000 },
|
||||
"claude-opus-4-6": { contextWindow: 1_000_000 },
|
||||
},
|
||||
);
|
||||
|
||||
expect(usage).toEqual({
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 5,
|
||||
outputTokens: 7,
|
||||
totalCostUsd: 0.12,
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
contextWindowUsedTokens: 22,
|
||||
});
|
||||
});
|
||||
|
||||
test("contextWindowUsedTokens falls back to result usage when no task_progress was received", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
const usage = session.convertUsage({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
cache_creation_input_tokens: 3,
|
||||
cache_read_input_tokens: 5,
|
||||
output_tokens: 7,
|
||||
},
|
||||
total_cost_usd: 0.12,
|
||||
});
|
||||
|
||||
expect(usage).toEqual({
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 5,
|
||||
outputTokens: 7,
|
||||
totalCostUsd: 0.12,
|
||||
contextWindowUsedTokens: 25,
|
||||
});
|
||||
});
|
||||
|
||||
test("contextWindowUsedTokens is populated from task_progress usage data", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
session.translateMessageToEvents({
|
||||
type: "system",
|
||||
subtype: "task_progress",
|
||||
task_id: "task-1",
|
||||
description: "Processing",
|
||||
usage: {
|
||||
total_tokens: 999,
|
||||
tool_uses: 1,
|
||||
duration_ms: 50,
|
||||
input_tokens: 345,
|
||||
cache_read_input_tokens: 55,
|
||||
},
|
||||
uuid: "task-progress-1",
|
||||
session_id: "session-1",
|
||||
});
|
||||
|
||||
const events = session.translateMessageToEvents({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
duration_ms: 100,
|
||||
duration_api_ms: 75,
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: "done",
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0.25,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
cache_read_input_tokens: 5,
|
||||
output_tokens: 7,
|
||||
},
|
||||
modelUsage: {
|
||||
"claude-sonnet-4-6": { contextWindow: 200_000 },
|
||||
},
|
||||
permission_denials: [],
|
||||
uuid: "result-1",
|
||||
session_id: "session-1",
|
||||
});
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 5,
|
||||
outputTokens: 7,
|
||||
totalCostUsd: 0.25,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 999,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("task_progress usage takes priority over derived result usage", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
session.translateMessageToEvents({
|
||||
type: "system",
|
||||
subtype: "task_progress",
|
||||
task_id: "task-1",
|
||||
description: "Processing",
|
||||
usage: {
|
||||
total_tokens: 999,
|
||||
tool_uses: 1,
|
||||
duration_ms: 50,
|
||||
input_tokens: 345,
|
||||
cache_read_input_tokens: 55,
|
||||
},
|
||||
uuid: "task-progress-1",
|
||||
session_id: "session-1",
|
||||
});
|
||||
|
||||
const usage = session.convertUsage({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
cache_creation_input_tokens: 3,
|
||||
cache_read_input_tokens: 5,
|
||||
output_tokens: 7,
|
||||
},
|
||||
total_cost_usd: 0.12,
|
||||
});
|
||||
|
||||
expect(usage).toEqual({
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 5,
|
||||
outputTokens: 7,
|
||||
totalCostUsd: 0.12,
|
||||
contextWindowUsedTokens: 999,
|
||||
});
|
||||
});
|
||||
|
||||
test("contextWindowUsedTokens persists across turns from last task_progress", async () => {
|
||||
const queryFactory = createQueryFactoryForTurns([
|
||||
[
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "session-1",
|
||||
permissionMode: "default",
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_progress",
|
||||
task_id: "task-1",
|
||||
description: "Processing",
|
||||
usage: {
|
||||
total_tokens: 999,
|
||||
tool_uses: 1,
|
||||
duration_ms: 50,
|
||||
input_tokens: 345,
|
||||
cache_read_input_tokens: 55,
|
||||
},
|
||||
uuid: "task-progress-1",
|
||||
session_id: "session-1",
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
duration_ms: 100,
|
||||
duration_api_ms: 75,
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: "done",
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0.25,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
cache_read_input_tokens: 5,
|
||||
output_tokens: 7,
|
||||
},
|
||||
modelUsage: {
|
||||
"claude-sonnet-4-6": { contextWindow: 200_000 },
|
||||
},
|
||||
permission_denials: [],
|
||||
uuid: "result-1",
|
||||
session_id: "session-1",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
duration_ms: 110,
|
||||
duration_api_ms: 80,
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: "still done",
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0.1,
|
||||
usage: {
|
||||
input_tokens: 11,
|
||||
cache_creation_input_tokens: 3,
|
||||
cache_read_input_tokens: 6,
|
||||
output_tokens: 8,
|
||||
},
|
||||
modelUsage: {
|
||||
"claude-sonnet-4-6": { contextWindow: 200_000 },
|
||||
},
|
||||
permission_denials: [],
|
||||
uuid: "result-2",
|
||||
session_id: "session-1",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const client = new ClaudeAgentClient({ logger, queryFactory });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
try {
|
||||
const firstTurn = await session.run("turn 1");
|
||||
const secondTurn = await session.run("turn 2");
|
||||
|
||||
expect(firstTurn.usage).toEqual({
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 5,
|
||||
outputTokens: 7,
|
||||
totalCostUsd: 0.25,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 999,
|
||||
});
|
||||
// Turn 2 has no task_progress, so contextWindowUsedTokens retains the
|
||||
// last known value from turn 1 rather than deriving from accumulated
|
||||
// result.usage (which would be incorrect — those are session-level totals).
|
||||
expect(secondTurn.usage).toEqual({
|
||||
inputTokens: 11,
|
||||
cachedInputTokens: 6,
|
||||
outputTokens: 8,
|
||||
totalCostUsd: 0.1,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 999,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("convertUsage derives used tokens from result usage as fallback when task_progress is missing", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
const usage = session.convertUsage({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
cache_read_input_tokens: 5,
|
||||
output_tokens: 7,
|
||||
},
|
||||
total_cost_usd: 0.12,
|
||||
});
|
||||
|
||||
expect(usage).toEqual({
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 5,
|
||||
outputTokens: 7,
|
||||
totalCostUsd: 0.12,
|
||||
contextWindowUsedTokens: 22,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type SpawnOptions,
|
||||
type SDKMessage,
|
||||
type SDKPartialAssistantMessage,
|
||||
type SDKTaskProgressMessage,
|
||||
type SDKResultMessage,
|
||||
type SDKSystemMessage,
|
||||
type SDKUserMessage,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
} from "./diagnostic-utils.js";
|
||||
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
@@ -669,6 +671,43 @@ function resolvePermissionKind(
|
||||
return "tool";
|
||||
}
|
||||
|
||||
function getClaudeModeLabel(modeId: PermissionMode): string {
|
||||
return DEFAULT_MODES.find((mode) => mode.id === modeId)?.label ?? modeId;
|
||||
}
|
||||
|
||||
function buildClaudePlanPermissionActions(
|
||||
resumeMode: PermissionMode | null,
|
||||
): AgentPermissionAction[] {
|
||||
const actions: AgentPermissionAction[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "implement",
|
||||
label: "Implement",
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
intent: "implement",
|
||||
},
|
||||
];
|
||||
|
||||
if (resumeMode === "bypassPermissions") {
|
||||
actions.push({
|
||||
id: "implement_resume",
|
||||
label: `Implement with ${getClaudeModeLabel(resumeMode)}`,
|
||||
behavior: "allow",
|
||||
variant: "secondary",
|
||||
intent: "implement_resume",
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
type TimelineFragment = {
|
||||
kind: "assistant" | "reasoning";
|
||||
text: string;
|
||||
@@ -967,7 +1006,8 @@ function isSyntheticUserEntry(entry: unknown): boolean {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
return (entry as { isSynthetic?: unknown }).isSynthetic === true;
|
||||
const candidate = entry as { isSynthetic?: unknown; isMeta?: unknown };
|
||||
return candidate.isSynthetic === true || candidate.isMeta === true;
|
||||
}
|
||||
|
||||
export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
||||
@@ -1161,6 +1201,40 @@ function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string
|
||||
}
|
||||
}
|
||||
|
||||
function extractContextWindowSize(modelUsage: unknown): number | undefined {
|
||||
if (!modelUsage || typeof modelUsage !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let maxContextWindow: number | undefined;
|
||||
for (const value of Object.values(modelUsage as Record<string, unknown>)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const contextWindow = (value as { contextWindow?: unknown }).contextWindow;
|
||||
if (
|
||||
typeof contextWindow !== "number" ||
|
||||
!Number.isFinite(contextWindow) ||
|
||||
contextWindow <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);
|
||||
}
|
||||
|
||||
return maxContextWindow;
|
||||
}
|
||||
|
||||
function readContextWindowUsedTokensFromTaskProgress(
|
||||
message: SDKTaskProgressMessage,
|
||||
): number | undefined {
|
||||
const totalTokens = message.usage?.total_tokens;
|
||||
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return totalTokens;
|
||||
}
|
||||
|
||||
class ClaudeAgentSession implements AgentSession {
|
||||
readonly provider: "claude" = "claude";
|
||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||
@@ -1176,6 +1250,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private claudeSessionId: string | null;
|
||||
private persistence: AgentPersistenceHandle | null;
|
||||
private currentMode: PermissionMode;
|
||||
private planResumeMode: PermissionMode | null = null;
|
||||
private availableModes: AgentMode[] = DEFAULT_MODES;
|
||||
private toolUseCache = new Map<string, ToolUseCacheEntry>();
|
||||
private toolUseIndexToId = new Map<number, string>();
|
||||
@@ -1202,6 +1277,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private pendingInterruptAbort = false;
|
||||
private lastForegroundPromptText: string | null = null;
|
||||
private foregroundHasVisibleActivity = false;
|
||||
private lastContextWindowUsedTokens: number | undefined;
|
||||
private userMessageIds: string[] = [];
|
||||
private recentStderr = "";
|
||||
private closed = false;
|
||||
@@ -1236,6 +1312,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default";
|
||||
if (this.currentMode !== "plan") {
|
||||
this.planResumeMode = this.currentMode;
|
||||
}
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
@@ -1477,8 +1556,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const normalized = isPermissionMode(modeId) ? modeId : "default";
|
||||
const previousMode = this.currentMode;
|
||||
const query = await this.ensureQuery();
|
||||
await query.setPermissionMode(normalized);
|
||||
if (normalized === "plan") {
|
||||
if (previousMode !== "plan") {
|
||||
this.planResumeMode = previousMode;
|
||||
}
|
||||
} else {
|
||||
this.planResumeMode = normalized;
|
||||
}
|
||||
this.currentMode = normalized;
|
||||
}
|
||||
|
||||
@@ -1525,13 +1612,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
if (response.behavior === "allow") {
|
||||
if (pending.request.kind === "plan") {
|
||||
await this.setMode("acceptEdits");
|
||||
const selectedActionId = response.selectedActionId;
|
||||
const shouldResumePriorMode =
|
||||
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
|
||||
const targetMode: PermissionMode = shouldResumePriorMode
|
||||
? "bypassPermissions"
|
||||
: "acceptEdits";
|
||||
await this.setMode(targetMode);
|
||||
this.pushToolCall(
|
||||
mapClaudeCompletedToolCall({
|
||||
name: "plan_approval",
|
||||
callId: pending.request.id,
|
||||
input: pending.request.input ?? null,
|
||||
output: { approved: true },
|
||||
output: {
|
||||
approved: true,
|
||||
actionId: selectedActionId ?? "implement",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1848,6 +1944,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.input = null;
|
||||
this.queryPumpPromise = null;
|
||||
this.queryRestartNeeded = false;
|
||||
// Reset session identity for explicit restarts so the new query starts
|
||||
// a fresh session rather than resuming the previous one.
|
||||
this.claudeSessionId = null;
|
||||
oldInput?.end();
|
||||
oldQuery.close?.();
|
||||
try {
|
||||
@@ -1857,6 +1956,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
// When the pump died unexpectedly (query became null, e.g. after a session
|
||||
// ID overwrite error), preserve claudeSessionId so buildOptions() passes
|
||||
// resume: sessionId and the new query auto-resumes the previous session.
|
||||
// For explicit restarts above, claudeSessionId was already cleared.
|
||||
this.persistence = null;
|
||||
|
||||
const input = createAsyncMessageInput<SDKUserMessage>();
|
||||
const options = this.buildOptions();
|
||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||
@@ -2567,6 +2672,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
provider: "claude",
|
||||
});
|
||||
}
|
||||
} else if (message.subtype === "task_progress") {
|
||||
this.lastContextWindowUsedTokens =
|
||||
readContextWindowUsedTokensFromTaskProgress(message) ??
|
||||
this.lastContextWindowUsedTokens;
|
||||
}
|
||||
break;
|
||||
case "user": {
|
||||
@@ -2644,7 +2753,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
break;
|
||||
}
|
||||
case "result": {
|
||||
const usage = this.convertUsage(message);
|
||||
const usage = this.convertUsage(message, message.modelUsage);
|
||||
if (message.subtype === "success") {
|
||||
events.push({ type: "turn_completed", provider: "claude", usage });
|
||||
} else {
|
||||
@@ -2689,11 +2798,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (this.claudeSessionId === sessionId) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(
|
||||
`CRITICAL: Claude session ID overwrite detected! ` +
|
||||
`Existing: ${this.claudeSessionId}, New: ${sessionId}. ` +
|
||||
`This indicates a session identity corruption bug.`,
|
||||
// Session ID changed mid-stream (e.g. a hook caused Claude to restart
|
||||
// with a new session). Accept the new ID and continue — the turn should
|
||||
// not be failed just because the underlying subprocess cycled.
|
||||
this.logger.warn(
|
||||
{ existingSessionId: this.claudeSessionId, newSessionId: sessionId },
|
||||
"Claude session ID changed in message; accepting new session",
|
||||
);
|
||||
this.claudeSessionId = sessionId;
|
||||
this.persistence = null;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
private handleSystemMessage(message: SDKSystemMessage): string | null {
|
||||
@@ -2728,14 +2842,20 @@ class ClaudeAgentSession implements AgentSession {
|
||||
} else if (existingSessionId === newSessionId) {
|
||||
this.logger.debug({ sessionId: newSessionId }, "Claude session ID unchanged (same value)");
|
||||
} else {
|
||||
throw new Error(
|
||||
`CRITICAL: Claude session ID overwrite detected! ` +
|
||||
`Existing: ${existingSessionId}, New: ${newSessionId}. ` +
|
||||
`This indicates a session identity corruption bug.`,
|
||||
// Session ID changed in an init message (e.g. a hook restarted Claude
|
||||
// with a new session mid-turn). Accept the new ID and continue.
|
||||
this.logger.warn(
|
||||
{ existingSessionId, newSessionId },
|
||||
"Claude session ID changed in init message; accepting new session",
|
||||
);
|
||||
this.claudeSessionId = newSessionId;
|
||||
threadStartedSessionId = newSessionId;
|
||||
}
|
||||
this.availableModes = DEFAULT_MODES;
|
||||
this.currentMode = message.permissionMode;
|
||||
if (this.currentMode !== "plan") {
|
||||
this.planResumeMode = this.currentMode;
|
||||
}
|
||||
this.persistence = null;
|
||||
if (message.model) {
|
||||
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
|
||||
@@ -2777,16 +2897,44 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return null;
|
||||
}
|
||||
|
||||
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
|
||||
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
|
||||
if (!message.usage) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
const usage: AgentUsage = {
|
||||
inputTokens: message.usage.input_tokens,
|
||||
cachedInputTokens: message.usage.cache_read_input_tokens,
|
||||
outputTokens: message.usage.output_tokens,
|
||||
totalCostUsd: message.total_cost_usd,
|
||||
};
|
||||
const contextWindowMaxTokens = extractContextWindowSize(
|
||||
modelUsage ?? message.modelUsage,
|
||||
);
|
||||
if (contextWindowMaxTokens !== undefined) {
|
||||
usage.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||
}
|
||||
if (typeof this.lastContextWindowUsedTokens === "number") {
|
||||
// task_progress.total_tokens is the accurate context window fill level.
|
||||
// Prefer it over result.usage which contains accumulated session totals.
|
||||
usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens;
|
||||
} else if (message.usage) {
|
||||
// Fallback: derive from result.usage when no task_progress has been
|
||||
// received yet. These values are accumulated across all API calls, but
|
||||
// for the first turn they equal the per-call values so the estimate is
|
||||
// reasonable. Once a task_progress arrives it takes over permanently.
|
||||
const usageWithCacheCreation = message.usage as typeof message.usage & {
|
||||
cache_creation_input_tokens?: number;
|
||||
};
|
||||
const derived =
|
||||
(message.usage.input_tokens ?? 0) +
|
||||
(usageWithCacheCreation.cache_creation_input_tokens ?? 0) +
|
||||
(message.usage.cache_read_input_tokens ?? 0) +
|
||||
(message.usage.output_tokens ?? 0);
|
||||
if (Number.isFinite(derived) && derived > 0) {
|
||||
usage.contextWindowUsedTokens = derived;
|
||||
}
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
private handlePermissionRequest: CanUseTool = async (
|
||||
@@ -2821,6 +2969,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
input,
|
||||
detail: toolDetail,
|
||||
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
|
||||
actions:
|
||||
kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
|
||||
metadata: Object.keys(metadata).length ? metadata : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
|
||||
import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
@@ -40,6 +40,183 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
|
||||
describe("Codex app-server provider", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("extracts context window usage from snake_case token payloads", () => {
|
||||
expect(
|
||||
__codexAppServerInternals.toAgentUsage({
|
||||
model_context_window: 200000,
|
||||
last: {
|
||||
total_tokens: 50000,
|
||||
inputTokens: 30000,
|
||||
cachedInputTokens: 5000,
|
||||
outputTokens: 15000,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 30000,
|
||||
cachedInputTokens: 5000,
|
||||
outputTokens: 15000,
|
||||
contextWindowMaxTokens: 200000,
|
||||
contextWindowUsedTokens: 50000,
|
||||
});
|
||||
});
|
||||
|
||||
test("extracts context window usage from camelCase token payloads", () => {
|
||||
expect(
|
||||
__codexAppServerInternals.toAgentUsage({
|
||||
modelContextWindow: 200000,
|
||||
last: {
|
||||
totalTokens: 50000,
|
||||
inputTokens: 30000,
|
||||
cachedInputTokens: 5000,
|
||||
outputTokens: 15000,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 30000,
|
||||
cachedInputTokens: 5000,
|
||||
outputTokens: 15000,
|
||||
contextWindowMaxTokens: 200000,
|
||||
contextWindowUsedTokens: 50000,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps existing usage behavior when context window fields are missing", () => {
|
||||
expect(
|
||||
__codexAppServerInternals.toAgentUsage({
|
||||
last: {
|
||||
inputTokens: 30000,
|
||||
cachedInputTokens: 5000,
|
||||
outputTokens: 15000,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 30000,
|
||||
cachedInputTokens: 5000,
|
||||
outputTokens: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
test("excludes invalid context window values", () => {
|
||||
expect(
|
||||
__codexAppServerInternals.toAgentUsage({
|
||||
model_context_window: Number.NaN,
|
||||
modelContextWindow: "200000",
|
||||
last: {
|
||||
total_tokens: Number.NaN,
|
||||
totalTokens: "50000",
|
||||
inputTokens: 30000,
|
||||
cachedInputTokens: 5000,
|
||||
outputTokens: 15000,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 30000,
|
||||
cachedInputTokens: 5000,
|
||||
outputTokens: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes raw output schemas for Codex structured outputs", () => {
|
||||
const input = {
|
||||
type: "object",
|
||||
properties: {
|
||||
findings: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
severity: { type: "string" },
|
||||
summary: { type: "string" },
|
||||
},
|
||||
required: ["severity"],
|
||||
},
|
||||
},
|
||||
overall: { type: "string" },
|
||||
},
|
||||
required: ["overall"],
|
||||
};
|
||||
|
||||
const normalized = __codexAppServerInternals.normalizeCodexOutputSchema(input);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
findings: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
severity: { type: "string" },
|
||||
summary: { type: "string" },
|
||||
},
|
||||
required: ["severity", "summary"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
overall: { type: "string" },
|
||||
},
|
||||
required: ["overall", "findings"],
|
||||
additionalProperties: false,
|
||||
});
|
||||
expect(input).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
findings: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
severity: { type: "string" },
|
||||
summary: { type: "string" },
|
||||
},
|
||||
required: ["severity"],
|
||||
},
|
||||
},
|
||||
overall: { type: "string" },
|
||||
},
|
||||
required: ["overall"],
|
||||
});
|
||||
});
|
||||
|
||||
test("passes a normalized output schema to turn/start", async () => {
|
||||
const session = createSession();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: ["test-thread"] };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = { request } as any;
|
||||
|
||||
await session.startTurn("Return JSON", {
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
summary: { type: "string" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
|
||||
expect(turnStartCall?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
summary: { type: "string" },
|
||||
},
|
||||
required: ["summary"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("maps image prompt blocks to Codex localImage input", async () => {
|
||||
const input = await codexAppServerTurnInputFromPrompt(
|
||||
[
|
||||
@@ -345,4 +522,165 @@ describe("Codex app-server provider", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("emits a synthetic plan approval permission after a successful Codex plan turn", () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
(session as any).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-1" },
|
||||
});
|
||||
(session as any).handleNotification("turn/plan/updated", {
|
||||
plan: [
|
||||
{ step: "Inspect the existing auth flow", status: "completed" },
|
||||
{ step: "Implement the button behavior", status: "pending" },
|
||||
],
|
||||
});
|
||||
(session as any).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
expect(events.at(-2)).toEqual({
|
||||
type: "permission_requested",
|
||||
provider: "codex",
|
||||
turnId: "test-turn",
|
||||
request: expect.objectContaining({
|
||||
provider: "codex",
|
||||
name: "CodexPlanApproval",
|
||||
kind: "plan",
|
||||
title: "Plan",
|
||||
input: {
|
||||
plan: "- Inspect the existing auth flow\n- Implement the button behavior",
|
||||
},
|
||||
actions: [
|
||||
expect.objectContaining({
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
behavior: "deny",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "implement",
|
||||
label: "Implement",
|
||||
behavior: "allow",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: "turn_completed",
|
||||
provider: "codex",
|
||||
turnId: "test-turn",
|
||||
usage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("approving a synthetic Codex plan permission disables plan and fast mode and starts implementation", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
const startTurnSpy = vi
|
||||
.spyOn(session, "startTurn")
|
||||
.mockResolvedValue({ turnId: "follow-up-turn" });
|
||||
|
||||
(session as any).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-2" },
|
||||
});
|
||||
(session as any).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the new flow", status: "pending" }],
|
||||
});
|
||||
(session as any).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
const request = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested" && event.request.kind === "plan",
|
||||
);
|
||||
expect(request).toBeDefined();
|
||||
if (!request) {
|
||||
throw new Error("Expected synthetic plan approval permission");
|
||||
}
|
||||
|
||||
await session.respondToPermission(request.request.id, {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement",
|
||||
});
|
||||
|
||||
expect((session as any).serviceTier).toBeNull();
|
||||
expect((session as any).planModeEnabled).toBe(false);
|
||||
expect((session as any).config.featureValues).toEqual({
|
||||
plan_mode: false,
|
||||
fast_mode: false,
|
||||
});
|
||||
expect(startTurnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("The user approved the plan. Implement it now."),
|
||||
);
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: "permission_resolved",
|
||||
provider: "codex",
|
||||
requestId: request.request.id,
|
||||
resolution: {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("failed synthetic Codex plan implementation keeps the permission pending for retry", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
const startTurnSpy = vi
|
||||
.spyOn(session, "startTurn")
|
||||
.mockRejectedValueOnce(new Error("follow-up failed"));
|
||||
|
||||
(session as any).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-retry" },
|
||||
});
|
||||
(session as any).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the retriable flow", status: "pending" }],
|
||||
});
|
||||
(session as any).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
const request = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested" && event.request.kind === "plan",
|
||||
);
|
||||
expect(request).toBeDefined();
|
||||
if (!request) {
|
||||
throw new Error("Expected synthetic plan approval permission");
|
||||
}
|
||||
|
||||
await expect(
|
||||
session.respondToPermission(request.request.id, {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement",
|
||||
}),
|
||||
).rejects.toThrow("follow-up failed");
|
||||
|
||||
expect(startTurnSpy).toHaveBeenCalledTimes(1);
|
||||
expect((session as any).planModeEnabled).toBe(true);
|
||||
expect((session as any).config.featureValues).toEqual({
|
||||
plan_mode: true,
|
||||
fast_mode: true,
|
||||
});
|
||||
expect(session.getPendingPermissions()).toEqual([request.request]);
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "permission_resolved" && event.requestId === request.request.id,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentFeature,
|
||||
@@ -64,6 +65,8 @@ const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||
const CODEX_PROVIDER = "codex" as const;
|
||||
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
||||
const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX =
|
||||
"The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked.";
|
||||
|
||||
const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -143,6 +146,73 @@ function normalizeCodexModelLabel(displayName: string): string {
|
||||
return displayName.replace(/\bgpt\b/gi, "GPT");
|
||||
}
|
||||
|
||||
function isSchemaRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isObjectSchemaNode(schema: Record<string, unknown>): boolean {
|
||||
const type = schema.type;
|
||||
return (
|
||||
isSchemaRecord(schema.properties) ||
|
||||
type === "object" ||
|
||||
(Array.isArray(type) && type.includes("object"))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCodexOutputSchemaNode(
|
||||
schema: unknown,
|
||||
path: string,
|
||||
): unknown {
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map((entry, index) => normalizeCodexOutputSchemaNode(entry, `${path}[${index}]`));
|
||||
}
|
||||
if (!isSchemaRecord(schema)) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
normalized[key] = normalizeCodexOutputSchemaNode(value, `${path}.${key}`);
|
||||
}
|
||||
|
||||
if (!isObjectSchemaNode(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized.additionalProperties === undefined) {
|
||||
normalized.additionalProperties = false;
|
||||
} else if (normalized.additionalProperties !== false) {
|
||||
throw new Error(
|
||||
`Codex structured outputs require ${path} to set additionalProperties to false for object schemas.`,
|
||||
);
|
||||
}
|
||||
|
||||
const properties = isSchemaRecord(normalized.properties) ? normalized.properties : null;
|
||||
if (!properties) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const propertyKeys = Object.keys(properties);
|
||||
const existingRequired = Array.isArray(normalized.required)
|
||||
? normalized.required.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
normalized.required = Array.from(new Set([...existingRequired, ...propertyKeys]));
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeCodexOutputSchema(schema: unknown): Record<string, unknown> {
|
||||
if (!isSchemaRecord(schema)) {
|
||||
throw new Error("Codex structured outputs require a JSON object schema.");
|
||||
}
|
||||
|
||||
const normalized = normalizeCodexOutputSchemaNode(schema, "$");
|
||||
if (!isSchemaRecord(normalized) || !isObjectSchemaNode(normalized)) {
|
||||
throw new Error("Codex structured outputs require a root object schema.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
type CodexConfiguredDefaults = {
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
@@ -677,12 +747,42 @@ function terminateChildProcessTree(child: ChildProcessWithoutNullStreams): void
|
||||
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
||||
if (!tokenUsage || typeof tokenUsage !== "object") return undefined;
|
||||
const usage = tokenUsage as {
|
||||
last?: { inputTokens?: number; cachedInputTokens?: number; outputTokens?: number };
|
||||
model_context_window?: number;
|
||||
modelContextWindow?: number;
|
||||
last?: {
|
||||
inputTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
outputTokens?: number;
|
||||
total_tokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
};
|
||||
const contextWindowMaxTokens =
|
||||
typeof usage.model_context_window === "number" &&
|
||||
Number.isFinite(usage.model_context_window) &&
|
||||
usage.model_context_window > 0
|
||||
? usage.model_context_window
|
||||
: typeof usage.modelContextWindow === "number" &&
|
||||
Number.isFinite(usage.modelContextWindow) &&
|
||||
usage.modelContextWindow > 0
|
||||
? usage.modelContextWindow
|
||||
: undefined;
|
||||
const contextWindowUsedTokens =
|
||||
typeof usage.last?.total_tokens === "number" &&
|
||||
Number.isFinite(usage.last.total_tokens) &&
|
||||
usage.last.total_tokens > 0
|
||||
? usage.last.total_tokens
|
||||
: typeof usage.last?.totalTokens === "number" &&
|
||||
Number.isFinite(usage.last.totalTokens) &&
|
||||
usage.last.totalTokens > 0
|
||||
? usage.last.totalTokens
|
||||
: undefined;
|
||||
return {
|
||||
inputTokens: usage.last?.inputTokens,
|
||||
cachedInputTokens: usage.last?.cachedInputTokens,
|
||||
outputTokens: usage.last?.outputTokens,
|
||||
...(contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}),
|
||||
...(contextWindowUsedTokens !== undefined ? { contextWindowUsedTokens } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -739,6 +839,53 @@ function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolC
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlanPermissionActions(
|
||||
options?: { includeResumeAction?: boolean; resumeLabel?: string },
|
||||
): AgentPermissionAction[] {
|
||||
const actions: AgentPermissionAction[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "implement",
|
||||
label: "Implement",
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
intent: "implement",
|
||||
},
|
||||
];
|
||||
|
||||
if (options?.includeResumeAction && options.resumeLabel) {
|
||||
actions.push({
|
||||
id: "implement_resume",
|
||||
label: options.resumeLabel,
|
||||
behavior: "allow",
|
||||
variant: "secondary",
|
||||
intent: "implement_resume",
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function buildCodexPlanImplementationPrompt(planText: string): string {
|
||||
const normalizedPlan = normalizePlanMarkdown(planText);
|
||||
if (!normalizedPlan) {
|
||||
return `${CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX} Make the required code changes and verify them.`;
|
||||
}
|
||||
|
||||
return [
|
||||
CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX,
|
||||
"Approved plan:",
|
||||
normalizedPlan,
|
||||
"Carry out the work, make the necessary code changes, and verify the result.",
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
type CodexQuestionOption = {
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -2269,8 +2416,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "question";
|
||||
kind: "command" | "file" | "question" | "plan";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
planText?: string;
|
||||
}
|
||||
>();
|
||||
private resolvedPermissionRequests = new Set<string>();
|
||||
@@ -2289,6 +2437,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private warnedInvalidNotificationPayloads = new Set<string>();
|
||||
private warnedIncompleteEditToolCallIds = new Set<string>();
|
||||
private latestUsage: AgentUsage | undefined;
|
||||
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
|
||||
private connected = false;
|
||||
private collaborationModes: Array<{
|
||||
name: string;
|
||||
@@ -2472,6 +2621,79 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
this.resolvedCollaborationMode = this.resolveCollaborationMode();
|
||||
}
|
||||
|
||||
private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void {
|
||||
this.config.featureValues = {
|
||||
...(this.config.featureValues ?? {}),
|
||||
[featureId]: value,
|
||||
};
|
||||
|
||||
if (featureId === "fast_mode") {
|
||||
this.serviceTier = value ? "fast" : null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.planModeEnabled = value;
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
|
||||
private rememberPlanResult(item: ToolCallTimelineItem): void {
|
||||
if (item.detail.type !== "plan") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.latestPlanResult = {
|
||||
callId: item.callId,
|
||||
text: item.detail.text,
|
||||
turnId: this.currentTurnId,
|
||||
};
|
||||
}
|
||||
|
||||
private emitSyntheticPlanApprovalRequest(planText: string): void {
|
||||
const requestId = `permission-${randomUUID()}`;
|
||||
const request: AgentPermissionRequest = {
|
||||
id: requestId,
|
||||
provider: CODEX_PROVIDER,
|
||||
name: "CodexPlanApproval",
|
||||
kind: "plan",
|
||||
title: "Plan",
|
||||
description: "Review the proposed plan before implementation starts.",
|
||||
input: { plan: planText },
|
||||
actions: buildPlanPermissionActions(),
|
||||
metadata: {
|
||||
planText,
|
||||
source: "codex_plan_approval",
|
||||
},
|
||||
};
|
||||
|
||||
this.pendingPermissions.set(requestId, request);
|
||||
this.pendingPermissionHandlers.set(requestId, {
|
||||
resolve: () => undefined,
|
||||
kind: "plan",
|
||||
planText,
|
||||
});
|
||||
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
||||
}
|
||||
|
||||
private async handleApprovedPlanPermission(params: { planText?: unknown }): Promise<void> {
|
||||
const planText =
|
||||
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
|
||||
const previousPlanMode = this.planModeEnabled;
|
||||
const previousFastMode = this.serviceTier === "fast";
|
||||
|
||||
this.applyFeatureValue("plan_mode", false);
|
||||
this.applyFeatureValue("fast_mode", false);
|
||||
|
||||
try {
|
||||
await this.startTurn(buildCodexPlanImplementationPrompt(planText));
|
||||
} catch (error) {
|
||||
this.applyFeatureValue("plan_mode", previousPlanMode);
|
||||
this.applyFeatureValue("fast_mode", previousFastMode);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private registerRequestHandlers(): void {
|
||||
if (!this.client) return;
|
||||
|
||||
@@ -2774,7 +2996,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
params.cwd = this.config.cwd;
|
||||
}
|
||||
if (options?.outputSchema) {
|
||||
params.outputSchema = options.outputSchema;
|
||||
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
|
||||
}
|
||||
if (this.config.systemPrompt?.trim()) {
|
||||
params.developerInstructions = this.config.systemPrompt.trim();
|
||||
@@ -2869,14 +3091,11 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (featureId === "fast_mode") {
|
||||
this.serviceTier = value ? "fast" : null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.applyFeatureValue("fast_mode", Boolean(value));
|
||||
return;
|
||||
}
|
||||
if (featureId === "plan_mode") {
|
||||
this.planModeEnabled = Boolean(value);
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.applyFeatureValue("plan_mode", Boolean(value));
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown Codex feature: ${featureId}`);
|
||||
@@ -2892,6 +3111,26 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
|
||||
}
|
||||
const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
|
||||
|
||||
if (pending.kind === "plan") {
|
||||
if (response.behavior === "allow") {
|
||||
await this.handleApprovedPlanPermission({
|
||||
planText: pending.planText ?? pendingRequest?.metadata?.planText,
|
||||
});
|
||||
}
|
||||
|
||||
this.pendingPermissionHandlers.delete(requestId);
|
||||
this.pendingPermissions.delete(requestId);
|
||||
this.resolvedPermissionRequests.add(requestId);
|
||||
this.emitEvent({
|
||||
type: "permission_resolved",
|
||||
provider: CODEX_PROVIDER,
|
||||
requestId,
|
||||
resolution: response,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingPermissionHandlers.delete(requestId);
|
||||
this.pendingPermissions.delete(requestId);
|
||||
this.resolvedPermissionRequests.add(requestId);
|
||||
@@ -3193,6 +3432,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
if (parsed.kind === "turn_started") {
|
||||
this.currentTurnId = parsed.turnId;
|
||||
this.latestPlanResult = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.emittedExecCommandStartedCallIds.clear();
|
||||
@@ -3214,6 +3454,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
} else if (parsed.status === "interrupted") {
|
||||
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
||||
} else {
|
||||
if (this.planModeEnabled && this.latestPlanResult?.text) {
|
||||
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
|
||||
}
|
||||
this.emitEvent({
|
||||
type: "turn_completed",
|
||||
provider: CODEX_PROVIDER,
|
||||
@@ -3221,6 +3464,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
this.activeForegroundTurnId = null;
|
||||
this.latestPlanResult = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.emittedExecCommandStartedCallIds.clear();
|
||||
@@ -3242,6 +3486,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
),
|
||||
});
|
||||
if (timelineItem) {
|
||||
this.rememberPlanResult(timelineItem);
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
@@ -3435,6 +3680,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
if (timelineItem.type === "tool_call") {
|
||||
if (timelineItem.detail.type === "plan") {
|
||||
this.rememberPlanResult(timelineItem);
|
||||
}
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
@@ -4008,6 +4256,8 @@ export const __codexAppServerInternals = {
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
planStepsToMarkdown,
|
||||
mapCodexPlanToToolCall,
|
||||
normalizeCodexOutputSchema,
|
||||
normalizeCodexQuestionPrompts,
|
||||
toAgentUsage,
|
||||
threadItemToTimeline,
|
||||
};
|
||||
|
||||
@@ -539,6 +539,26 @@ describe("codex tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes codex paseo_voice.speak mcp calls and extracts spoken text", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "mcpToolCall",
|
||||
id: "codex-speak-thread-2",
|
||||
status: "completed",
|
||||
server: "paseo_voice",
|
||||
tool: "speak",
|
||||
arguments: { text: "Voice response from Codex via paseo_voice." },
|
||||
result: { ok: true },
|
||||
});
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
expect(item?.name).toBe("speak");
|
||||
expect(item?.detail).toEqual({
|
||||
type: "unknown",
|
||||
input: "Voice response from Codex via paseo_voice.",
|
||||
output: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes codex paseo speak rollout names and extracts spoken text", () => {
|
||||
const item = expectMapped(
|
||||
mapCodexRolloutToolCall({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { extractCodexShellOutput, truncateDiffText } from "../tool-call-mapper-utils.js";
|
||||
import { deriveCodexToolDetail, normalizeCodexFilePath } from "./tool-call-detail-parser.js";
|
||||
import { isSpeakToolName } from "../../tool-name-normalization.js";
|
||||
|
||||
type CodexMapperOptions = { cwd?: string | null };
|
||||
|
||||
@@ -67,7 +68,7 @@ const CodexEditToolNameSchema = z.union([
|
||||
z.literal("apply_diff"),
|
||||
]);
|
||||
const CodexSearchToolNameSchema = z.union([z.literal("search"), z.literal("web_search")]);
|
||||
const CodexSpeakToolNameSchema = z.literal("paseo.speak");
|
||||
const CodexSpeakToolNameSchema = z.string().min(1).refine((name) => isSpeakToolName(name.trim()));
|
||||
|
||||
const CodexToolKindSchema = z.enum([
|
||||
"shell",
|
||||
|
||||
@@ -12,7 +12,11 @@ import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
__openCodeInternals,
|
||||
OpenCodeAgentClient,
|
||||
translateOpenCodeEvent,
|
||||
} from "./opencode-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import type {
|
||||
AgentSessionConfig,
|
||||
@@ -202,6 +206,11 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
||||
|
||||
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
|
||||
expect(model.id).toContain("/");
|
||||
expect(model.metadata).toMatchObject({
|
||||
providerId: expect.any(String),
|
||||
modelId: expect.any(String),
|
||||
contextWindowMaxTokens: expect.any(Number),
|
||||
});
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -303,3 +312,149 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe("OpenCode adapter context-window normalization", () => {
|
||||
test("preserves provider catalog context limit in model metadata", () => {
|
||||
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
"gpt-5",
|
||||
{
|
||||
name: "GPT-5",
|
||||
family: "gpt",
|
||||
limit: {
|
||||
context: 400_000,
|
||||
input: 200_000,
|
||||
output: 16_384,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(definition.metadata).toMatchObject({
|
||||
providerId: "openai",
|
||||
modelId: "gpt-5",
|
||||
contextWindowMaxTokens: 400_000,
|
||||
limit: {
|
||||
context: 400_000,
|
||||
input: 200_000,
|
||||
output: 16_384,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves selected model context window from connected provider catalog data", () => {
|
||||
expect(
|
||||
__openCodeInternals.resolveOpenCodeSelectedModelContextWindow(
|
||||
{
|
||||
connected: ["openai"],
|
||||
all: [
|
||||
{
|
||||
id: "openai",
|
||||
models: {
|
||||
"gpt-5": {
|
||||
limit: {
|
||||
context: 400_000,
|
||||
output: 16_384,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
models: {
|
||||
"claude-opus": {
|
||||
limit: {
|
||||
context: 1_000_000,
|
||||
output: 8_192,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"openai/gpt-5",
|
||||
),
|
||||
).toBe(400_000);
|
||||
|
||||
expect(
|
||||
__openCodeInternals.resolveOpenCodeSelectedModelContextWindow(
|
||||
{
|
||||
connected: ["openai"],
|
||||
all: [
|
||||
{
|
||||
id: "anthropic",
|
||||
models: {
|
||||
"claude-opus": {
|
||||
limit: {
|
||||
context: 1_000_000,
|
||||
output: 8_192,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"anthropic/claude-opus",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("normalizes step-finish usage into AgentUsage context window fields", () => {
|
||||
const usage = { contextWindowMaxTokens: 400_000 };
|
||||
|
||||
__openCodeInternals.mergeOpenCodeStepFinishUsage(usage, {
|
||||
cost: 0.25,
|
||||
tokens: {
|
||||
total: 999_999,
|
||||
input: 30_000,
|
||||
output: 12_000,
|
||||
reasoning: 10_000,
|
||||
cache: {
|
||||
read: 2_000,
|
||||
write: 1_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(usage).toEqual({
|
||||
contextWindowMaxTokens: 400_000,
|
||||
contextWindowUsedTokens: 55_000,
|
||||
cachedInputTokens: 2_000,
|
||||
inputTokens: 30_000,
|
||||
outputTokens: 12_000,
|
||||
totalCostUsd: 0.25,
|
||||
});
|
||||
expect(__openCodeInternals.hasNormalizedOpenCodeUsage(usage)).toBe(true);
|
||||
});
|
||||
|
||||
test("resolves context window max tokens from assistant message metadata", () => {
|
||||
const usage = {};
|
||||
const onAssistantModelContextWindowResolved = vi.fn();
|
||||
|
||||
translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "message-1",
|
||||
sessionID: "session-1",
|
||||
role: "assistant",
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "session-1",
|
||||
messageRoles: new Map(),
|
||||
accumulatedUsage: usage,
|
||||
streamedPartKeys: new Set(),
|
||||
emittedStructuredMessageIds: new Set(),
|
||||
partTypes: new Map(),
|
||||
modelContextWindowsByModelKey: new Map([["openai/gpt-5", 400_000]]),
|
||||
onAssistantModelContextWindowResolved,
|
||||
},
|
||||
);
|
||||
|
||||
expect(onAssistantModelContextWindowResolved).toHaveBeenCalledWith(400_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type OpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
import net from "node:net";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
@@ -268,6 +271,224 @@ function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] {
|
||||
});
|
||||
}
|
||||
|
||||
function readPositiveFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function buildOpenCodeModelLookupKey(providerId: string, modelId: string): string {
|
||||
return `${providerId}/${modelId}`;
|
||||
}
|
||||
|
||||
function parseOpenCodeModelLookupKey(modelId: string | null | undefined): string | undefined {
|
||||
if (typeof modelId !== "string" || modelId.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const slashIndex = modelId.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex === modelId.length - 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const providerId = modelId.slice(0, slashIndex).trim();
|
||||
const providerModelId = modelId.slice(slashIndex + 1).trim();
|
||||
if (!providerId || !providerModelId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildOpenCodeModelLookupKey(providerId, providerModelId);
|
||||
}
|
||||
|
||||
function extractOpenCodeModelContextWindow(model: unknown): number | undefined {
|
||||
if (!model || typeof model !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const limit = (model as { limit?: { context?: unknown } }).limit;
|
||||
return readPositiveFiniteNumber(limit?.context);
|
||||
}
|
||||
|
||||
function buildOpenCodeModelDefinition(
|
||||
provider: {
|
||||
id: string;
|
||||
name: string;
|
||||
},
|
||||
modelId: string,
|
||||
model: {
|
||||
name: string;
|
||||
family?: string;
|
||||
release_date?: string;
|
||||
attachment?: boolean;
|
||||
reasoning?: boolean;
|
||||
tool_call?: boolean;
|
||||
cost?: unknown;
|
||||
limit?: { context?: number; input?: number; output?: number };
|
||||
variants?: Record<string, unknown>;
|
||||
},
|
||||
): AgentModelDefinition {
|
||||
const rawVariants = model.variants ? Object.keys(model.variants) : [];
|
||||
const thinkingOptions = rawVariants.map((id, index) => ({
|
||||
id,
|
||||
label: id,
|
||||
isDefault: index === 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
provider: "opencode",
|
||||
id: `${provider.id}/${modelId}`,
|
||||
label: model.name,
|
||||
description: `${provider.name} - ${model.family ?? ""}`.trim(),
|
||||
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
|
||||
defaultThinkingOptionId: thinkingOptions[0]?.id,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
modelId,
|
||||
family: model.family,
|
||||
releaseDate: model.release_date,
|
||||
supportsAttachments: model.attachment,
|
||||
supportsReasoning: model.reasoning,
|
||||
supportsToolCall: model.tool_call,
|
||||
cost: model.cost,
|
||||
contextWindowMaxTokens: extractOpenCodeModelContextWindow(model),
|
||||
...(model.limit ? { limit: model.limit } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpenCodeSelectedModelContextWindow(
|
||||
providers: {
|
||||
connected?: string[];
|
||||
all?: Array<{
|
||||
id: string;
|
||||
models?: Record<string, unknown>;
|
||||
}>;
|
||||
} | null | undefined,
|
||||
modelId: string | null | undefined,
|
||||
): number | undefined {
|
||||
if (!providers) {
|
||||
return undefined;
|
||||
}
|
||||
const modelLookupKey = parseOpenCodeModelLookupKey(modelId);
|
||||
if (!modelLookupKey) {
|
||||
return undefined;
|
||||
}
|
||||
const lookup = buildOpenCodeModelContextWindowLookup(providers);
|
||||
return lookup.get(modelLookupKey);
|
||||
}
|
||||
|
||||
function buildOpenCodeModelContextWindowLookup(providers: {
|
||||
connected?: string[];
|
||||
all?: Array<{
|
||||
id: string;
|
||||
models?: Record<string, unknown>;
|
||||
}>;
|
||||
} | null | undefined): Map<string, number> {
|
||||
const lookup = new Map<string, number>();
|
||||
if (!providers) {
|
||||
return lookup;
|
||||
}
|
||||
|
||||
const connectedProviderIds = new Set(providers.connected ?? []);
|
||||
for (const provider of providers.all ?? []) {
|
||||
if (!connectedProviderIds.has(provider.id)) {
|
||||
continue;
|
||||
}
|
||||
for (const [modelId, modelDefinition] of Object.entries(provider.models ?? {})) {
|
||||
const contextWindow = extractOpenCodeModelContextWindow(modelDefinition);
|
||||
if (contextWindow === undefined) {
|
||||
continue;
|
||||
}
|
||||
lookup.set(buildOpenCodeModelLookupKey(provider.id, modelId), contextWindow);
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(
|
||||
info: AgentMetadata | undefined,
|
||||
): string | undefined {
|
||||
if (!info || info.role !== "assistant") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const providerId = readNonEmptyString(info.providerID);
|
||||
const modelId = readNonEmptyString(info.modelID);
|
||||
if (!providerId || !modelId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildOpenCodeModelLookupKey(providerId, modelId);
|
||||
}
|
||||
|
||||
function mergeOpenCodeStepFinishUsage(
|
||||
usage: AgentUsage,
|
||||
part: {
|
||||
cost?: unknown;
|
||||
tokens?: {
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
reasoning?: unknown;
|
||||
total?: unknown;
|
||||
cache?: {
|
||||
read?: unknown;
|
||||
write?: unknown;
|
||||
};
|
||||
};
|
||||
},
|
||||
): void {
|
||||
const inputTokens = readPositiveFiniteNumber(part.tokens?.input);
|
||||
const outputTokens = readPositiveFiniteNumber(part.tokens?.output);
|
||||
const reasoningTokens = readPositiveFiniteNumber(part.tokens?.reasoning);
|
||||
const cacheReadTokens = readPositiveFiniteNumber(part.tokens?.cache?.read);
|
||||
const cacheWriteTokens = readPositiveFiniteNumber(part.tokens?.cache?.write);
|
||||
const totalTokens =
|
||||
(inputTokens ?? 0) +
|
||||
(outputTokens ?? 0) +
|
||||
(reasoningTokens ?? 0) +
|
||||
(cacheReadTokens ?? 0) +
|
||||
(cacheWriteTokens ?? 0);
|
||||
const cost = readPositiveFiniteNumber(part.cost);
|
||||
|
||||
if (inputTokens !== undefined) {
|
||||
usage.inputTokens = (usage.inputTokens ?? 0) + inputTokens;
|
||||
}
|
||||
if (cacheReadTokens !== undefined) {
|
||||
usage.cachedInputTokens = (usage.cachedInputTokens ?? 0) + cacheReadTokens;
|
||||
}
|
||||
if (outputTokens !== undefined) {
|
||||
usage.outputTokens = (usage.outputTokens ?? 0) + outputTokens;
|
||||
}
|
||||
if (totalTokens > 0) {
|
||||
usage.contextWindowUsedTokens = (usage.contextWindowUsedTokens ?? 0) + totalTokens;
|
||||
}
|
||||
if (cost !== undefined) {
|
||||
usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost;
|
||||
}
|
||||
}
|
||||
|
||||
function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
|
||||
return [
|
||||
usage.inputTokens,
|
||||
usage.cachedInputTokens,
|
||||
usage.outputTokens,
|
||||
usage.totalCostUsd,
|
||||
usage.contextWindowMaxTokens,
|
||||
usage.contextWindowUsedTokens,
|
||||
].some((value) => typeof value === "number" && Number.isFinite(value));
|
||||
}
|
||||
|
||||
export const __openCodeInternals = {
|
||||
buildOpenCodeModelContextWindowLookup,
|
||||
buildOpenCodeModelDefinition,
|
||||
buildOpenCodeModelLookupKey,
|
||||
extractOpenCodeModelContextWindow,
|
||||
hasNormalizedOpenCodeUsage,
|
||||
mergeOpenCodeStepFinishUsage,
|
||||
parseOpenCodeModelLookupKey,
|
||||
resolveOpenCodeModelLookupKeyFromAssistantMessageInfo,
|
||||
resolveOpenCodeSelectedModelContextWindow,
|
||||
};
|
||||
|
||||
export class OpenCodeServerManager {
|
||||
private static instance: OpenCodeServerManager | null = null;
|
||||
private static exitHandlerRegistered = false;
|
||||
@@ -423,6 +644,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
private readonly serverManager: OpenCodeServerManager;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly modelContextWindows = new Map<string, number>();
|
||||
|
||||
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
|
||||
this.logger = logger.child({ module: "agent", provider: "opencode" });
|
||||
@@ -460,7 +682,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
throw new Error("OpenCode session creation returned no data");
|
||||
}
|
||||
|
||||
return new OpenCodeAgentSession(openCodeConfig, client, session.id, this.logger);
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
|
||||
return new OpenCodeAgentSession(
|
||||
openCodeConfig,
|
||||
client,
|
||||
session.id,
|
||||
this.logger,
|
||||
new Map(this.modelContextWindows),
|
||||
);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
@@ -485,7 +715,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
|
||||
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId, this.logger);
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
|
||||
return new OpenCodeAgentSession(
|
||||
openCodeConfig,
|
||||
client,
|
||||
handle.sessionId,
|
||||
this.logger,
|
||||
new Map(this.modelContextWindows),
|
||||
);
|
||||
}
|
||||
|
||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
@@ -533,6 +771,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
const models: AgentModelDefinition[] = [];
|
||||
this.modelContextWindows.clear();
|
||||
for (const provider of providers.all) {
|
||||
// Skip providers that aren't connected/configured
|
||||
if (!connectedProviderIds.has(provider.id)) {
|
||||
@@ -540,32 +779,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||
const rawVariants = model.variants ? Object.keys(model.variants) : [];
|
||||
const thinkingOptions = rawVariants.map((id, index) => ({
|
||||
id,
|
||||
label: id,
|
||||
isDefault: index === 0,
|
||||
}));
|
||||
|
||||
models.push({
|
||||
provider: "opencode",
|
||||
id: `${provider.id}/${modelId}`,
|
||||
label: model.name,
|
||||
description: `${provider.name} - ${model.family ?? ""}`.trim(),
|
||||
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
|
||||
defaultThinkingOptionId: thinkingOptions[0]?.id,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
modelId,
|
||||
family: model.family,
|
||||
releaseDate: model.release_date,
|
||||
supportsAttachments: model.attachment,
|
||||
supportsReasoning: model.reasoning,
|
||||
supportsToolCall: model.tool_call,
|
||||
cost: model.cost,
|
||||
},
|
||||
});
|
||||
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
|
||||
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
|
||||
if (contextWindowMaxTokens !== undefined) {
|
||||
this.modelContextWindows.set(
|
||||
buildOpenCodeModelLookupKey(provider.id, modelId),
|
||||
contextWindowMaxTokens,
|
||||
);
|
||||
}
|
||||
models.push(definition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,6 +907,22 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
return { ...config, provider: "opencode" };
|
||||
}
|
||||
|
||||
private async populateModelContextWindowCache(
|
||||
client: OpencodeClient,
|
||||
cwd: string,
|
||||
): Promise<void> {
|
||||
const response = await client.provider.list({ directory: cwd });
|
||||
if (response.error || !response.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lookup = buildOpenCodeModelContextWindowLookup(response.data);
|
||||
this.modelContextWindows.clear();
|
||||
for (const [modelLookupKey, contextWindowMaxTokens] of lookup.entries()) {
|
||||
this.modelContextWindows.set(modelLookupKey, contextWindowMaxTokens);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenCodeEventTranslationState = {
|
||||
@@ -695,6 +933,8 @@ export type OpenCodeEventTranslationState = {
|
||||
emittedStructuredMessageIds: Set<string>;
|
||||
/** Tracks the type of each part by ID, learned from message.part.updated events. */
|
||||
partTypes: Map<string, string>;
|
||||
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
|
||||
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
|
||||
};
|
||||
|
||||
function stringifyStructuredAssistantMessage(value: unknown): string | null {
|
||||
@@ -787,6 +1027,15 @@ export function translateOpenCodeEvent(
|
||||
|
||||
if (messageId && messageSessionId === state.sessionId && role) {
|
||||
state.messageRoles.set(messageId, role);
|
||||
if (role === "assistant") {
|
||||
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(info);
|
||||
if (modelLookupKey) {
|
||||
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
|
||||
if (contextWindowMaxTokens !== undefined) {
|
||||
state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
role === "assistant" &&
|
||||
!state.emittedStructuredMessageIds.has(messageId) &&
|
||||
@@ -894,20 +1143,7 @@ export function translateOpenCodeEvent(
|
||||
});
|
||||
}
|
||||
} else if (partType === "step-finish") {
|
||||
const tokens = part.tokens as
|
||||
| { input?: number; output?: number; reasoning?: number }
|
||||
| undefined;
|
||||
const cost = part.cost as number | undefined;
|
||||
|
||||
if (tokens) {
|
||||
state.accumulatedUsage.inputTokens =
|
||||
(state.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
|
||||
state.accumulatedUsage.outputTokens =
|
||||
(state.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
|
||||
}
|
||||
if (cost !== undefined) {
|
||||
state.accumulatedUsage.totalCostUsd = (state.accumulatedUsage.totalCostUsd ?? 0) + cost;
|
||||
}
|
||||
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1083,6 +1319,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private readonly client: OpencodeClient;
|
||||
private readonly sessionId: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
|
||||
private currentMode: string = "default";
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private abortController: AbortController | null = null;
|
||||
@@ -1102,18 +1339,23 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private nextTurnOrdinal = 0;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
|
||||
private selectedModelContextWindowMaxTokens: number | undefined;
|
||||
|
||||
constructor(
|
||||
config: OpenCodeAgentConfig,
|
||||
client: OpencodeClient,
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
this.sessionId = sessionId;
|
||||
this.logger = logger;
|
||||
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||
this.selectedModelContextWindowMaxTokens =
|
||||
this.resolveConfiguredModelContextWindowMaxTokens(config.model);
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
@@ -1133,6 +1375,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const normalizedModelId =
|
||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||
this.config.model = normalizedModelId ?? undefined;
|
||||
this.selectedModelContextWindowMaxTokens =
|
||||
this.resolveConfiguredModelContextWindowMaxTokens(this.config.model);
|
||||
}
|
||||
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||
@@ -1246,6 +1490,9 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const turnAbortController = new AbortController();
|
||||
this.abortController = turnAbortController;
|
||||
await this.ensureMcpServersConfigured();
|
||||
const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
|
||||
this.accumulatedUsage =
|
||||
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
||||
|
||||
const parts = this.buildPromptParts(prompt);
|
||||
const model = this.parseModel(this.config.model);
|
||||
@@ -1781,6 +2028,13 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
streamedPartKeys: this.streamedPartKeys,
|
||||
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
|
||||
partTypes: this.partTypes,
|
||||
modelContextWindowsByModelKey: this.modelContextWindowsByModelKey,
|
||||
onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => {
|
||||
this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||
if (!this.config.model) {
|
||||
this.selectedModelContextWindowMaxTokens = contextWindowMaxTokens;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
for (const translatedEvent of translated) {
|
||||
@@ -1788,21 +2042,32 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
|
||||
}
|
||||
if (translatedEvent.type === "turn_completed") {
|
||||
translatedEvent.usage = this.extractAndResetUsage();
|
||||
if (hasNormalizedOpenCodeUsage(this.accumulatedUsage)) {
|
||||
translatedEvent.usage = this.accumulatedUsage;
|
||||
}
|
||||
const contextWindowMaxTokens =
|
||||
this.resolveSelectedModelContextWindowMaxTokens();
|
||||
this.accumulatedUsage =
|
||||
contextWindowMaxTokens !== undefined
|
||||
? { contextWindowMaxTokens }
|
||||
: {};
|
||||
}
|
||||
}
|
||||
|
||||
return translated;
|
||||
}
|
||||
|
||||
private extractAndResetUsage(): AgentUsage | undefined {
|
||||
const usage = this.accumulatedUsage;
|
||||
this.accumulatedUsage = {};
|
||||
private resolveSelectedModelContextWindowMaxTokens(): number | undefined {
|
||||
return this.selectedModelContextWindowMaxTokens;
|
||||
}
|
||||
|
||||
if (!usage.inputTokens && !usage.outputTokens && !usage.totalCostUsd) {
|
||||
private resolveConfiguredModelContextWindowMaxTokens(
|
||||
modelId: string | undefined,
|
||||
): number | undefined {
|
||||
const modelLookupKey = parseOpenCodeModelLookupKey(modelId);
|
||||
if (!modelLookupKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return usage;
|
||||
return this.modelContextWindowsByModelKey.get(modelLookupKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,33 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
|
||||
}
|
||||
|
||||
describe("translateOpenCodeEvent", () => {
|
||||
it("resolves context window max tokens from assistant message.updated model metadata", () => {
|
||||
const resolvedContextWindowMaxTokens: number[] = [];
|
||||
const state = createState();
|
||||
state.modelContextWindowsByModelKey = new Map([["anthropic/claude-sonnet-4", 200_000]]);
|
||||
state.onAssistantModelContextWindowResolved = (contextWindowMaxTokens) => {
|
||||
resolvedContextWindowMaxTokens.push(contextWindowMaxTokens);
|
||||
};
|
||||
|
||||
translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "message-model-1",
|
||||
sessionID: "session-1",
|
||||
role: "assistant",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4",
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(resolvedContextWindowMaxTokens).toEqual([200_000]);
|
||||
});
|
||||
|
||||
it("does not duplicate assistant output when completed part echoes streamed delta", () => {
|
||||
const state = createState();
|
||||
|
||||
|
||||
248
packages/server/src/server/background-git-fetch-manager.test.ts
Normal file
248
packages/server/src/server/background-git-fetch-manager.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const execFileMock = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
(
|
||||
_file: string,
|
||||
_args: string[],
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
|
||||
) => {
|
||||
callback(null, "", "");
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
execFile: execFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function createLogger() {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
return logger;
|
||||
}
|
||||
|
||||
describe("BackgroundGitFetchManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
execFileMock.mockReset();
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
_file: string,
|
||||
_args: string[],
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
|
||||
) => {
|
||||
callback(null, "", "");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("creates a fetch timer for a repo with an origin remote", async () => {
|
||||
const logger = createLogger();
|
||||
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||
|
||||
const subscription = await manager.subscribe(
|
||||
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||
vi.fn(),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const managerAny = manager as any;
|
||||
const target = managerAny.targets.get("/tmp/repo/.git");
|
||||
expect(target).toBeDefined();
|
||||
expect(target.intervalId).toBeTruthy();
|
||||
expect(execFileMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"git",
|
||||
["remote", "get-url", "origin"],
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/repo",
|
||||
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(execFileMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"git",
|
||||
["fetch", "origin", "--prune"],
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/repo",
|
||||
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
subscription.unsubscribe();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test("dedupes multiple subscribers for the same repo root behind one timer", async () => {
|
||||
const logger = createLogger();
|
||||
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||
|
||||
const listenerOne = vi.fn();
|
||||
const listenerTwo = vi.fn();
|
||||
const subscriptionOne = await manager.subscribe(
|
||||
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||
listenerOne,
|
||||
);
|
||||
const subscriptionTwo = await manager.subscribe(
|
||||
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo-worktree" },
|
||||
listenerTwo,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const managerAny = manager as any;
|
||||
const target = managerAny.targets.get("/tmp/repo/.git");
|
||||
expect(managerAny.targets.size).toBe(1);
|
||||
expect(target.listeners).toEqual(new Set([listenerOne, listenerTwo]));
|
||||
expect(execFileMock.mock.calls.filter((call) => call[1][0] === "remote")).toHaveLength(1);
|
||||
|
||||
subscriptionOne.unsubscribe();
|
||||
subscriptionTwo.unsubscribe();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test("cleans up the timer when the last subscriber unsubscribes", async () => {
|
||||
const logger = createLogger();
|
||||
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||
|
||||
const subscription = await manager.subscribe(
|
||||
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||
vi.fn(),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const managerAny = manager as any;
|
||||
const target = managerAny.targets.get("/tmp/repo/.git");
|
||||
const intervalId = target.intervalId;
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
|
||||
|
||||
subscription.unsubscribe();
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId);
|
||||
expect(managerAny.targets.size).toBe(0);
|
||||
|
||||
clearIntervalSpy.mockRestore();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test("logs fetch errors without crashing", async () => {
|
||||
const logger = createLogger();
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
_file: string,
|
||||
args: string[],
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
|
||||
) => {
|
||||
if (args[0] === "remote") {
|
||||
callback(null, "", "");
|
||||
return;
|
||||
}
|
||||
callback(new Error("fetch failed"));
|
||||
},
|
||||
);
|
||||
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||
|
||||
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||
"Running background git fetch",
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.any(Error),
|
||||
repoGitRoot: "/tmp/repo/.git",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
"Background git fetch failed",
|
||||
);
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test("calls listeners when a fetch completes", async () => {
|
||||
const logger = createLogger();
|
||||
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||
const listener = vi.fn();
|
||||
|
||||
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, listener);
|
||||
await flushPromises();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test("does not create a timer when the repo has no origin remote", async () => {
|
||||
const logger = createLogger();
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
_file: string,
|
||||
_args: string[],
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
|
||||
) => {
|
||||
callback(new Error("missing origin"));
|
||||
},
|
||||
);
|
||||
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||
|
||||
const subscription = await manager.subscribe(
|
||||
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect((manager as any).targets.size).toBe(0);
|
||||
subscription.unsubscribe();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test("dispose clears timers and listeners", async () => {
|
||||
const logger = createLogger();
|
||||
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||
|
||||
const listener = vi.fn();
|
||||
await manager.subscribe({ repoGitRoot: "/tmp/repo-one/.git", cwd: "/tmp/repo-one" }, listener);
|
||||
await manager.subscribe({ repoGitRoot: "/tmp/repo-two/.git", cwd: "/tmp/repo-two" }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
const managerAny = manager as any;
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
|
||||
|
||||
manager.dispose();
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledTimes(2);
|
||||
expect(managerAny.targets.size).toBe(0);
|
||||
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
150
packages/server/src/server/background-git-fetch-manager.ts
Normal file
150
packages/server/src/server/background-git-fetch-manager.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type pino from "pino";
|
||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||
|
||||
type BackgroundGitFetchTarget = {
|
||||
repoGitRoot: string;
|
||||
cwd: string;
|
||||
listeners: Set<() => void>;
|
||||
intervalId: NodeJS.Timeout | null;
|
||||
fetchInFlight: boolean;
|
||||
};
|
||||
|
||||
export class BackgroundGitFetchManager {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly targets = new Map<string, BackgroundGitFetchTarget>();
|
||||
|
||||
constructor(options: { logger: pino.Logger }) {
|
||||
this.logger = options.logger.child({ module: "background-git-fetch-manager" });
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
params: { repoGitRoot: string; cwd: string },
|
||||
listener: () => void,
|
||||
): Promise<{ unsubscribe: () => void }> {
|
||||
const existingTarget = this.targets.get(params.repoGitRoot);
|
||||
if (existingTarget) {
|
||||
existingTarget.listeners.add(listener);
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
this.removeListener(params.repoGitRoot, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const hasOrigin = await this.hasOriginRemote(params.cwd);
|
||||
if (!hasOrigin) {
|
||||
return { unsubscribe: () => {} };
|
||||
}
|
||||
|
||||
const targetAfterProbe = this.targets.get(params.repoGitRoot);
|
||||
if (targetAfterProbe) {
|
||||
targetAfterProbe.listeners.add(listener);
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
this.removeListener(params.repoGitRoot, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const target: BackgroundGitFetchTarget = {
|
||||
repoGitRoot: params.repoGitRoot,
|
||||
cwd: params.cwd,
|
||||
listeners: new Set([listener]),
|
||||
intervalId: setInterval(() => {
|
||||
void this.runFetch(target);
|
||||
}, BACKGROUND_GIT_FETCH_INTERVAL_MS),
|
||||
fetchInFlight: false,
|
||||
};
|
||||
this.targets.set(params.repoGitRoot, target);
|
||||
void this.runFetch(target);
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
this.removeListener(params.repoGitRoot, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const target of this.targets.values()) {
|
||||
this.closeTarget(target);
|
||||
}
|
||||
this.targets.clear();
|
||||
}
|
||||
|
||||
private closeTarget(target: BackgroundGitFetchTarget): void {
|
||||
if (target.intervalId) {
|
||||
clearInterval(target.intervalId);
|
||||
target.intervalId = null;
|
||||
}
|
||||
target.listeners.clear();
|
||||
}
|
||||
|
||||
private removeListener(targetKey: string, listener: () => void): void {
|
||||
const target = this.targets.get(targetKey);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.listeners.delete(listener);
|
||||
if (target.listeners.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeTarget(target);
|
||||
this.targets.delete(targetKey);
|
||||
}
|
||||
|
||||
private async hasOriginRemote(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync("git", ["remote", "get-url", "origin"], {
|
||||
cwd,
|
||||
env: {
|
||||
...READ_ONLY_GIT_ENV,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runFetch(target: BackgroundGitFetchTarget): Promise<void> {
|
||||
if (target.fetchInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.fetchInFlight = true;
|
||||
this.logger.debug(
|
||||
{ repoGitRoot: target.repoGitRoot, cwd: target.cwd },
|
||||
"Running background git fetch",
|
||||
);
|
||||
|
||||
try {
|
||||
await execFileAsync("git", ["fetch", "origin", "--prune"], {
|
||||
cwd: target.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
|
||||
"Background git fetch failed",
|
||||
);
|
||||
} finally {
|
||||
target.fetchInFlight = false;
|
||||
for (const listener of target.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,11 +123,14 @@ export class CheckoutDiffManager {
|
||||
}
|
||||
|
||||
private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
|
||||
private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
|
||||
@@ -135,6 +138,7 @@ export class CheckoutDiffManager {
|
||||
cwd,
|
||||
compare.mode,
|
||||
compare.mode === "base" ? (compare.baseRef ?? "") : "",
|
||||
compare.ignoreWhitespace === true,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -206,6 +210,7 @@ export class CheckoutDiffManager {
|
||||
{
|
||||
mode: compare.mode,
|
||||
baseRef: compare.baseRef,
|
||||
ignoreWhitespace: compare.ignoreWhitespace,
|
||||
includeStructured: true,
|
||||
},
|
||||
{ paseoHome: this.paseoHome },
|
||||
|
||||
100
packages/server/src/server/editor-targets.test.ts
Normal file
100
packages/server/src/server/editor-targets.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
|
||||
|
||||
describe("editor-targets", () => {
|
||||
it("lists available editors in deterministic order", () => {
|
||||
const available = new Set(["code", "cursor", "explorer"]);
|
||||
|
||||
const editors = listAvailableEditorTargets({
|
||||
platform: "win32",
|
||||
findExecutable: (command) => (available.has(command) ? command : null),
|
||||
});
|
||||
|
||||
expect(editors).toEqual([
|
||||
{ id: "cursor", label: "Cursor" },
|
||||
{ id: "vscode", label: "VS Code" },
|
||||
{ id: "explorer", label: "Explorer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns Finder on macOS", () => {
|
||||
const editors = listAvailableEditorTargets({
|
||||
platform: "darwin",
|
||||
findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null),
|
||||
});
|
||||
|
||||
expect(editors).toEqual([{ id: "finder", label: "Finder" }]);
|
||||
});
|
||||
|
||||
it("returns the generic file manager target on Linux", () => {
|
||||
const editors = listAvailableEditorTargets({
|
||||
platform: "linux",
|
||||
findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null),
|
||||
});
|
||||
|
||||
expect(editors).toEqual([{ id: "file-manager", label: "File Manager" }]);
|
||||
});
|
||||
|
||||
it("launches editors as detached processes", async () => {
|
||||
const unref = vi.fn();
|
||||
const once = vi.fn((event: string, handler: () => void) => {
|
||||
if (event === "spawn") {
|
||||
queueMicrotask(handler);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
const child = { once, unref };
|
||||
const spawn = vi.fn(() => child as any);
|
||||
|
||||
await openInEditorTarget(
|
||||
{
|
||||
editorId: "vscode",
|
||||
path: "/tmp/repo",
|
||||
},
|
||||
{
|
||||
platform: "darwin",
|
||||
existsSync: () => true,
|
||||
findExecutable: (command) => (command === "code" ? "/usr/local/bin/code" : null),
|
||||
spawn,
|
||||
},
|
||||
);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith("/usr/local/bin/code", ["/tmp/repo"], {
|
||||
detached: true,
|
||||
shell: false,
|
||||
stdio: "ignore",
|
||||
});
|
||||
expect(unref).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects relative paths", async () => {
|
||||
await expect(
|
||||
openInEditorTarget(
|
||||
{
|
||||
editorId: "cursor",
|
||||
path: "repo",
|
||||
},
|
||||
{
|
||||
existsSync: () => true,
|
||||
findExecutable: () => "/usr/local/bin/cursor",
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("Editor target path must be an absolute local path");
|
||||
});
|
||||
|
||||
it("rejects platform-specific targets that are unavailable on this OS", async () => {
|
||||
await expect(
|
||||
openInEditorTarget(
|
||||
{
|
||||
editorId: "finder",
|
||||
path: "/tmp/repo",
|
||||
},
|
||||
{
|
||||
platform: "linux",
|
||||
existsSync: () => true,
|
||||
findExecutable: () => "/usr/bin/open",
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("Editor target unavailable: Finder");
|
||||
});
|
||||
});
|
||||
168
packages/server/src/server/editor-targets.ts
Normal file
168
packages/server/src/server/editor-targets.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { posix, win32 } from "node:path";
|
||||
import type { EditorTargetDescriptorPayload, EditorTargetId } from "../shared/messages.js";
|
||||
import {
|
||||
findExecutable,
|
||||
quoteWindowsArgument,
|
||||
quoteWindowsCommand,
|
||||
} from "../utils/executable.js";
|
||||
|
||||
type EditorTargetDefinition = {
|
||||
id: EditorTargetId;
|
||||
label: string;
|
||||
command: string;
|
||||
platforms?: readonly NodeJS.Platform[];
|
||||
excludedPlatforms?: readonly NodeJS.Platform[];
|
||||
};
|
||||
|
||||
type ListAvailableEditorTargetsDependencies = {
|
||||
platform?: NodeJS.Platform;
|
||||
findExecutable?: (command: string) => string | null;
|
||||
};
|
||||
|
||||
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
|
||||
existsSync?: typeof existsSync;
|
||||
spawn?: typeof spawn;
|
||||
};
|
||||
|
||||
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
|
||||
{ id: "cursor", label: "Cursor", command: "cursor" },
|
||||
{ id: "vscode", label: "VS Code", command: "code" },
|
||||
{ id: "zed", label: "Zed", command: "zed" },
|
||||
{ id: "finder", label: "Finder", command: "open", platforms: ["darwin"] },
|
||||
{ id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] },
|
||||
{
|
||||
id: "file-manager",
|
||||
label: "File Manager",
|
||||
command: "xdg-open",
|
||||
excludedPlatforms: ["darwin", "win32"],
|
||||
},
|
||||
];
|
||||
|
||||
function isAbsolutePath(value: string): boolean {
|
||||
return posix.isAbsolute(value) || win32.isAbsolute(value);
|
||||
}
|
||||
|
||||
function isTargetSupportedOnPlatform(
|
||||
target: EditorTargetDefinition,
|
||||
platform: NodeJS.Platform,
|
||||
): boolean {
|
||||
if (target.platforms && !target.platforms.includes(platform)) {
|
||||
return false;
|
||||
}
|
||||
if (target.excludedPlatforms?.includes(platform)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDefinition {
|
||||
const target = EDITOR_TARGETS.find((entry) => entry.id === editorId);
|
||||
if (!target) {
|
||||
throw new Error(`Unknown editor target: ${editorId}`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function listAvailableEditorTargets(
|
||||
dependencies: ListAvailableEditorTargetsDependencies = {},
|
||||
): EditorTargetDescriptorPayload[] {
|
||||
const platform = dependencies.platform ?? process.platform;
|
||||
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
|
||||
|
||||
return EDITOR_TARGETS.flatMap((target) => {
|
||||
if (!isTargetSupportedOnPlatform(target, platform)) {
|
||||
return [];
|
||||
}
|
||||
const executable = findExecutableFn(target.command);
|
||||
if (!executable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: target.id,
|
||||
label: target.label,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
type Launch = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
function resolveEditorLaunch(input: {
|
||||
editorId: EditorTargetId;
|
||||
path: string;
|
||||
platform: NodeJS.Platform;
|
||||
findExecutableFn: typeof findExecutable;
|
||||
}): Launch {
|
||||
const target = resolveEditorTargetDefinition(input.editorId);
|
||||
if (!isTargetSupportedOnPlatform(target, input.platform)) {
|
||||
throw new Error(`Editor target unavailable: ${target.label}`);
|
||||
}
|
||||
const executable = input.findExecutableFn(target.command);
|
||||
if (!executable) {
|
||||
throw new Error(`Editor target unavailable: ${target.label}`);
|
||||
}
|
||||
|
||||
return {
|
||||
command: executable,
|
||||
args: [input.path],
|
||||
};
|
||||
}
|
||||
|
||||
export async function openInEditorTarget(
|
||||
input: {
|
||||
editorId: EditorTargetId;
|
||||
path: string;
|
||||
},
|
||||
dependencies: OpenInEditorTargetDependencies = {},
|
||||
): Promise<void> {
|
||||
const platform = dependencies.platform ?? process.platform;
|
||||
const pathToOpen = input.path.trim();
|
||||
const existsSyncFn = dependencies.existsSync ?? existsSync;
|
||||
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
|
||||
const spawnFn = dependencies.spawn ?? spawn;
|
||||
|
||||
if (!pathToOpen || !isAbsolutePath(pathToOpen)) {
|
||||
throw new Error("Editor target path must be an absolute local path");
|
||||
}
|
||||
if (!existsSyncFn(pathToOpen)) {
|
||||
throw new Error(`Path does not exist: ${pathToOpen}`);
|
||||
}
|
||||
|
||||
const launch = resolveEditorLaunch({
|
||||
editorId: input.editorId,
|
||||
path: pathToOpen,
|
||||
platform,
|
||||
findExecutableFn,
|
||||
});
|
||||
|
||||
const command = platform === "win32" ? quoteWindowsCommand(launch.command) : launch.command;
|
||||
const args =
|
||||
platform === "win32" ? launch.args.map((argument) => quoteWindowsArgument(argument)) : launch.args;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawnFn(command, args, {
|
||||
detached: true,
|
||||
shell: platform === "win32",
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
child.once("error", reject);
|
||||
child.once("spawn", () => {
|
||||
child.unref();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type SubscribeCheckoutDiffRequest,
|
||||
type UnsubscribeCheckoutDiffRequest,
|
||||
type DirectorySuggestionsRequest,
|
||||
type EditorTargetId,
|
||||
type ProjectPlacementPayload,
|
||||
type WorkspaceDescriptorPayload,
|
||||
type WorkspaceStateBucket,
|
||||
@@ -47,6 +48,7 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech
|
||||
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
||||
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
||||
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
|
||||
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
|
||||
import {
|
||||
DictationStreamManager,
|
||||
type DictationStreamOutboundMessage,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
|
||||
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
|
||||
|
||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||
@@ -130,6 +133,7 @@ import {
|
||||
buildVoiceAgentMcpServerConfig,
|
||||
buildVoiceModeSystemPrompt,
|
||||
stripVoiceModeSystemPrompt,
|
||||
wrapSpokenInput,
|
||||
} from "./voice-config.js";
|
||||
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
||||
import {
|
||||
@@ -254,6 +258,17 @@ export function resolveCreateAgentTitles(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWaitForFinishError(options: {
|
||||
status: "permission" | "error" | "idle";
|
||||
final: AgentSnapshotPayload | null;
|
||||
}): string | null {
|
||||
if (options.status !== "error") {
|
||||
return null;
|
||||
}
|
||||
const message = options.final?.lastError;
|
||||
return typeof message === "string" && message.trim().length > 0 ? message : "Agent failed";
|
||||
}
|
||||
|
||||
type ProcessingPhase = "idle" | "transcribing";
|
||||
|
||||
type WorkspaceGitWatchTarget = {
|
||||
@@ -396,6 +411,7 @@ export type SessionOptions = {
|
||||
scheduleService: ScheduleService;
|
||||
loopService: LoopService;
|
||||
checkoutDiffManager: CheckoutDiffManager;
|
||||
backgroundGitFetchManager: BackgroundGitFetchManager;
|
||||
createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
@@ -581,6 +597,7 @@ export class Session {
|
||||
private readonly scheduleService: ScheduleService;
|
||||
private readonly loopService: LoopService;
|
||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
||||
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
|
||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
@@ -609,6 +626,7 @@ export class Session {
|
||||
private peakInflightRequests = 0;
|
||||
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
|
||||
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
|
||||
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
|
||||
private readonly registerVoiceSpeakHandler?: (
|
||||
agentId: string,
|
||||
@@ -647,6 +665,7 @@ export class Session {
|
||||
scheduleService,
|
||||
loopService,
|
||||
checkoutDiffManager,
|
||||
backgroundGitFetchManager,
|
||||
createAgentMcpTransport,
|
||||
stt,
|
||||
tts,
|
||||
@@ -675,6 +694,7 @@ export class Session {
|
||||
this.scheduleService = scheduleService;
|
||||
this.loopService = loopService;
|
||||
this.checkoutDiffManager = checkoutDiffManager;
|
||||
this.backgroundGitFetchManager = backgroundGitFetchManager;
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
this.terminalManager = terminalManager;
|
||||
this.providerSnapshotManager = providerSnapshotManager ?? null;
|
||||
@@ -1742,6 +1762,14 @@ export class Session {
|
||||
await this.handleCreatePaseoWorktreeRequest(msg);
|
||||
break;
|
||||
|
||||
case "list_available_editors_request":
|
||||
await this.handleListAvailableEditorsRequest(msg);
|
||||
break;
|
||||
|
||||
case "open_in_editor_request":
|
||||
await this.handleOpenInEditorRequest(msg);
|
||||
break;
|
||||
|
||||
case "open_project_request":
|
||||
await this.handleOpenProjectRequest(msg);
|
||||
break;
|
||||
@@ -2807,6 +2835,7 @@ export class Session {
|
||||
messageId?: string,
|
||||
images?: Array<{ data: string; mimeType: string }>,
|
||||
runOptions?: AgentRunOptions,
|
||||
options?: { spokenInput?: boolean },
|
||||
): Promise<void> {
|
||||
this.sessionLogger.info(
|
||||
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
|
||||
@@ -2834,7 +2863,8 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
const prompt = this.buildAgentPrompt(text, images);
|
||||
const promptText = options?.spokenInput ? wrapSpokenInput(text) : text;
|
||||
const prompt = this.buildAgentPrompt(promptText, images);
|
||||
|
||||
this.startAgentStream(agentId, prompt, runOptions);
|
||||
}
|
||||
@@ -4189,6 +4219,9 @@ export class Session {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(workspaceId);
|
||||
unsubscribeFetch?.();
|
||||
this.workspaceGitFetchSubscriptions.delete(workspaceId);
|
||||
this.closeWorkspaceGitWatchTarget(target);
|
||||
this.workspaceGitWatchTargets.delete(workspaceId);
|
||||
}
|
||||
@@ -4319,6 +4352,16 @@ export class Session {
|
||||
}
|
||||
|
||||
this.workspaceGitWatchTargets.set(workspaceId, target);
|
||||
const subscription = await this.backgroundGitFetchManager.subscribe(
|
||||
{ repoGitRoot: refsRoot, cwd: workspaceId },
|
||||
() => {
|
||||
const activeTarget = this.workspaceGitWatchTargets.get(workspaceId);
|
||||
if (activeTarget) {
|
||||
this.scheduleWorkspaceGitWatchRefresh(activeTarget);
|
||||
}
|
||||
},
|
||||
);
|
||||
this.workspaceGitFetchSubscriptions.set(workspaceId, subscription.unsubscribe);
|
||||
}
|
||||
|
||||
private async syncWorkspaceGitWatchTarget(
|
||||
@@ -5297,6 +5340,26 @@ export class Session {
|
||||
): Promise<WorkspaceDescriptorPayload> {
|
||||
const resolvedProjectRecord =
|
||||
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
|
||||
|
||||
return {
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId,
|
||||
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
|
||||
projectKind: resolvedProjectRecord?.kind ?? "non_git",
|
||||
workspaceKind: workspace.kind,
|
||||
name: workspace.displayName,
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async describeWorkspaceRecordWithGitData(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
projectRecord?: PersistedProjectRecord | null,
|
||||
): Promise<WorkspaceDescriptorPayload> {
|
||||
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
|
||||
let displayName = workspace.displayName;
|
||||
try {
|
||||
const placement = await this.buildProjectPlacement(workspace.cwd);
|
||||
@@ -5315,21 +5378,24 @@ export class Session {
|
||||
// Non-critical — leave null on failure.
|
||||
}
|
||||
|
||||
return {
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId,
|
||||
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
|
||||
projectKind: resolvedProjectRecord?.kind ?? "non_git",
|
||||
workspaceKind: workspace.kind,
|
||||
name: displayName,
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat,
|
||||
};
|
||||
return { ...base, name: displayName, diffStat };
|
||||
}
|
||||
|
||||
private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> {
|
||||
private async buildWorkspaceDescriptor(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
projectRecord?: PersistedProjectRecord | null;
|
||||
includeGitData: boolean;
|
||||
}): Promise<WorkspaceDescriptorPayload> {
|
||||
if (input.includeGitData && input.projectRecord?.kind === "git") {
|
||||
return this.describeWorkspaceRecordWithGitData(input.workspace, input.projectRecord);
|
||||
}
|
||||
return this.describeWorkspaceRecord(input.workspace, input.projectRecord);
|
||||
}
|
||||
|
||||
private async buildWorkspaceDescriptorMap(options: {
|
||||
includeGitData: boolean;
|
||||
workspaceIds?: Iterable<string>;
|
||||
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
|
||||
const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
|
||||
this.listAgentPayloads(),
|
||||
this.workspaceRegistry.list(),
|
||||
@@ -5343,14 +5409,26 @@ export class Session {
|
||||
.map((project) => [project.projectId, project] as const),
|
||||
);
|
||||
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
|
||||
const workspaceIds = options.workspaceIds
|
||||
? new Set(
|
||||
Array.from(options.workspaceIds, (workspaceId) =>
|
||||
normalizePersistedWorkspaceId(workspaceId),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
for (const workspace of activeRecords) {
|
||||
if (workspaceIds && !workspaceIds.has(workspace.workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
const projectRecord = activeProjects.get(workspace.projectId) ?? null;
|
||||
descriptorsByWorkspaceId.set(
|
||||
workspace.workspaceId,
|
||||
await this.describeWorkspaceRecord(
|
||||
await this.buildWorkspaceDescriptor({
|
||||
workspace,
|
||||
activeProjects.get(workspace.projectId) ?? null,
|
||||
),
|
||||
projectRecord,
|
||||
includeGitData: options.includeGitData,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5372,7 +5450,15 @@ export class Session {
|
||||
existing.activityAt = this.accumulateLatestActivityAt(existing.activityAt, agent);
|
||||
}
|
||||
|
||||
return Array.from(descriptorsByWorkspaceId.values());
|
||||
return descriptorsByWorkspaceId;
|
||||
}
|
||||
|
||||
private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> {
|
||||
return Array.from(
|
||||
(await this.buildWorkspaceDescriptorMap({
|
||||
includeGitData: false,
|
||||
})).values(),
|
||||
);
|
||||
}
|
||||
|
||||
private resolveRegisteredWorkspaceIdForCwd(
|
||||
@@ -5402,7 +5488,6 @@ export class Session {
|
||||
}
|
||||
|
||||
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||||
await this.reconcileActiveWorkspaceRecords();
|
||||
return this.listWorkspaceDescriptorsSnapshot();
|
||||
}
|
||||
|
||||
@@ -5732,80 +5817,56 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async emitWorkspaceUpdateForCwd(
|
||||
cwd: string,
|
||||
options?: { dedupeGitState?: boolean },
|
||||
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
|
||||
if (!this.workspaceUpdatesSubscription) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
|
||||
if (changedWorkspaceIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(changedWorkspaceIds, {
|
||||
skipReconcile: true,
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, "Background workspace reconciliation failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async emitWorkspaceUpdatesForWorkspaceIds(
|
||||
workspaceIds: Iterable<string>,
|
||||
options?: { dedupeGitState?: boolean; skipReconcile?: boolean },
|
||||
): Promise<void> {
|
||||
const subscription = this.workspaceUpdatesSubscription;
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
|
||||
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => !workspace.archivedAt,
|
||||
const uniqueWorkspaceIds = new Set(
|
||||
Array.from(workspaceIds, (workspaceId) => normalizePersistedWorkspaceId(workspaceId)),
|
||||
);
|
||||
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces);
|
||||
const all = await this.listWorkspaceDescriptorsSnapshot();
|
||||
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
|
||||
const workspaceIdsToEmit = new Set<string>([
|
||||
workspaceId,
|
||||
...changedWorkspaceIds,
|
||||
]);
|
||||
if (uniqueWorkspaceIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const nextWorkspaceId of workspaceIdsToEmit) {
|
||||
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId);
|
||||
const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({
|
||||
workspaceIds: uniqueWorkspaceIds,
|
||||
includeGitData: true,
|
||||
});
|
||||
|
||||
for (const workspaceId of uniqueWorkspaceIds) {
|
||||
const workspace = descriptorsByWorkspaceId.get(workspaceId);
|
||||
const nextWorkspace =
|
||||
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
|
||||
? workspace
|
||||
: null;
|
||||
if (
|
||||
options?.dedupeGitState &&
|
||||
this.shouldSkipWorkspaceGitWatchUpdate(nextWorkspaceId, nextWorkspace)
|
||||
this.shouldSkipWorkspaceGitWatchUpdate(workspaceId, nextWorkspace)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.rememberWorkspaceGitWatchFingerprint(nextWorkspaceId, nextWorkspace);
|
||||
|
||||
if (!nextWorkspace) {
|
||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||
kind: "remove",
|
||||
id: nextWorkspaceId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||
kind: "upsert",
|
||||
workspace: nextWorkspace,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
|
||||
if (!this.workspaceUpdatesSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
|
||||
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => !workspace.archivedAt,
|
||||
);
|
||||
const uniqueWorkspaceCwds = new Set<string>(changedWorkspaceIds);
|
||||
for (const cwd of cwds) {
|
||||
uniqueWorkspaceCwds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces));
|
||||
}
|
||||
|
||||
const subscription = this.workspaceUpdatesSubscription;
|
||||
const all = await this.listWorkspaceDescriptorsSnapshot();
|
||||
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
|
||||
|
||||
for (const workspaceId of uniqueWorkspaceCwds) {
|
||||
const workspace = descriptorsByWorkspaceId.get(workspaceId);
|
||||
const nextWorkspace =
|
||||
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
|
||||
? workspace
|
||||
: null;
|
||||
this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace);
|
||||
|
||||
if (!nextWorkspace) {
|
||||
@@ -5821,6 +5882,53 @@ export class Session {
|
||||
workspace: nextWorkspace,
|
||||
});
|
||||
}
|
||||
|
||||
if (!options?.skipReconcile) {
|
||||
void this.reconcileAndEmitWorkspaceUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleWorkspaceGitBootstrapUpdates(options: {
|
||||
subscriptionId: string;
|
||||
workspaces: Iterable<WorkspaceDescriptorPayload>;
|
||||
}): void {
|
||||
const gitWorkspaceIds = Array.from(options.workspaces, (workspace) => workspace)
|
||||
.filter((workspace) => workspace.projectKind === "git")
|
||||
.map((workspace) => workspace.id);
|
||||
if (gitWorkspaceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (this.workspaceUpdatesSubscription?.subscriptionId !== options.subscriptionId) {
|
||||
return;
|
||||
}
|
||||
void this.emitWorkspaceUpdatesForWorkspaceIds(gitWorkspaceIds, {
|
||||
skipReconcile: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async emitWorkspaceUpdateForCwd(
|
||||
cwd: string,
|
||||
options?: { dedupeGitState?: boolean },
|
||||
): Promise<void> {
|
||||
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => !workspace.archivedAt,
|
||||
);
|
||||
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces);
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
|
||||
}
|
||||
|
||||
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
|
||||
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => !workspace.archivedAt,
|
||||
);
|
||||
const uniqueWorkspaceIds = new Set<string>();
|
||||
for (const cwd of cwds) {
|
||||
uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces));
|
||||
}
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(uniqueWorkspaceIds);
|
||||
}
|
||||
|
||||
private async handleFetchAgents(
|
||||
@@ -5932,6 +6040,11 @@ export class Session {
|
||||
|
||||
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
||||
this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId });
|
||||
void this.reconcileAndEmitWorkspaceUpdates();
|
||||
this.scheduleWorkspaceGitBootstrapUpdates({
|
||||
subscriptionId,
|
||||
workspaces: payload.entries,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
||||
@@ -5958,7 +6071,7 @@ export class Session {
|
||||
try {
|
||||
const workspace = await this.ensureWorkspaceRegistered(request.cwd);
|
||||
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
|
||||
const descriptor = await this.describeWorkspaceRecord(workspace);
|
||||
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
|
||||
this.emit({
|
||||
type: "open_project_response",
|
||||
payload: {
|
||||
@@ -5981,13 +6094,84 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableEditorTargets() {
|
||||
return listAvailableEditorTargets();
|
||||
}
|
||||
|
||||
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
|
||||
await openInEditorTarget(options);
|
||||
}
|
||||
|
||||
private async handleListAvailableEditorsRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const editors = await this.getAvailableEditorTargets();
|
||||
this.emit({
|
||||
type: "list_available_editors_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
editors,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to list available editors";
|
||||
this.sessionLogger.error(
|
||||
{ err: error, requestType: request.type },
|
||||
"Failed to list available editors",
|
||||
);
|
||||
this.emit({
|
||||
type: "list_available_editors_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
editors: [],
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOpenInEditorRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "open_in_editor_request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.openEditorTarget({ editorId: request.editorId, path: request.path });
|
||||
this.emit({
|
||||
type: "open_in_editor_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to open in editor";
|
||||
this.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
editorId: request.editorId,
|
||||
path: request.path,
|
||||
requestType: request.type,
|
||||
},
|
||||
"Failed to open in editor",
|
||||
);
|
||||
this.emit({
|
||||
type: "open_in_editor_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCreatePaseoWorktreeRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||
): Promise<void> {
|
||||
return handleCreateWorktreeRequest(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecord(workspace),
|
||||
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
|
||||
emit: (message) => this.emit(message),
|
||||
registerPendingWorktreeWorkspace: (options) =>
|
||||
this.registerPendingWorktreeWorkspace(options),
|
||||
@@ -6399,9 +6583,10 @@ export class Session {
|
||||
: record.lastStatus === "error"
|
||||
? "error"
|
||||
: "idle";
|
||||
const error = resolveWaitForFinishError({ status, final });
|
||||
this.emit({
|
||||
type: "wait_for_finish_response",
|
||||
payload: { requestId, status, final, error: null, lastMessage: null },
|
||||
payload: { requestId, status, final, error, lastMessage: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -6428,10 +6613,11 @@ export class Session {
|
||||
: result.status === "error"
|
||||
? "error"
|
||||
: "idle";
|
||||
const error = resolveWaitForFinishError({ status, final });
|
||||
|
||||
this.emit({
|
||||
type: "wait_for_finish_response",
|
||||
payload: { requestId, status, final, error: null, lastMessage: result.lastMessage },
|
||||
payload: { requestId, status, final, error, lastMessage: result.lastMessage },
|
||||
});
|
||||
} catch (error) {
|
||||
const isAbort =
|
||||
@@ -6836,9 +7022,14 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
// Route voice utterances through the same send path as regular text input:
|
||||
// interrupt-if-running, record message, then start a new stream.
|
||||
await this.handleSendAgentMessage(agentId, result.text);
|
||||
await this.handleSendAgentMessage(
|
||||
agentId,
|
||||
result.text,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ spokenInput: true },
|
||||
);
|
||||
await this.flushPendingAudioSegments("transcription complete");
|
||||
}
|
||||
|
||||
@@ -7173,6 +7364,10 @@ export class Session {
|
||||
}
|
||||
this.checkoutDiffSubscriptions.clear();
|
||||
|
||||
for (const unsubscribe of this.workspaceGitFetchSubscriptions.values()) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.workspaceGitFetchSubscriptions.clear();
|
||||
for (const target of this.workspaceGitWatchTargets.values()) {
|
||||
this.closeWorkspaceGitWatchTarget(target);
|
||||
}
|
||||
|
||||
32
packages/server/src/server/session.wait-for-finish.test.ts
Normal file
32
packages/server/src/server/session.wait-for-finish.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveWaitForFinishError } from "./session.js";
|
||||
|
||||
describe("resolveWaitForFinishError", () => {
|
||||
test("returns the agent error when the wait result is an error", () => {
|
||||
expect(
|
||||
resolveWaitForFinishError({
|
||||
status: "error",
|
||||
final: { lastError: "invalid_json_schema" } as any,
|
||||
}),
|
||||
).toBe("invalid_json_schema");
|
||||
});
|
||||
|
||||
test("returns a generic fallback when the agent ended in error without a message", () => {
|
||||
expect(
|
||||
resolveWaitForFinishError({
|
||||
status: "error",
|
||||
final: {} as any,
|
||||
}),
|
||||
).toBe("Agent failed");
|
||||
});
|
||||
|
||||
test("returns null for non-error wait results", () => {
|
||||
expect(
|
||||
resolveWaitForFinishError({
|
||||
status: "idle",
|
||||
final: { lastError: "should not surface" } as any,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,14 @@ vi.mock("node:fs", async () => {
|
||||
});
|
||||
|
||||
vi.mock("./checkout-git-utils.js", () => ({
|
||||
READ_ONLY_GIT_ENV: {
|
||||
...process.env,
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
},
|
||||
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
|
||||
toCheckoutError: vi.fn((error: unknown) => ({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { Session } from "./session.js";
|
||||
@@ -51,10 +58,31 @@ import { Session } from "./session.js";
|
||||
function createSessionForWorkspaceGitWatchTests(): {
|
||||
session: Session;
|
||||
emitted: Array<{ type: string; payload: unknown }>;
|
||||
backgroundGitFetchManager: {
|
||||
subscribe: ReturnType<typeof vi.fn>;
|
||||
subscriptions: Array<{
|
||||
params: { repoGitRoot: string; cwd: string };
|
||||
listener: () => void;
|
||||
unsubscribe: ReturnType<typeof vi.fn>;
|
||||
}>;
|
||||
};
|
||||
logger: {
|
||||
child: () => unknown;
|
||||
trace: ReturnType<typeof vi.fn>;
|
||||
debug: ReturnType<typeof vi.fn>;
|
||||
info: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
} {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const projects = new Map<string, any>();
|
||||
const workspaces = new Map<string, any>();
|
||||
const backgroundGitFetchSubscriptions: Array<{
|
||||
params: { repoGitRoot: string; cwd: string };
|
||||
listener: () => void;
|
||||
unsubscribe: ReturnType<typeof vi.fn>;
|
||||
}> = [];
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
@@ -63,6 +91,19 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const backgroundGitFetchManager = {
|
||||
subscribe: vi.fn(
|
||||
async (params: { repoGitRoot: string; cwd: string }, listener: () => void) => {
|
||||
const unsubscribe = vi.fn();
|
||||
backgroundGitFetchSubscriptions.push({
|
||||
params,
|
||||
listener,
|
||||
unsubscribe,
|
||||
});
|
||||
return { unsubscribe };
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
@@ -140,6 +181,7 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
backgroundGitFetchManager: backgroundGitFetchManager as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
@@ -153,6 +195,11 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
return {
|
||||
session,
|
||||
emitted,
|
||||
backgroundGitFetchManager: {
|
||||
subscribe: backgroundGitFetchManager.subscribe,
|
||||
subscriptions: backgroundGitFetchSubscriptions,
|
||||
},
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,7 +255,8 @@ describe("workspace git watch targets", () => {
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
};
|
||||
|
||||
sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor];
|
||||
sessionAny.buildWorkspaceDescriptorMap = async () =>
|
||||
new Map([[descriptor.id, descriptor]]);
|
||||
|
||||
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
|
||||
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
|
||||
@@ -316,4 +364,148 @@ describe("workspace git watch targets", () => {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("subscribes to the background fetch manager when a git watch target is created", async () => {
|
||||
const { session, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests();
|
||||
const sessionAny = session as any;
|
||||
|
||||
sessionAny.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: path.basename(cwd),
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
|
||||
|
||||
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
|
||||
|
||||
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledWith(
|
||||
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
|
||||
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
test("stores separate background fetch subscriptions per workspace and unsubscribes removed targets", async () => {
|
||||
const { session, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests();
|
||||
const sessionAny = session as any;
|
||||
|
||||
sessionAny.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: path.basename(cwd),
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) =>
|
||||
cwd === "/tmp/repo" ? "/tmp/repo/.git" : "/tmp/repo/.git/worktrees/feature",
|
||||
);
|
||||
sessionAny.resolveWorkspaceGitRefsRoot = vi.fn(async () => "/tmp/repo/.git");
|
||||
|
||||
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
|
||||
await sessionAny.ensureWorkspaceRegistered("/tmp/repo-feature");
|
||||
|
||||
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledTimes(2);
|
||||
expect(backgroundGitFetchManager.subscriptions[0]?.params).toEqual({
|
||||
repoGitRoot: "/tmp/repo/.git",
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
expect(backgroundGitFetchManager.subscriptions[1]?.params).toEqual({
|
||||
repoGitRoot: "/tmp/repo/.git",
|
||||
cwd: "/tmp/repo-feature",
|
||||
});
|
||||
|
||||
sessionAny.removeWorkspaceGitWatchTarget("/tmp/repo");
|
||||
|
||||
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(backgroundGitFetchManager.subscriptions[1]?.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
|
||||
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
test("refreshes the workspace when the background fetch manager callback fires and unsubscribes on cleanup", async () => {
|
||||
const { session, emitted, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests();
|
||||
const sessionAny = session as any;
|
||||
|
||||
sessionAny.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: path.basename(cwd),
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
|
||||
sessionAny.workspaceUpdatesSubscription = {
|
||||
subscriptionId: "sub-1",
|
||||
filter: undefined,
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
};
|
||||
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
|
||||
|
||||
let descriptor = {
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
};
|
||||
|
||||
sessionAny.buildWorkspaceDescriptorMap = async () =>
|
||||
new Map([[descriptor.id, descriptor]]);
|
||||
|
||||
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
|
||||
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
|
||||
|
||||
descriptor = {
|
||||
...descriptor,
|
||||
name: "updated-after-fetch",
|
||||
};
|
||||
|
||||
backgroundGitFetchManager.subscriptions[0]?.listener();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const workspaceUpdates = emitted.filter(
|
||||
(message) => message.type === "workspace_update",
|
||||
) as any[];
|
||||
expect(workspaceUpdates).toHaveLength(1);
|
||||
expect(workspaceUpdates[0]?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "/tmp/repo",
|
||||
name: "updated-after-fetch",
|
||||
},
|
||||
});
|
||||
|
||||
await session.cleanup();
|
||||
|
||||
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -964,34 +964,42 @@ describe("workspace aggregation", () => {
|
||||
};
|
||||
session.reconcileActiveWorkspaceRecords = async () => new Set();
|
||||
|
||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "repo",
|
||||
status: "running",
|
||||
activityAt: "2026-03-01T12:00:00.000Z",
|
||||
},
|
||||
];
|
||||
session.buildWorkspaceDescriptorMap = async () =>
|
||||
new Map([
|
||||
[
|
||||
"/tmp/repo",
|
||||
{
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "repo",
|
||||
status: "running",
|
||||
activityAt: "2026-03-01T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
]);
|
||||
await session.emitWorkspaceUpdateForCwd("/tmp/repo");
|
||||
|
||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
},
|
||||
];
|
||||
session.buildWorkspaceDescriptorMap = async () =>
|
||||
new Map([
|
||||
[
|
||||
"/tmp/repo",
|
||||
{
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
await session.emitWorkspaceUpdateForCwd("/tmp/repo");
|
||||
|
||||
const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update");
|
||||
@@ -1083,44 +1091,57 @@ describe("workspace aggregation", () => {
|
||||
};
|
||||
session.reconcileActiveWorkspaceRecords = async () =>
|
||||
new Set(["/tmp/repo", "/tmp/repo/worktree"]);
|
||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
},
|
||||
{
|
||||
id: "/tmp/repo/worktree",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "feature",
|
||||
status: "running",
|
||||
activityAt: "2026-03-01T12:00:00.000Z",
|
||||
},
|
||||
];
|
||||
session.buildWorkspaceDescriptorMap = async () =>
|
||||
new Map([
|
||||
[
|
||||
"/tmp/repo",
|
||||
{
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
"/tmp/repo/worktree",
|
||||
{
|
||||
id: "/tmp/repo/worktree",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "feature",
|
||||
status: "running",
|
||||
activityAt: "2026-03-01T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
]);
|
||||
session.onMessage = (message: { type: string; payload: unknown }) => {
|
||||
emitted.push(message);
|
||||
};
|
||||
|
||||
await session.emitWorkspaceUpdateForCwd("/tmp/repo/worktree");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const workspaceUpdates = emitted.filter(
|
||||
(message) => message.type === "workspace_update",
|
||||
) as any[];
|
||||
expect(workspaceUpdates).toHaveLength(2);
|
||||
expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]);
|
||||
expect(workspaceUpdates).toHaveLength(3);
|
||||
expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual([
|
||||
"upsert",
|
||||
"upsert",
|
||||
"upsert",
|
||||
]);
|
||||
expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([
|
||||
"/tmp/repo",
|
||||
"/tmp/repo/worktree",
|
||||
"/tmp/repo/worktree",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1223,6 +1244,55 @@ describe("workspace aggregation", () => {
|
||||
expect(response?.payload.workspace?.id).toBe(repoRoot);
|
||||
});
|
||||
|
||||
test("list_available_editors_request returns available targets", async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const session = createSessionForWorkspaceTests() as any;
|
||||
|
||||
session.emit = (message: any) => emitted.push(message);
|
||||
session.getAvailableEditorTargets = async () => [
|
||||
{ id: "cursor", label: "Cursor" },
|
||||
{ id: "finder", label: "Finder" },
|
||||
];
|
||||
|
||||
await session.handleMessage({
|
||||
type: "list_available_editors_request",
|
||||
requestId: "req-editors",
|
||||
});
|
||||
|
||||
const response = emitted.find(
|
||||
(message) => message.type === "list_available_editors_response",
|
||||
) as any;
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.editors).toEqual([
|
||||
{ id: "cursor", label: "Cursor" },
|
||||
{ id: "finder", label: "Finder" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("open_in_editor_request launches the selected target", async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const session = createSessionForWorkspaceTests() as any;
|
||||
const calls: Array<{ editorId: string; path: string }> = [];
|
||||
|
||||
session.emit = (message: any) => emitted.push(message);
|
||||
session.openEditorTarget = async (input: { editorId: string; path: string }) => {
|
||||
calls.push(input);
|
||||
};
|
||||
|
||||
await session.handleMessage({
|
||||
type: "open_in_editor_request",
|
||||
requestId: "req-open-editor",
|
||||
editorId: "vscode",
|
||||
path: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]);
|
||||
const response = emitted.find(
|
||||
(message) => message.type === "open_in_editor_response",
|
||||
) as any;
|
||||
expect(response?.payload.error).toBeNull();
|
||||
});
|
||||
|
||||
test("archive_workspace_request hides non-destructive workspace records", async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const session = createSessionForWorkspaceTests() as any;
|
||||
@@ -1556,4 +1626,176 @@ describe("workspace aggregation", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline descriptor path", async () => {
|
||||
const session = createSessionForWorkspaceTests() as any;
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "/tmp/repo",
|
||||
rootPath: "/tmp/repo",
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/repo",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/repo",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
|
||||
session.listAgentPayloads = async () => [];
|
||||
session.projectRegistry.list = async () => [project];
|
||||
session.workspaceRegistry.list = async () => [workspace];
|
||||
|
||||
const baselineDescriptor = {
|
||||
id: workspace.workspaceId,
|
||||
projectId: project.projectId,
|
||||
projectDisplayName: project.displayName,
|
||||
projectRootPath: project.rootPath,
|
||||
projectKind: project.kind,
|
||||
workspaceKind: workspace.kind,
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
} as const;
|
||||
const gitDescriptor = {
|
||||
...baselineDescriptor,
|
||||
diffStat: { additions: 3, deletions: 1 },
|
||||
} as const;
|
||||
|
||||
session.describeWorkspaceRecord = vi.fn(async () => baselineDescriptor);
|
||||
session.describeWorkspaceRecordWithGitData = vi.fn(async () => gitDescriptor);
|
||||
|
||||
const descriptors = await session.listWorkspaceDescriptorsSnapshot();
|
||||
|
||||
expect(session.describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project);
|
||||
expect(session.describeWorkspaceRecordWithGitData).not.toHaveBeenCalled();
|
||||
expect(descriptors).toEqual([baselineDescriptor]);
|
||||
});
|
||||
|
||||
test("subscribed fetch_workspaces emits git enrichment updates after the baseline snapshot", async () => {
|
||||
const emitted: Array<{ type: string; payload: any }> = [];
|
||||
const session = createSessionForWorkspaceTests() as any;
|
||||
const gitProject = createPersistedProjectRecord({
|
||||
projectId: "/tmp/repo",
|
||||
rootPath: "/tmp/repo",
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const directoryProject = createPersistedProjectRecord({
|
||||
projectId: "/tmp/docs",
|
||||
rootPath: "/tmp/docs",
|
||||
kind: "non_git",
|
||||
displayName: "docs",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const gitWorkspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/repo",
|
||||
projectId: gitProject.projectId,
|
||||
cwd: "/tmp/repo",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const directoryWorkspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/docs",
|
||||
projectId: directoryProject.projectId,
|
||||
cwd: "/tmp/docs",
|
||||
kind: "directory",
|
||||
displayName: "docs",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const baselineGitDescriptor = {
|
||||
id: gitWorkspace.workspaceId,
|
||||
projectId: gitProject.projectId,
|
||||
projectDisplayName: gitProject.displayName,
|
||||
projectRootPath: gitProject.rootPath,
|
||||
projectKind: gitProject.kind,
|
||||
workspaceKind: gitWorkspace.kind,
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
} as const;
|
||||
const enrichedGitDescriptor = {
|
||||
...baselineGitDescriptor,
|
||||
diffStat: { additions: 3, deletions: 1 },
|
||||
} as const;
|
||||
const directoryDescriptor = {
|
||||
id: directoryWorkspace.workspaceId,
|
||||
projectId: directoryProject.projectId,
|
||||
projectDisplayName: directoryProject.displayName,
|
||||
projectRootPath: directoryProject.rootPath,
|
||||
projectKind: directoryProject.kind,
|
||||
workspaceKind: directoryWorkspace.kind,
|
||||
name: "docs",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
} as const;
|
||||
|
||||
session.emit = (message: any) => emitted.push(message);
|
||||
session.listAgentPayloads = async () => [];
|
||||
session.projectRegistry.list = async () => [gitProject, directoryProject];
|
||||
session.workspaceRegistry.list = async () => [gitWorkspace, directoryWorkspace];
|
||||
session.reconcileAndEmitWorkspaceUpdates = vi.fn(async () => {});
|
||||
session.describeWorkspaceRecord = vi.fn(
|
||||
async (workspace: typeof gitWorkspace | typeof directoryWorkspace, project: any) => {
|
||||
if (workspace.workspaceId === gitWorkspace.workspaceId) {
|
||||
expect(project).toEqual(gitProject);
|
||||
return baselineGitDescriptor;
|
||||
}
|
||||
expect(project).toEqual(directoryProject);
|
||||
return directoryDescriptor;
|
||||
},
|
||||
);
|
||||
session.describeWorkspaceRecordWithGitData = vi.fn(async () => enrichedGitDescriptor);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "req-fetch-workspaces",
|
||||
subscribe: {},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const response = emitted.find(
|
||||
(message) => message.type === "fetch_workspaces_response",
|
||||
) as { type: "fetch_workspaces_response"; payload: any } | undefined;
|
||||
expect(
|
||||
response?.payload.entries.map((entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [
|
||||
entry.id,
|
||||
entry.diffStat,
|
||||
]),
|
||||
).toEqual([
|
||||
[directoryDescriptor.id, directoryDescriptor.diffStat],
|
||||
[baselineGitDescriptor.id, baselineGitDescriptor.diffStat],
|
||||
]);
|
||||
|
||||
const workspaceUpdates = emitted.filter(
|
||||
(message) => message.type === "workspace_update",
|
||||
) as Array<{ type: "workspace_update"; payload: any }>;
|
||||
expect(workspaceUpdates).toEqual([
|
||||
{
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: enrichedGitDescriptor,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(session.describeWorkspaceRecordWithGitData).toHaveBeenCalledWith(
|
||||
gitWorkspace,
|
||||
gitProject,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,17 @@ import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js";
|
||||
const DEFAULT_SAMPLE_RATE = 16000;
|
||||
const DEFAULT_BUFFER_SIZE_SECONDS = 60;
|
||||
const DEFAULT_SILERO_THRESHOLD = 0.5;
|
||||
const DEFAULT_MIN_SILENCE_DURATION = 1.2;
|
||||
const DEFAULT_MIN_SPEECH_DURATION = 0.2;
|
||||
const DEFAULT_WINDOW_SIZE = 512;
|
||||
|
||||
// Silero internal durations — kept low so isDetected() tracks actual sound.
|
||||
// Our own state machine handles speech confirmation and end-of-speech detection.
|
||||
const SILERO_MIN_SILENCE_DURATION = 0.2;
|
||||
const SILERO_MIN_SPEECH_DURATION = 0.1;
|
||||
|
||||
// Our boundary detection thresholds (in milliseconds).
|
||||
const DEFAULT_CONFIRM_MS = 800;
|
||||
const DEFAULT_SILENCE_MS = 1000;
|
||||
|
||||
type SherpaVadHandle = {
|
||||
acceptWaveform(samples: Float32Array): void;
|
||||
isDetected(): boolean;
|
||||
@@ -42,20 +49,30 @@ export interface SherpaSileroVadSessionConfig {
|
||||
modelPath?: string;
|
||||
sampleRate?: number;
|
||||
threshold?: number;
|
||||
minSilenceDuration?: number;
|
||||
minSpeechDuration?: number;
|
||||
windowSize?: number;
|
||||
bufferSizeInSeconds?: number;
|
||||
confirmMs?: number;
|
||||
silenceMs?: number;
|
||||
}
|
||||
|
||||
type VadPhase =
|
||||
| { state: "idle" }
|
||||
| { state: "confirming"; startedAt: number }
|
||||
| { state: "speaking" }
|
||||
| { state: "ending"; startedAt: number };
|
||||
|
||||
export class SherpaSileroVadSession extends EventEmitter implements TurnDetectionSession {
|
||||
public readonly requiredSampleRate: number;
|
||||
|
||||
private readonly vad: SherpaVadHandle;
|
||||
private readonly inputBuffer: SherpaCircularBufferHandle;
|
||||
private readonly windowSize: number;
|
||||
private readonly msPerWindow: number;
|
||||
private readonly confirmMs: number;
|
||||
private readonly silenceMs: number;
|
||||
private connected = false;
|
||||
private inSpeech = false;
|
||||
private phase: VadPhase = { state: "idle" };
|
||||
private windowTimestamp = 0;
|
||||
private readonly logger;
|
||||
|
||||
constructor(params: {
|
||||
@@ -67,16 +84,35 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
|
||||
const config = params.config ?? {};
|
||||
this.requiredSampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
||||
this.windowSize = config.windowSize ?? DEFAULT_WINDOW_SIZE;
|
||||
this.msPerWindow = (this.windowSize / this.requiredSampleRate) * 1000;
|
||||
this.confirmMs = config.confirmMs ?? DEFAULT_CONFIRM_MS;
|
||||
this.silenceMs = config.silenceMs ?? DEFAULT_SILENCE_MS;
|
||||
|
||||
const threshold = config.threshold ?? DEFAULT_SILERO_THRESHOLD;
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
threshold,
|
||||
sileroMinSilenceDuration: SILERO_MIN_SILENCE_DURATION,
|
||||
sileroMinSpeechDuration: SILERO_MIN_SPEECH_DURATION,
|
||||
confirmMs: this.confirmMs,
|
||||
silenceMs: this.silenceMs,
|
||||
windowSize: this.windowSize,
|
||||
msPerWindow: this.msPerWindow,
|
||||
sampleRate: this.requiredSampleRate,
|
||||
},
|
||||
"[VAD] Initializing Silero VAD session",
|
||||
);
|
||||
|
||||
const sherpa = loadSherpaOnnxNode() as unknown as SherpaVadModule;
|
||||
this.vad = new sherpa.Vad(
|
||||
{
|
||||
sileroVad: {
|
||||
model: config.modelPath ?? resolveBundledSileroVadModelPath(),
|
||||
threshold: config.threshold ?? DEFAULT_SILERO_THRESHOLD,
|
||||
minSilenceDuration: config.minSilenceDuration ?? DEFAULT_MIN_SILENCE_DURATION,
|
||||
minSpeechDuration: config.minSpeechDuration ?? DEFAULT_MIN_SPEECH_DURATION,
|
||||
windowSize: config.windowSize ?? DEFAULT_WINDOW_SIZE,
|
||||
threshold,
|
||||
minSilenceDuration: SILERO_MIN_SILENCE_DURATION,
|
||||
minSpeechDuration: SILERO_MIN_SPEECH_DURATION,
|
||||
windowSize: this.windowSize,
|
||||
},
|
||||
sampleRate: this.requiredSampleRate,
|
||||
numThreads: 1,
|
||||
@@ -106,11 +142,14 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
|
||||
try {
|
||||
const samples = pcm16leToFloat32(pcm16le, 1);
|
||||
this.inputBuffer.push(samples);
|
||||
let windowsProcessed = 0;
|
||||
while (this.inputBuffer.size() > this.windowSize) {
|
||||
const window = this.inputBuffer.get(this.inputBuffer.head(), this.windowSize, false);
|
||||
this.inputBuffer.pop(this.windowSize);
|
||||
this.vad.acceptWaveform(window);
|
||||
this.syncDetectionState();
|
||||
this.windowTimestamp += this.msPerWindow;
|
||||
windowsProcessed++;
|
||||
this.stepStateMachine();
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||
@@ -123,41 +162,111 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug(
|
||||
{ phase: this.phase.state },
|
||||
"[VAD] Flushing remaining audio",
|
||||
);
|
||||
this.vad.flush();
|
||||
this.syncDetectionState();
|
||||
if (this.inSpeech) {
|
||||
this.inSpeech = false;
|
||||
this.stepStateMachine();
|
||||
if (this.phase.state === "speaking" || this.phase.state === "ending") {
|
||||
this.logger.debug("[VAD] Forcing speech_stopped after flush");
|
||||
this.phase = { state: "idle" };
|
||||
this.emit("speech_stopped");
|
||||
} else if (this.phase.state === "confirming") {
|
||||
this.logger.debug("[VAD] Discarding unconfirmed speech on flush");
|
||||
this.phase = { state: "idle" };
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
reset(): void {
|
||||
try {
|
||||
this.vad.reset();
|
||||
this.inputBuffer.reset();
|
||||
} catch {
|
||||
// ignore native cleanup failures
|
||||
} finally {
|
||||
this.connected = false;
|
||||
this.inSpeech = false;
|
||||
this.phase = { state: "idle" };
|
||||
}
|
||||
}
|
||||
|
||||
private syncDetectionState(): void {
|
||||
const detected = this.vad.isDetected();
|
||||
if (detected && !this.inSpeech) {
|
||||
this.inSpeech = true;
|
||||
this.emit("speech_started");
|
||||
return;
|
||||
}
|
||||
close(): void {
|
||||
this.reset();
|
||||
this.connected = false;
|
||||
this.windowTimestamp = 0;
|
||||
}
|
||||
|
||||
if (!detected && this.inSpeech && !this.vad.isEmpty()) {
|
||||
this.logger.debug("Silero VAD marked end of speech");
|
||||
this.inSpeech = false;
|
||||
this.emit("speech_stopped");
|
||||
private stepStateMachine(): void {
|
||||
const detected = this.vad.isDetected();
|
||||
const now = this.windowTimestamp;
|
||||
|
||||
switch (this.phase.state) {
|
||||
case "idle": {
|
||||
if (detected) {
|
||||
this.logger.debug(
|
||||
{ now },
|
||||
"[VAD] idle → confirming (detection started)",
|
||||
);
|
||||
this.phase = { state: "confirming", startedAt: now };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "confirming": {
|
||||
if (!detected) {
|
||||
const elapsed = now - this.phase.startedAt;
|
||||
this.logger.debug(
|
||||
{ elapsed, confirmMs: this.confirmMs },
|
||||
"[VAD] confirming → idle (detection dropped before confirmation)",
|
||||
);
|
||||
this.phase = { state: "idle" };
|
||||
break;
|
||||
}
|
||||
const elapsed = now - this.phase.startedAt;
|
||||
if (elapsed >= this.confirmMs) {
|
||||
this.logger.debug(
|
||||
{ elapsed, confirmMs: this.confirmMs },
|
||||
"[VAD] confirming → speaking (speech confirmed)",
|
||||
);
|
||||
this.phase = { state: "speaking" };
|
||||
this.emit("speech_started");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "speaking": {
|
||||
if (!detected) {
|
||||
this.logger.debug(
|
||||
{ now },
|
||||
"[VAD] speaking → ending (silence started)",
|
||||
);
|
||||
this.phase = { state: "ending", startedAt: now };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ending": {
|
||||
if (detected) {
|
||||
this.logger.debug(
|
||||
{ elapsed: now - this.phase.startedAt },
|
||||
"[VAD] ending → speaking (speech resumed)",
|
||||
);
|
||||
this.phase = { state: "speaking" };
|
||||
break;
|
||||
}
|
||||
const elapsed = now - this.phase.startedAt;
|
||||
if (elapsed >= this.silenceMs) {
|
||||
this.logger.debug(
|
||||
{ elapsed, silenceMs: this.silenceMs },
|
||||
"[VAD] ending → idle (speech stopped)",
|
||||
);
|
||||
this.phase = { state: "idle" };
|
||||
this.emit("speech_stopped");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export type TurnDetectionSession = {
|
||||
connect(): Promise<void>;
|
||||
appendPcm16(pcm16le: Buffer): void;
|
||||
flush(): void;
|
||||
reset(): void;
|
||||
close(): void;
|
||||
|
||||
on(event: "speech_started", handler: () => void): unknown;
|
||||
|
||||
@@ -53,6 +53,10 @@ export function buildVoiceModeSystemPrompt(existing: string | undefined, enabled
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function wrapSpokenInput(text: string): string {
|
||||
return `<spoken-input>\n${text}\n</spoken-input>`;
|
||||
}
|
||||
|
||||
export function buildVoiceAgentMcpServerConfig(params: {
|
||||
command: string;
|
||||
baseArgs: string[];
|
||||
|
||||
@@ -19,6 +19,7 @@ class FakeTurnDetectionSession extends EventEmitter implements TurnDetectionSess
|
||||
}
|
||||
|
||||
flush(): void {}
|
||||
reset(): void {}
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { FixedDurationPcmRingBuffer } from "./fixed-duration-pcm-ring-buffer.js"
|
||||
|
||||
const PCM_CHANNELS = 1;
|
||||
const PCM_BITS_PER_SAMPLE = 16;
|
||||
const DEFAULT_PREFIX_DURATION_MS = 400;
|
||||
const DEFAULT_PREFIX_DURATION_MS = 1000;
|
||||
|
||||
type VoiceInputState =
|
||||
| { status: "idle" }
|
||||
@@ -134,6 +134,8 @@ export function createVoiceTurnController(params: {
|
||||
utteranceChunks = [];
|
||||
state = { status: "listening", rollingPrefixBytes: prefixBuffer.byteLength };
|
||||
|
||||
detector.reset();
|
||||
|
||||
await params.callbacks.onSpeechStopped();
|
||||
|
||||
params.logger.info(
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js";
|
||||
import type { LoopService } from "./loop-service.js";
|
||||
import type { ScheduleService } from "./schedule/service.js";
|
||||
import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js";
|
||||
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
|
||||
import {
|
||||
type ServerInfoStatusPayload,
|
||||
type WSHelloMessage,
|
||||
@@ -237,6 +238,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly loopService: LoopService;
|
||||
private readonly scheduleService: ScheduleService;
|
||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
||||
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly paseoHome: string;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
@@ -338,6 +340,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
|
||||
}
|
||||
this.checkoutDiffManager = checkoutDiffManager;
|
||||
this.backgroundGitFetchManager = new BackgroundGitFetchManager({
|
||||
logger: this.logger,
|
||||
});
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
@@ -507,6 +512,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
await Promise.all(cleanupPromises);
|
||||
this.providerSnapshotManager.destroy();
|
||||
this.backgroundGitFetchManager.dispose();
|
||||
this.checkoutDiffManager.dispose();
|
||||
this.pendingConnections.clear();
|
||||
this.sessions.clear();
|
||||
@@ -653,6 +659,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
loopService: this.loopService,
|
||||
scheduleService: this.scheduleService,
|
||||
checkoutDiffManager: this.checkoutDiffManager,
|
||||
backgroundGitFetchManager: this.backgroundGitFetchManager,
|
||||
createAgentMcpTransport: this.createAgentMcpTransport,
|
||||
stt: () => this.speech?.resolveStt() ?? null,
|
||||
tts: () => this.speech?.resolveTts() ?? null,
|
||||
|
||||
@@ -139,6 +139,74 @@ describe("shared messages stream parsing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("parses optional permission actions and selectedActionId compatibly", () => {
|
||||
const requestParsed = AgentStreamMessageSchema.parse({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent_live",
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
event: {
|
||||
type: "permission_requested",
|
||||
provider: "codex",
|
||||
request: {
|
||||
id: "perm-1",
|
||||
provider: "codex",
|
||||
name: "CodexPlanApproval",
|
||||
kind: "plan",
|
||||
input: { plan: "- step 1" },
|
||||
actions: [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "implement",
|
||||
label: "Implement",
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
intent: "implement",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestParsed.payload.event.type).toBe("permission_requested");
|
||||
if (requestParsed.payload.event.type === "permission_requested") {
|
||||
expect(requestParsed.payload.event.request.actions).toHaveLength(2);
|
||||
expect(requestParsed.payload.event.request.actions?.[1]?.label).toBe("Implement");
|
||||
}
|
||||
|
||||
const resolutionParsed = AgentStreamMessageSchema.parse({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent_live",
|
||||
timestamp: "2026-02-08T20:10:01.000Z",
|
||||
event: {
|
||||
type: "permission_resolved",
|
||||
provider: "claude",
|
||||
requestId: "perm-1",
|
||||
resolution: {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement_resume",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolutionParsed.payload.event.type).toBe("permission_resolved");
|
||||
if (resolutionParsed.payload.event.type === "permission_resolved") {
|
||||
expect(resolutionParsed.payload.event.resolution).toEqual({
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement_resume",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects removed initialize_agent_request inbound payload", () => {
|
||||
const parsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "initialize_agent_request",
|
||||
|
||||
@@ -146,6 +146,8 @@ const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
|
||||
cachedInputTokens: z.number().optional(),
|
||||
outputTokens: z.number().optional(),
|
||||
totalCostUsd: z.number().optional(),
|
||||
contextWindowMaxTokens: z.number().optional(),
|
||||
contextWindowUsedTokens: z.number().optional(),
|
||||
});
|
||||
|
||||
const McpStdioServerConfigSchema = z.object({
|
||||
@@ -197,15 +199,24 @@ const AgentSessionConfigSchema = z.object({
|
||||
});
|
||||
|
||||
const AgentPermissionUpdateSchema = z.record(z.unknown());
|
||||
const AgentPermissionActionSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
behavior: z.enum(["allow", "deny"]),
|
||||
variant: z.enum(["primary", "secondary", "danger"]).optional(),
|
||||
intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(),
|
||||
});
|
||||
|
||||
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> = z.union([
|
||||
z.object({
|
||||
behavior: z.literal("allow"),
|
||||
selectedActionId: z.string().optional(),
|
||||
updatedInput: z.record(z.unknown()).optional(),
|
||||
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||
}),
|
||||
z.object({
|
||||
behavior: z.literal("deny"),
|
||||
selectedActionId: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
interrupt: z.boolean().optional(),
|
||||
}),
|
||||
@@ -220,6 +231,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
|
||||
description: z.string().optional(),
|
||||
input: z.record(z.unknown()).optional(),
|
||||
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||
actions: z.array(AgentPermissionActionSchema).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
@@ -969,6 +981,7 @@ const CheckoutErrorSchema = z.object({
|
||||
const CheckoutDiffCompareSchema = z.object({
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
baseRef: z.string().optional(),
|
||||
ignoreWhitespace: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const CheckoutStatusRequestSchema = z.object({
|
||||
@@ -1083,6 +1096,32 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const EditorTargetIdSchema = z.enum([
|
||||
"cursor",
|
||||
"vscode",
|
||||
"zed",
|
||||
"finder",
|
||||
"explorer",
|
||||
"file-manager",
|
||||
]);
|
||||
|
||||
export const EditorTargetDescriptorPayloadSchema = z.object({
|
||||
id: EditorTargetIdSchema,
|
||||
label: z.string(),
|
||||
});
|
||||
|
||||
export const ListAvailableEditorsRequestSchema = z.object({
|
||||
type: z.literal("list_available_editors_request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const OpenInEditorRequestSchema = z.object({
|
||||
type: z.literal("open_in_editor_request"),
|
||||
path: z.string(),
|
||||
editorId: EditorTargetIdSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const OpenProjectRequestSchema = z.object({
|
||||
type: z.literal("open_project_request"),
|
||||
cwd: z.string(),
|
||||
@@ -1340,6 +1379,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoWorktreeListRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
CreatePaseoWorktreeRequestSchema,
|
||||
ListAvailableEditorsRequestSchema,
|
||||
OpenInEditorRequestSchema,
|
||||
OpenProjectRequestSchema,
|
||||
ArchiveWorkspaceRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
@@ -1827,6 +1868,23 @@ export const OpenProjectResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ListAvailableEditorsResponseMessageSchema = z.object({
|
||||
type: z.literal("list_available_editors_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
editors: z.array(EditorTargetDescriptorPayloadSchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const OpenInEditorResponseMessageSchema = z.object({
|
||||
type: z.literal("open_in_editor_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ArchiveWorkspaceResponseMessageSchema = z.object({
|
||||
type: z.literal("archive_workspace_response"),
|
||||
payload: z.object({
|
||||
@@ -2474,6 +2532,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FetchAgentsResponseMessageSchema,
|
||||
FetchWorkspacesResponseMessageSchema,
|
||||
OpenProjectResponseMessageSchema,
|
||||
ListAvailableEditorsResponseMessageSchema,
|
||||
OpenInEditorResponseMessageSchema,
|
||||
ArchiveWorkspaceResponseMessageSchema,
|
||||
FetchAgentResponseMessageSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
@@ -2566,9 +2626,15 @@ export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePaylo
|
||||
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
|
||||
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
|
||||
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
|
||||
export type EditorTargetId = z.infer<typeof EditorTargetIdSchema>;
|
||||
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
|
||||
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
|
||||
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
||||
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
|
||||
export type ListAvailableEditorsResponseMessage = z.infer<
|
||||
typeof ListAvailableEditorsResponseMessageSchema
|
||||
>;
|
||||
export type OpenInEditorResponseMessage = z.infer<typeof OpenInEditorResponseMessageSchema>;
|
||||
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
|
||||
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
|
||||
export type FetchAgentTimelineResponseMessage = z.infer<
|
||||
@@ -2716,6 +2782,8 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
|
||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
||||
export type ListAvailableEditorsRequest = z.infer<typeof ListAvailableEditorsRequestSchema>;
|
||||
export type OpenInEditorRequest = z.infer<typeof OpenInEditorRequestSchema>;
|
||||
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
||||
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||
|
||||
@@ -29,6 +29,27 @@ describe("workspace message schemas", () => {
|
||||
expect(parsed.type).toBe("open_project_request");
|
||||
});
|
||||
|
||||
test("parses list_available_editors_request", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "list_available_editors_request",
|
||||
requestId: "req-editors",
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("list_available_editors_request");
|
||||
});
|
||||
|
||||
test("parses open_in_editor_response", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "open_in_editor_response",
|
||||
payload: {
|
||||
requestId: "req-open-editor",
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("open_in_editor_response");
|
||||
});
|
||||
|
||||
test("rejects invalid workspace update payload", () => {
|
||||
const result = SessionOutboundMessageSchema.safeParse({
|
||||
type: "workspace_update",
|
||||
|
||||
@@ -104,6 +104,21 @@ describe("checkout git utilities", () => {
|
||||
expect(message).toBe("update file");
|
||||
});
|
||||
|
||||
it("hides whitespace-only changes when requested", async () => {
|
||||
writeFileSync(join(repoDir, "file.txt"), "hello \n");
|
||||
|
||||
const visibleDiff = await getCheckoutDiff(repoDir, { mode: "uncommitted" });
|
||||
expect(visibleDiff.diff).toContain("file.txt");
|
||||
|
||||
const hiddenDiff = await getCheckoutDiff(repoDir, {
|
||||
mode: "uncommitted",
|
||||
ignoreWhitespace: true,
|
||||
includeStructured: true,
|
||||
});
|
||||
expect(hiddenDiff.diff).toBe("");
|
||||
expect(hiddenDiff.structured).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves removed-line syntax highlighting with structured diffs", async () => {
|
||||
const originalContent = `/*
|
||||
comment line 1
|
||||
|
||||
@@ -275,13 +275,25 @@ export async function listBranchSuggestions(
|
||||
return ordered.slice(0, limit);
|
||||
}
|
||||
|
||||
async function listCheckoutFileChanges(cwd: string, ref: string): Promise<CheckoutFileChange[]> {
|
||||
async function listCheckoutFileChanges(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
ignoreWhitespace = false,
|
||||
): Promise<CheckoutFileChange[]> {
|
||||
const changes: CheckoutFileChange[] = [];
|
||||
|
||||
const { stdout: nameStatusOut } = await execGit(`git diff --name-status ${ref}`, {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const { stdout: nameStatusOut } = await execFileAsync(
|
||||
"git",
|
||||
buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--name-status", ref],
|
||||
}),
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBuffer: SMALL_OUTPUT_MAX_BUFFER,
|
||||
},
|
||||
);
|
||||
for (const line of nameStatusOut
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
@@ -396,13 +408,24 @@ function normalizeNumstatPath(pathField: string): string {
|
||||
return pathField;
|
||||
}
|
||||
|
||||
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
|
||||
return ["diff", ...(args.ignoreWhitespace ? ["-w"] : []), ...args.extra];
|
||||
}
|
||||
|
||||
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const TRACKED_MAX_CHANGED_LINES = 40_000;
|
||||
|
||||
async function getTrackedNumstatByPath(cwd: string, ref: string): Promise<Map<string, FileStat>> {
|
||||
async function getTrackedNumstatByPath(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
ignoreWhitespace = false,
|
||||
): Promise<Map<string, FileStat>> {
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", "--numstat", ref],
|
||||
args: buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--numstat", ref],
|
||||
}),
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
||||
@@ -578,6 +601,7 @@ export interface CheckoutDiffResult {
|
||||
export interface CheckoutDiffCompare {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
includeStructured?: boolean;
|
||||
}
|
||||
|
||||
@@ -1072,6 +1096,7 @@ function buildPlaceholderParsedDiffFile(
|
||||
async function getUntrackedDiffText(
|
||||
cwd: string,
|
||||
change: CheckoutFileChange,
|
||||
ignoreWhitespace = false,
|
||||
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
||||
try {
|
||||
const inspected = await inspectUntrackedFile(cwd, change.path);
|
||||
@@ -1084,7 +1109,10 @@ async function getUntrackedDiffText(
|
||||
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
||||
args: buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--no-index", "/dev/null", "--", change.path],
|
||||
}),
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||
@@ -1299,7 +1327,8 @@ export async function getCheckoutDiff(
|
||||
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef;
|
||||
}
|
||||
|
||||
const changes = await listCheckoutFileChanges(cwd, refForDiff);
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
|
||||
changes.sort((a, b) => {
|
||||
if (a.path === b.path) return 0;
|
||||
return a.path < b.path ? -1 : 1;
|
||||
@@ -1330,7 +1359,7 @@ export async function getCheckoutDiff(
|
||||
|
||||
const trackedNumstatByPath =
|
||||
trackedChanges.length > 0
|
||||
? await getTrackedNumstatByPath(cwd, refForDiff)
|
||||
? await getTrackedNumstatByPath(cwd, refForDiff, ignoreWhitespace)
|
||||
: new Map<string, FileStat>();
|
||||
const trackedDiffPaths: string[] = [];
|
||||
const trackedPlaceholderByPath = new Map<
|
||||
@@ -1356,7 +1385,10 @@ export async function getCheckoutDiff(
|
||||
if (trackedDiffPaths.length > 0) {
|
||||
const trackedDiffResult = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", refForDiff, "--", ...trackedDiffPaths],
|
||||
args: buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: [refForDiff, "--", ...trackedDiffPaths],
|
||||
}),
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: TOTAL_DIFF_MAX_BYTES,
|
||||
@@ -1445,7 +1477,11 @@ export async function getCheckoutDiff(
|
||||
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
||||
break;
|
||||
}
|
||||
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change);
|
||||
const { text, truncated, stat } = await getUntrackedDiffText(
|
||||
cwd,
|
||||
change,
|
||||
ignoreWhitespace,
|
||||
);
|
||||
|
||||
if (!compare.includeStructured) {
|
||||
if (stat?.isBinary) {
|
||||
|
||||
@@ -149,6 +149,71 @@ describe("createWorktree", () => {
|
||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
||||
});
|
||||
|
||||
it("prefers origin/{branch} over local {branch} when both exist", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||
execSync(`git init --bare ${remoteDir}`);
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||
execSync("git push -u origin main", { cwd: repoDir });
|
||||
|
||||
execSync(`git clone ${remoteDir} ${remoteCloneDir}`);
|
||||
execSync("git config user.email 'test@test.com'", { cwd: remoteCloneDir });
|
||||
execSync("git config user.name 'Test'", { cwd: remoteCloneDir });
|
||||
writeFileSync(join(remoteCloneDir, "file.txt"), "from-origin\n");
|
||||
execSync("git add file.txt", { cwd: remoteCloneDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'advance origin main'", {
|
||||
cwd: remoteCloneDir,
|
||||
});
|
||||
execSync("git push origin main", { cwd: remoteCloneDir });
|
||||
|
||||
writeFileSync(join(repoDir, "file.txt"), "from-local\n");
|
||||
execSync("git add file.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'advance local main'", { cwd: repoDir });
|
||||
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
|
||||
const result = await createWorktree({
|
||||
branchName: "prefer-origin-feature",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "prefer-origin-feature",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-origin\n");
|
||||
});
|
||||
|
||||
it("falls back to local {branch} when origin/{branch} does not exist", async () => {
|
||||
writeFileSync(join(repoDir, "file.txt"), "from-local-only\n");
|
||||
execSync("git add file.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'advance local main only'", { cwd: repoDir });
|
||||
|
||||
const result = await createWorktree({
|
||||
branchName: "prefer-local-fallback-feature",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "prefer-local-fallback-feature",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-local-only\n");
|
||||
});
|
||||
|
||||
it("throws when neither origin/{branch} nor local {branch} exists", async () => {
|
||||
await expect(
|
||||
createWorktree({
|
||||
branchName: "missing-base-feature",
|
||||
cwd: repoDir,
|
||||
baseBranch: "does-not-exist",
|
||||
worktreeSlug: "missing-base-feature",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toThrow("Base branch not found: does-not-exist");
|
||||
});
|
||||
|
||||
it("fails with invalid branch name", async () => {
|
||||
await expect(
|
||||
createWorktree({
|
||||
|
||||
@@ -924,15 +924,14 @@ export async function createWorktree({
|
||||
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
|
||||
}
|
||||
|
||||
// Resolve the base branch - try local first, then remote
|
||||
// Resolve the base branch - prefer origin/{branch}, then fall back to local
|
||||
let resolvedBaseBranch = normalizedBaseBranch;
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
|
||||
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
|
||||
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
|
||||
} catch {
|
||||
// Local branch doesn't exist, try remote (origin/{branch})
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
|
||||
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
|
||||
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
|
||||
} catch {
|
||||
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.50",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -23,9 +23,9 @@ import { Route as BlogIndexRouteImport } from './routes/blog/index'
|
||||
import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees'
|
||||
import { Route as DocsVoiceRouteImport } from './routes/docs/voice'
|
||||
import { Route as DocsUpdatesRouteImport } from './routes/docs/updates'
|
||||
import { Route as DocsSkillsRouteImport } from './routes/docs/skills'
|
||||
import { Route as DocsSecurityRouteImport } from './routes/docs/security'
|
||||
import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
|
||||
import { Route as DocsSkillsRouteImport } from './routes/docs/skills'
|
||||
import { Route as DocsCliRouteImport } from './routes/docs/cli'
|
||||
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
|
||||
import { Route as BlogSplatRouteImport } from './routes/blog/$'
|
||||
@@ -100,6 +100,11 @@ const DocsUpdatesRoute = DocsUpdatesRouteImport.update({
|
||||
path: '/updates',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
const DocsSkillsRoute = DocsSkillsRouteImport.update({
|
||||
id: '/skills',
|
||||
path: '/skills',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
const DocsSecurityRoute = DocsSecurityRouteImport.update({
|
||||
id: '/security',
|
||||
path: '/security',
|
||||
@@ -110,11 +115,6 @@ const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
|
||||
path: '/configuration',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
const DocsSkillsRoute = DocsSkillsRouteImport.update({
|
||||
id: '/skills',
|
||||
path: '/skills',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
const DocsCliRoute = DocsCliRouteImport.update({
|
||||
id: '/cli',
|
||||
path: '/cli',
|
||||
@@ -375,6 +375,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DocsUpdatesRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/skills': {
|
||||
id: '/docs/skills'
|
||||
path: '/skills'
|
||||
fullPath: '/docs/skills'
|
||||
preLoaderRoute: typeof DocsSkillsRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/security': {
|
||||
id: '/docs/security'
|
||||
path: '/security'
|
||||
@@ -389,13 +396,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DocsConfigurationRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/skills': {
|
||||
id: '/docs/skills'
|
||||
path: '/skills'
|
||||
fullPath: '/docs/skills'
|
||||
preLoaderRoute: typeof DocsSkillsRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/cli': {
|
||||
id: '/docs/cli'
|
||||
path: '/cli'
|
||||
|
||||
Reference in New Issue
Block a user