mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ee9329663 | ||
|
|
b30aafc2bd | ||
|
|
90cb20ce79 | ||
|
|
bd6feac235 | ||
|
|
9f3256a254 | ||
|
|
4165dbe8e9 | ||
|
|
15e15b525f | ||
|
|
371c29fecd | ||
|
|
7d823fd3ec | ||
|
|
ab4525db5b | ||
|
|
73d27bd5fe | ||
|
|
6db5200890 | ||
|
|
9dd5c6e5f3 | ||
|
|
407c4ca554 | ||
|
|
47f26e99db | ||
|
|
263886fc1c | ||
|
|
bb5ac5b1d0 | ||
|
|
9c1c2cea76 | ||
|
|
c0037e56af | ||
|
|
4f16056578 | ||
|
|
a4bcc7b169 | ||
|
|
f17fb77013 | ||
|
|
0079f2a875 | ||
|
|
0abcd21cd3 | ||
|
|
5fb6e0e74d | ||
|
|
d88af28e93 | ||
|
|
73f35537f9 | ||
|
|
e5deb90986 | ||
|
|
d5ffc51f07 | ||
|
|
861917836a |
121
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
121
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
name: Bug report
|
||||
description: Something is broken or doesn't behave the way it should.
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before opening, please:
|
||||
|
||||
- search existing issues for the same symptom
|
||||
- try to reproduce on the latest version
|
||||
- if it's a UI bug, capture a screenshot or short video. text descriptions of UI bugs almost always lose detail.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What's broken
|
||||
description: What happened, and what did you expect to happen instead?
|
||||
placeholder: |
|
||||
I tried to X, expected Y, got Z.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: The shortest sequence that triggers the bug. If you can't reproduce on demand, say so.
|
||||
placeholder: |
|
||||
1. Open the desktop app
|
||||
2. Pair a daemon
|
||||
3. Click X
|
||||
4. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: Where did this happen
|
||||
description: The surface you saw the bug on. Pick the closest match.
|
||||
options:
|
||||
- iOS app
|
||||
- Android app
|
||||
- Web (browser)
|
||||
- Desktop (Electron)
|
||||
- CLI
|
||||
- Daemon
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: paseo-version
|
||||
attributes:
|
||||
label: Paseo version
|
||||
description: Settings → About in the app, or `paseo --version` from the CLI.
|
||||
placeholder: "0.1.71"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: OS version
|
||||
description: Only relevant for desktop, CLI, or daemon issues. Skip for mobile or web.
|
||||
placeholder: "macOS 15.2, Windows 11, Ubuntu 24.04"
|
||||
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: Agent provider
|
||||
description: If the bug involves a specific agent provider, pick which one.
|
||||
options:
|
||||
- Not relevant
|
||||
- Claude Code
|
||||
- Codex
|
||||
- OpenCode
|
||||
- Custom provider
|
||||
|
||||
- type: textarea
|
||||
id: provider-details
|
||||
attributes:
|
||||
label: Provider configuration
|
||||
description: |
|
||||
If the bug involves an agent, what version are you on and what API are you using? Provider behavior changes a lot across versions and API backends.
|
||||
placeholder: |
|
||||
Claude Code v1.2.3 with Anthropic API
|
||||
Codex CLI v0.5.0 with OpenAI API
|
||||
OpenCode v0.3.1
|
||||
(or paste the relevant section of ~/.paseo/config.json for custom providers)
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
Paste relevant log output. Strongly preferred for crashes and daemon issues.
|
||||
|
||||
- **Daemon log:** `~/.paseo/daemon.log` (override with `$PASEO_HOME`)
|
||||
- **Electron log (macOS):** `~/Library/Logs/Paseo/main.log`
|
||||
- **Electron log (Windows):** `%APPDATA%\Paseo\logs\main.log`
|
||||
- **Electron log (Linux):** `~/.config/Paseo/logs/main.log`
|
||||
|
||||
Paste the **full log around the time of the bug**, not a summary. If you used an AI to investigate, paste the raw log it read, not the AI's interpretation. AI summaries skew the signal and waste my time.
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots or video
|
||||
description: |
|
||||
**Required for UI bugs.** Drag and drop directly into this field. Short videos beat screenshots for anything involving interaction or animation.
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
---
|
||||
|
||||
**A note on AI-assisted reports.** Using an agent to gather information (logs, repro steps, version checks) is fine and useful. Using an agent to *diagnose* the bug and then submitting only that diagnosis is not. Agents routinely correlate adjacent log lines as cause-and-effect when they aren't related, and once a report is filtered through an AI summary I lose the signal I need to actually fix the bug. Paste the raw inputs.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/jz8T2uahpH
|
||||
about: Quick questions, sharing a video of a bug, or anything that's better as a chat. A lot of issues start better here.
|
||||
43
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
43
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Feature request
|
||||
description: Propose a new feature or a change to existing behavior.
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
|
||||
|
||||
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
|
||||
|
||||
- type: checkboxes
|
||||
id: prior-search
|
||||
attributes:
|
||||
label: Prior search
|
||||
options:
|
||||
- label: I searched existing issues and discussions, and this isn't already proposed.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What's the problem
|
||||
description: What are you actually trying to do, and why is the current behavior in the way?
|
||||
placeholder: |
|
||||
When I'm doing X, I want to Y, but Paseo currently Z.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: What would solve it
|
||||
description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives you considered
|
||||
description: Optional. What else did you try, and why doesn't it work?
|
||||
43
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
43
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
|
||||
|
||||
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
|
||||
-->
|
||||
|
||||
### Linked issue
|
||||
|
||||
Closes #
|
||||
|
||||
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
|
||||
|
||||
### Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature (with prior issue + design alignment)
|
||||
- [ ] Refactor / code improvement
|
||||
- [ ] Docs
|
||||
|
||||
### What does this PR do
|
||||
|
||||
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
|
||||
|
||||
### How did you verify it
|
||||
|
||||
<!--
|
||||
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
|
||||
|
||||
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
|
||||
- For behavior changes: the actual steps you ran, and what you observed.
|
||||
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
|
||||
|
||||
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
|
||||
-->
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] One focused change. Unrelated cleanups split out.
|
||||
- [ ] `npm run typecheck` passes
|
||||
- [ ] `npm run lint` passes
|
||||
- [ ] `npm run format` ran (Biome)
|
||||
- [ ] UI changes include screenshots or video for every affected platform
|
||||
- [ ] Tests added or updated where it made sense
|
||||
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
run: npx oxfmt --check .
|
||||
@@ -35,7 +35,13 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Lint lockfile
|
||||
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
|
||||
|
||||
- name: Verify dependency signatures
|
||||
run: npm audit signatures
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
@@ -51,7 +57,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -86,7 +92,7 @@ jobs:
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
@@ -118,7 +124,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -143,7 +149,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
@@ -165,7 +171,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
@@ -208,7 +214,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build relay
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
@@ -232,7 +238,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
2
.github/workflows/deploy-relay.yml
vendored
2
.github/workflows/deploy-relay.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/relay --include-workspace-root
|
||||
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/relay
|
||||
|
||||
2
.github/workflows/deploy-website.yml
vendored
2
.github/workflows/deploy-website.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/website --include-workspace-root
|
||||
run: npm ci --workspace=@getpaseo/website --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/website
|
||||
|
||||
2
.github/workflows/nix-build.yml
vendored
2
.github/workflows/nix-build.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref || github.ref }}
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
|
||||
55
.github/workflows/server-ci.yml
vendored
55
.github/workflows/server-ci.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Server CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/server-ci.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/server-ci.yml"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install server dependencies
|
||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/server
|
||||
|
||||
- name: Test
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -1,39 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.72 - 2026-05-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex approval prompts no longer hang.** Fixes a regression introduced in 0.1.70 where Codex agents would wait forever on command and file approvals — the prompt never reached the app and the agent stayed stuck in "running". ([#866](https://github.com/getpaseo/paseo/pull/866), [#869](https://github.com/getpaseo/paseo/pull/869))
|
||||
- **Windows: daemon no longer crashes when Codex emits non-JSON output.** Localized stdout lines from the Codex CLI are now ignored instead of taking down the daemon worker. ([#866](https://github.com/getpaseo/paseo/pull/866))
|
||||
- Drag-and-drop images onto the new workspace screen now works. ([#850](https://github.com/getpaseo/paseo/pull/850))
|
||||
- Archiving a worktree from the toolbar redirects you immediately instead of leaving you on the dead screen for a beat. ([#852](https://github.com/getpaseo/paseo/pull/852))
|
||||
- Pi-backed sessions now shut down cleanly when you close them, releasing extension resources on the Pi side. ([#863](https://github.com/getpaseo/paseo/pull/863))
|
||||
|
||||
## 0.1.71 - 2026-05-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Import existing Claude, Codex, and OpenCode sessions** straight into Paseo — pick up a conversation you started in the terminal and keep going from the app, with full timeline rehydration. ([#766](https://github.com/getpaseo/paseo/pull/766))
|
||||
- **Subagents now appear in a collapsible section above the composer** so you can see and jump into the agents your main agent has spawned, without leaving the workspace. ([#532](https://github.com/getpaseo/paseo/pull/532))
|
||||
- **Merge PR action in the checkout pane** — merge a pull request directly from Paseo, with a real-GitHub end-to-end test backing it. ([#814](https://github.com/getpaseo/paseo/pull/814))
|
||||
- **Project-level prompts for metadata generation** — customize how titles and summaries are generated per project. ([#836](https://github.com/getpaseo/paseo/pull/836))
|
||||
- **Grouped project settings** with inline links to the relevant docs. ([#837](https://github.com/getpaseo/paseo/pull/837))
|
||||
- **Create an empty workspace without writing a prompt** — useful for opening a project just to browse or import a session. ([#834](https://github.com/getpaseo/paseo/pull/834))
|
||||
- **Import-agent pill above the draft composer** so you can quickly resume a recent terminal session as a Paseo agent. ([#833](https://github.com/getpaseo/paseo/pull/833))
|
||||
- **Rich context menu on desktop** — copy link, copy image, and spellcheck suggestions in the Electron desktop app.
|
||||
- **Codex `/archive` integration** — archiving a Codex-backed agent now archives the underlying native Codex thread too. ([#827](https://github.com/getpaseo/paseo/pull/827) by [@32r4](https://github.com/32r4))
|
||||
- **Import existing Claude, Codex, and OpenCode sessions** into Paseo — pick up a conversation you started in the terminal and keep going from the app, with the full timeline. ([#766](https://github.com/getpaseo/paseo/pull/766), [#833](https://github.com/getpaseo/paseo/pull/833))
|
||||
- **Subagents now appear in a collapsible section above the composer** so you can jump into agents your main agent spawned. ([#532](https://github.com/getpaseo/paseo/pull/532))
|
||||
- Merge a pull request directly from the checkout pane. ([#814](https://github.com/getpaseo/paseo/pull/814))
|
||||
- Customize the per-project prompts Paseo uses to auto-generate agent titles, branch names, commit messages, and pull request descriptions. ([#836](https://github.com/getpaseo/paseo/pull/836))
|
||||
- Open an empty workspace without typing a prompt first. ([#834](https://github.com/getpaseo/paseo/pull/834))
|
||||
- Project settings are now grouped with inline links to the relevant docs. ([#837](https://github.com/getpaseo/paseo/pull/837))
|
||||
- Rich context menu on desktop — copy link, copy image, and spellcheck suggestions.
|
||||
- Archiving a Codex-backed agent now archives the underlying native Codex thread too. ([#827](https://github.com/getpaseo/paseo/pull/827) by [@32r4](https://github.com/32r4))
|
||||
|
||||
### Improved
|
||||
|
||||
- Workspace navigation auto-focuses the agent that needs your attention. ([#828](https://github.com/getpaseo/paseo/pull/828))
|
||||
- MCP `create_agent` inherits the caller's unattended mode when launching an agent on a different provider, instead of falling back to attended.
|
||||
- Subagent task notifications no longer flicker on archive, and the subagents track displays correctly across reloads.
|
||||
- Opening a workspace auto-focuses the agent that needs your attention. ([#828](https://github.com/getpaseo/paseo/pull/828))
|
||||
- An unattended agent that spawns a sub-agent on a different provider via MCP now starts the sub-agent in unattended mode too.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Reload now properly rehydrates the timeline from disk** under a fresh epoch, so a reloaded imported agent picks up new entries instead of getting stuck on the previous view.
|
||||
- **Chat fanout and system-injected agent prompts unified** — fixes inconsistent delivery and formatting of system messages routed to multiple agents. ([#830](https://github.com/getpaseo/paseo/pull/830))
|
||||
- iOS project picker now submits the typed path correctly. ([#829](https://github.com/getpaseo/paseo/issues/829), [#831](https://github.com/getpaseo/paseo/pull/831))
|
||||
- The composer's working directory now sources from the agent's actual state, not the draft hook, so it stays in sync after reloads.
|
||||
- Workspace open no longer auto-focuses subagents.
|
||||
- Ephemeral Claude session transcripts are deleted on close so internal generator runs don't pollute the import sheet.
|
||||
- Markdown link clicks no longer trigger the underlying anchor's default behavior.
|
||||
- `QueryProvider` is hoisted above `PortalProvider` so portaled sheets can read from the React Query client.
|
||||
- iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831))
|
||||
- System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830))
|
||||
- Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link.
|
||||
|
||||
### Security
|
||||
|
||||
- **Local state file permissions are now restricted** so daemon keypairs, persisted config, and token stores are readable only by the owner. ([#825](https://github.com/getpaseo/paseo/pull/825) by [@joaosa](https://github.com/joaosa))
|
||||
- Local state files (daemon keypair, stored credentials, persisted config) are now readable only by the owning user. ([#825](https://github.com/getpaseo/paseo/pull/825) by [@joaosa](https://github.com/joaosa))
|
||||
|
||||
## 0.1.70 - 2026-05-08
|
||||
|
||||
|
||||
153
CONTRIBUTING.md
153
CONTRIBUTING.md
@@ -1,31 +1,24 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Thanks for taking the time to contribute.
|
||||
|
||||
## How this project works
|
||||
|
||||
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
||||
Paseo is opinionated and maintained by one person. I read every issue and PR myself, so review cost is real.
|
||||
|
||||
This means:
|
||||
- **Feature requests are welcome.** Open an issue describing the problem. Get a thumbs up before writing code. Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first.
|
||||
- **Objective bug fixes don't need a prior issue.** Reference what's broken, keep the diff narrow, open the PR.
|
||||
- **The product stays lean.** I'll close, scope down, or rewrite PRs that add surface area I don't want to maintain, even if the code is fine.
|
||||
|
||||
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
|
||||
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
|
||||
- There is no obligation to merge a PR as-submitted, regardless of code quality.
|
||||
## Reporting bugs
|
||||
|
||||
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
||||
Fill in the bug report form. The fields are there because asking back for the surface, version, provider config, and logs is where most of my time on a bad report goes.
|
||||
|
||||
## How to contribute
|
||||
|
||||
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
|
||||
2. **Keep it small.** One bug, one flow, one focused change.
|
||||
3. **Open a PR** once there is alignment on scope.
|
||||
|
||||
If you want to propose a direction change, start a conversation.
|
||||
- **Full logs, not AI summaries.** Use an agent to grab the relevant log section if you want, but paste the raw log. Agents routinely correlate adjacent lines as cause-and-effect when they aren't, and once a report is filtered through that the signal I need is gone.
|
||||
- **Agents for information gathering, not diagnosis.** A bot that grabs your daemon log, version, and OS is helpful. A bot that submits its own theory of the bug is noise 99% of the time.
|
||||
- **Screenshots or video for UI bugs.** A 10-second recording beats a paragraph.
|
||||
- **One bug per issue.** Three findings, three issues.
|
||||
|
||||
## Before you start
|
||||
|
||||
Please read these first:
|
||||
|
||||
- [README.md](README.md)
|
||||
- [docs/architecture.md](docs/architecture.md)
|
||||
- [docs/development.md](docs/development.md)
|
||||
@@ -35,134 +28,36 @@ Please read these first:
|
||||
|
||||
## What is most helpful
|
||||
|
||||
The most useful contributions right now are:
|
||||
|
||||
- bug fixes
|
||||
- windows and linux specific fixes
|
||||
- bug fixes (especially Windows and Linux)
|
||||
- regression fixes
|
||||
- doc improvements
|
||||
- packaging / platform fixes
|
||||
- focused UX improvements that fit the existing product direction
|
||||
- packaging and platform fixes
|
||||
- focused UX improvements that fit the product direction
|
||||
- tests that lock down important behavior
|
||||
|
||||
## Scope expectations
|
||||
|
||||
Please keep PRs narrow.
|
||||
|
||||
Good:
|
||||
|
||||
- fix one bug
|
||||
- improve one flow
|
||||
- add one focused panel or command
|
||||
- tighten one piece of UI
|
||||
|
||||
Bad:
|
||||
|
||||
- combine multiple product ideas in one PR
|
||||
- bundle unrelated refactors with a feature
|
||||
- sneak in roadmap decisions
|
||||
|
||||
If a contribution contains multiple ideas, split it up.
|
||||
|
||||
## Product fit matters
|
||||
|
||||
Paseo is an opinionated product.
|
||||
|
||||
When reviewing contributions, the bar is not just:
|
||||
|
||||
- is this useful?
|
||||
- is this well implemented?
|
||||
|
||||
It is also:
|
||||
|
||||
- does this fit Paseo?
|
||||
- does this add product surface that will be hard to maintain?
|
||||
- does the value justify the maintenance surface it adds?
|
||||
- does this solve a common need or over-serve an edge case?
|
||||
- does this preserve the product's current direction?
|
||||
|
||||
## Development setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js matching `.tool-versions`
|
||||
- npm workspaces
|
||||
|
||||
### Start local development
|
||||
|
||||
```bash
|
||||
# runs both daemon and expo app
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
npm run dev # daemon + expo
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
npm run cli -- ls -a -g
|
||||
```
|
||||
|
||||
Read [docs/development.md](docs/development.md) for build-sync gotchas, local state, ports, and daemon details.
|
||||
[docs/development.md](docs/development.md) covers build sync, local state, and ports. Coding rules live in [docs/coding-standards.md](docs/coding-standards.md).
|
||||
|
||||
## Multi-platform testing
|
||||
## Pull requests
|
||||
|
||||
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
|
||||
- One focused change per PR. Split unrelated cleanups out.
|
||||
- Reference the issue you're fixing, unless it's a small objective bug.
|
||||
- UI changes need screenshots or video on every affected platform (mobile, web, desktop). Things that look fine on one surface regularly break on another.
|
||||
- `npm run typecheck` and `npm run lint` must pass.
|
||||
- Don't make breaking WebSocket or protocol changes. Old apps and old daemons coexist in the wild.
|
||||
- The PR template applies whether you used the web UI or `gh pr create`. Don't strip it out.
|
||||
|
||||
Common checks:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run test --workspaces --if-present
|
||||
```
|
||||
|
||||
Important rules:
|
||||
|
||||
- always run `npm run typecheck` after changes
|
||||
- tests should be deterministic
|
||||
- prefer real dependencies over mocks when possible
|
||||
- do not make breaking WebSocket / protocol changes
|
||||
- app and daemon versions in the wild lag each other, so compatibility matters
|
||||
|
||||
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Coding standards
|
||||
|
||||
Paseo has explicit standards. Follow them.
|
||||
|
||||
The full guide lives in [docs/coding-standards.md](docs/coding-standards.md).
|
||||
|
||||
## PR checklist
|
||||
|
||||
Before opening a PR, make sure:
|
||||
|
||||
- there was prior discussion and alignment on scope (issue or conversation)
|
||||
- the change is focused, one idea per PR
|
||||
- the PR description explains what changed and why
|
||||
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
|
||||
- UI changes have been tested on mobile and web at minimum
|
||||
- typecheck passes
|
||||
- tests pass, or you clearly explain what could not be run
|
||||
- relevant docs were updated if needed
|
||||
|
||||
## Communication
|
||||
|
||||
If you are unsure whether something fits, ask first.
|
||||
|
||||
That is especially true for:
|
||||
|
||||
- new core UX
|
||||
- naming / terminology changes
|
||||
- new extension points
|
||||
- new orchestration models
|
||||
- anything that would be hard to remove later
|
||||
|
||||
Early alignment saves everyone time.
|
||||
**On AI-assisted PRs.** AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and usually gets closed. If you don't fully understand why your fix works, say so directly. "Here's the repro before and after, not sure why this fixes it" is much better than a fabricated explanation.
|
||||
|
||||
## Forks are fine
|
||||
|
||||
If you want to explore a different product direction, a fork is completely fine.
|
||||
|
||||
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
|
||||
If you want to explore a different product direction, fork. Paseo is open source on purpose. Not every idea needs to land here to be valuable.
|
||||
|
||||
@@ -103,7 +103,7 @@ Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- ECDH key exchange + AES-256-GCM encryption
|
||||
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
|
||||
@@ -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-ocBSTgfzPlucgcCaPRi3iYhxpZD6hTzD3b2lphSTtb4=";
|
||||
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
|
||||
|
||||
# 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).
|
||||
|
||||
899
package-lock.json
generated
899
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.72",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -414,15 +414,19 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||
);
|
||||
const hasDefaultLocalModelsDir =
|
||||
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
|
||||
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
|
||||
throw new Error(
|
||||
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
|
||||
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
|
||||
// Fork PRs run without secrets and usually without local models. Don't crash
|
||||
// the whole Playwright run — disable dictation/voice and let tests that need
|
||||
// them gate on PASEO_DICTATION_ENABLED.
|
||||
if (!openAiUsable && !hasDefaultLocalModelsDir) {
|
||||
console.warn(
|
||||
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
|
||||
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
|
||||
);
|
||||
return { openAiUsable: false, localModelsDir: null };
|
||||
}
|
||||
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.72",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -115,7 +115,7 @@
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.59.1",
|
||||
"wrangler": "^4.75.0",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
@@ -72,7 +71,6 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { resolveActiveHost } from "@/utils/active-host";
|
||||
@@ -85,13 +83,12 @@ import {
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import {
|
||||
ensureOsNotificationPermission,
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
type WebNotificationClickDetail,
|
||||
} from "@/utils/os-notifications";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -118,25 +115,8 @@ function PushNotificationRouter() {
|
||||
const serverId = target.serverId;
|
||||
const agentId = target.agentId;
|
||||
if (serverId && agentId) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(agentId);
|
||||
const workspaceId =
|
||||
target.workspaceId ??
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: session?.workspaces.values(),
|
||||
workspaceDirectory: agent?.cwd,
|
||||
});
|
||||
|
||||
if (workspaceId) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
pin: true,
|
||||
});
|
||||
navigateToWorkspace(serverId, workspaceId, { currentPathname: pathname });
|
||||
return;
|
||||
}
|
||||
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
|
||||
return;
|
||||
}
|
||||
|
||||
router.navigate(buildNotificationRoute(data));
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { router, type Href } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
@@ -19,9 +18,7 @@ import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
|
||||
@@ -312,23 +309,13 @@ export function AgentList({
|
||||
|
||||
const serverId = agent.serverId;
|
||||
const agentId = agent.id;
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
|
||||
workspaceDirectory: agent.cwd,
|
||||
});
|
||||
|
||||
onAgentSelect?.();
|
||||
|
||||
if (!workspaceId) {
|
||||
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as Href);
|
||||
return;
|
||||
}
|
||||
|
||||
rememberArchivedAgentDetail(agent);
|
||||
navigateToPreparedWorkspaceTab({
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
agentId,
|
||||
pin: Boolean(agent.archivedAt),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -66,13 +66,8 @@ import * as Clipboard from "expo-clipboard";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path";
|
||||
import {
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "@/utils/tool-call-detail-state";
|
||||
import {
|
||||
parseAssistantFileLink,
|
||||
parseInlinePathToken,
|
||||
@@ -2858,7 +2853,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
||||
@@ -2875,63 +2869,36 @@ export const ToolCall = memo(function ToolCall({
|
||||
return undefined;
|
||||
}, [detail, args, result]);
|
||||
|
||||
const displayDetail = useMemo<ToolCallDetail>(
|
||||
const presentation = useMemo(
|
||||
() =>
|
||||
effectiveDetail ?? {
|
||||
type: "unknown",
|
||||
input: null,
|
||||
output: null,
|
||||
},
|
||||
[effectiveDetail],
|
||||
);
|
||||
|
||||
const displayModel = useMemo(
|
||||
() =>
|
||||
buildToolCallDisplayModel({
|
||||
name: toolName,
|
||||
status: status === "executing" ? "running" : status,
|
||||
buildToolCallPresentation({
|
||||
toolName,
|
||||
status,
|
||||
error: error ?? null,
|
||||
detail: displayDetail,
|
||||
detail: effectiveDetail,
|
||||
metadata,
|
||||
cwd,
|
||||
resolveIcon: resolveToolCallIcon,
|
||||
}),
|
||||
[toolName, status, error, displayDetail, metadata, cwd],
|
||||
);
|
||||
const displayName = displayModel.displayName;
|
||||
const summary = displayModel.summary;
|
||||
const errorText = displayModel.errorText;
|
||||
const IconComponent = resolveToolCallIcon(toolName, effectiveDetail);
|
||||
const isLoadingDetails = isPendingToolCallDetail({
|
||||
detail: effectiveDetail,
|
||||
status,
|
||||
error,
|
||||
});
|
||||
const secondaryLabel = summary;
|
||||
|
||||
// Check if there's any content to display
|
||||
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
|
||||
const canOpenDetails = hasDetails || isLoadingDetails;
|
||||
|
||||
const extractedFilePath = useMemo(
|
||||
() => extractToolCallFilePath(effectiveDetail),
|
||||
[effectiveDetail],
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd],
|
||||
);
|
||||
const handleOpenFile = useMemo(() => {
|
||||
if (!extractedFilePath || !onOpenFilePath) {
|
||||
const openFilePath = presentation.openFilePath;
|
||||
if (!openFilePath || !onOpenFilePath) {
|
||||
return undefined;
|
||||
}
|
||||
return () => onOpenFilePath(extractedFilePath);
|
||||
}, [extractedFilePath, onOpenFilePath]);
|
||||
return () => onOpenFilePath(openFilePath);
|
||||
}, [presentation.openFilePath, onOpenFilePath]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
openToolCall({
|
||||
toolName,
|
||||
displayName,
|
||||
summary: secondaryLabel,
|
||||
displayName: presentation.displayName,
|
||||
summary: presentation.summary,
|
||||
detail: effectiveDetail,
|
||||
errorText,
|
||||
showLoadingSkeleton: isLoadingDetails,
|
||||
errorText: presentation.errorText,
|
||||
icon: presentation.icon,
|
||||
showLoadingSkeleton: presentation.isLoadingDetails,
|
||||
});
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
@@ -2939,12 +2906,12 @@ export const ToolCall = memo(function ToolCall({
|
||||
}, [
|
||||
isMobile,
|
||||
openToolCall,
|
||||
toolName,
|
||||
displayName,
|
||||
secondaryLabel,
|
||||
presentation.displayName,
|
||||
presentation.summary,
|
||||
presentation.errorText,
|
||||
presentation.icon,
|
||||
presentation.isLoadingDetails,
|
||||
effectiveDetail,
|
||||
errorText,
|
||||
isLoadingDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2980,14 +2947,14 @@ export const ToolCall = memo(function ToolCall({
|
||||
return (
|
||||
<ToolCallDetailsContent
|
||||
detail={effectiveDetail}
|
||||
errorText={errorText}
|
||||
errorText={presentation.errorText}
|
||||
maxHeight={400}
|
||||
showLoadingSkeleton={isLoadingDetails}
|
||||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||||
/>
|
||||
);
|
||||
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
|
||||
}, [isMobile, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
|
||||
|
||||
if (effectiveDetail?.type === "plan") {
|
||||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||||
return (
|
||||
<PlanCard
|
||||
title="Plan"
|
||||
@@ -3001,13 +2968,13 @@ export const ToolCall = memo(function ToolCall({
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-badge"
|
||||
label={displayName}
|
||||
secondaryLabel={secondaryLabel}
|
||||
icon={IconComponent}
|
||||
label={presentation.displayName}
|
||||
secondaryLabel={presentation.summary}
|
||||
icon={presentation.icon}
|
||||
isExpanded={!isMobile && isExpanded}
|
||||
onToggle={canOpenDetails ? handleToggle : undefined}
|
||||
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
|
||||
onOpenFile={handleOpenFile}
|
||||
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||
renderDetails={presentation.canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||
isLoading={status === "running" || status === "executing"}
|
||||
isError={status === "failed"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
|
||||
@@ -101,16 +101,15 @@ import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspac
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import {
|
||||
archiveWorkspaceOptimistically,
|
||||
archiveWorkspacesOptimistically,
|
||||
} from "@/workspace/workspace-archive";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
@@ -126,35 +125,6 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
|
||||
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
|
||||
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreOptimisticallyHiddenWorkspace(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
snapshot: WorkspaceDescriptor | null;
|
||||
}): void {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (input.snapshot) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
|
||||
}
|
||||
}
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
@@ -1574,21 +1544,14 @@ function WorkspaceRowWithMenu({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace,
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
@@ -1719,21 +1682,14 @@ function NonGitProjectRowWithMenuContent({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace,
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
@@ -2145,33 +2101,11 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
setIsRemovingProject(true);
|
||||
const snapshots = new Map(
|
||||
project.workspaces.map((workspace) => [
|
||||
workspace.workspaceId,
|
||||
hideWorkspaceOptimistically(workspace),
|
||||
]),
|
||||
);
|
||||
|
||||
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
||||
void Promise.allSettled(
|
||||
project.workspaces.map(async (ws) => {
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId,
|
||||
workspaceId: ws.workspaceId,
|
||||
snapshot: snapshots.get(ws.workspaceId) ?? null,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
const failed = results.filter(isRejected);
|
||||
if (failed.length > 0) {
|
||||
void archiveWorkspacesOptimistically({
|
||||
client,
|
||||
workspaces: project.workspaces,
|
||||
}).then((failures) => {
|
||||
if (failures.length > 0) {
|
||||
toast.error("Failed to remove some workspaces");
|
||||
}
|
||||
setIsRemovingProject(false);
|
||||
|
||||
@@ -14,17 +14,17 @@ import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import type { ToolCallIconComponent } from "@/utils/tool-call-icon";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
|
||||
// ----- Types -----
|
||||
|
||||
export interface ToolCallSheetData {
|
||||
toolName: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
detail?: ToolCallDetail;
|
||||
errorText?: string;
|
||||
icon: ToolCallIconComponent;
|
||||
showLoadingSkeleton?: boolean;
|
||||
}
|
||||
|
||||
@@ -139,9 +139,7 @@ interface ToolCallSheetContentProps {
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
|
||||
|
||||
const IconComponent = resolveToolCallIcon(toolName, detail);
|
||||
const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
|
||||
import type { CreateAgentRequestOptions, DaemonClient } from "@server/client/daemon-client";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { ImageAttachment, MessagePayload } from "./message-input";
|
||||
|
||||
@@ -190,6 +191,14 @@ export function WorkspaceSetupDialog() {
|
||||
}
|
||||
|
||||
clearWorkspaceSetup();
|
||||
if (target.kind === "agent") {
|
||||
navigateToAgent({
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
agentId: target.agentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
workspaceId,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
__resetCheckoutGitActionsStoreForTests,
|
||||
invalidateCheckoutGitQueriesForClient,
|
||||
isLocalWorktreeArchivePending,
|
||||
useCheckoutGitActionsStore,
|
||||
} from "@/git/actions-store";
|
||||
@@ -172,32 +170,6 @@ describe("checkout-git-actions-store", () => {
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("invalidates checkout PR status and every PR pane timeline for a checkout", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
queryClient.setQueryData(["checkoutPrStatus", serverId, cwd], { status: { number: 12 } });
|
||||
queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 12], { items: [] });
|
||||
queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 13], { items: [] });
|
||||
queryClient.setQueryData(["prPaneTimeline", serverId, "/tmp/other", 12], { items: [] });
|
||||
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
|
||||
|
||||
expect(queryClient.getQueryState(["checkoutPrStatus", serverId, cwd])?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 12])?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 13])?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(["prPaneTimeline", serverId, "/tmp/other", 12])?.isInvalidated,
|
||||
).toBe(false);
|
||||
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import type { CheckoutPrMergeMethod } from "@server/shared/messages";
|
||||
import { create } from "zustand";
|
||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
|
||||
const SUCCESS_DISPLAY_MS = 1000;
|
||||
|
||||
@@ -74,44 +75,6 @@ function setStatus(
|
||||
});
|
||||
}
|
||||
|
||||
export async function invalidateCheckoutGitQueriesForClient(
|
||||
queryClient: QueryClient,
|
||||
{ serverId, cwd }: { serverId: string; cwd: string },
|
||||
) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["checkoutStatus", serverId, cwd],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
Array.isArray(key) && key[0] === "checkoutDiff" && key[1] === serverId && key[2] === cwd
|
||||
);
|
||||
},
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
Array.isArray(key) &&
|
||||
key[0] === "checkoutPrStatus" &&
|
||||
key[1] === serverId &&
|
||||
key[2] === cwd
|
||||
);
|
||||
},
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
Array.isArray(key) && key[0] === "prPaneTimeline" && key[1] === serverId && key[2] === cwd
|
||||
);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
|
||||
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ describe("parseGitHubRepoFromRemote", () => {
|
||||
it.each([
|
||||
["https://github.com/acme/repo.git", "acme/repo"],
|
||||
["https://github.com/acme/repo", "acme/repo"],
|
||||
["http://github.com/acme/repo.git", "acme/repo"],
|
||||
["git@github.com:acme/repo.git", "acme/repo"],
|
||||
["ssh://git@github.com/acme/repo.git", "acme/repo"],
|
||||
["ssh://git@ssh.github.com/acme/repo.git", "acme/repo"],
|
||||
["https://github.com/acme/repo/", "acme/repo"],
|
||||
])("extracts the repo from %s", (remoteUrl, expected) => {
|
||||
expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected);
|
||||
|
||||
@@ -1,42 +1,11 @@
|
||||
// TODO: this duplicates parseGitHubRepoFromRemote in packages/server/src/services/github-service.ts.
|
||||
// Consolidate into a shared package once we have a third caller.
|
||||
import { parseGitHubRemoteUrl } from "@server/shared/git-remote";
|
||||
|
||||
// Note: SSH host aliases (e.g. `git@github-work:acme/repo.git` resolved via ~/.ssh/config)
|
||||
// are not detected here, so the GitHub action will silently not appear for those remotes.
|
||||
export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null {
|
||||
const trimmed = remoteUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cleaned = trimmed;
|
||||
if (cleaned.startsWith("git@github.com:")) {
|
||||
cleaned = cleaned.slice("git@github.com:".length);
|
||||
} else {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(cleaned);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hostname !== "github.com") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
cleaned = decodeURIComponent(parsed.pathname.replace(/^\/+/, ""));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
cleaned = cleaned.replace(/\/+$/, "");
|
||||
if (cleaned.endsWith(".git")) {
|
||||
cleaned = cleaned.slice(0, -".git".length);
|
||||
}
|
||||
if (!cleaned.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
return cleaned;
|
||||
return parseGitHubRemoteUrl(trimmed)?.repo ?? null;
|
||||
}
|
||||
|
||||
export function buildGitHubBranchTreeUrl(input: {
|
||||
|
||||
62
packages/app/src/git/query-keys.test.ts
Normal file
62
packages/app/src/git/query-keys.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
checkoutDiffQueryKey,
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
invalidateCheckoutGitQueriesForClient,
|
||||
prPaneTimelineQueryKey,
|
||||
} from "@/git/query-keys";
|
||||
|
||||
describe("checkout query keys", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo";
|
||||
|
||||
it("invalidates every query for a checkout without touching other checkouts", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true });
|
||||
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
|
||||
files: [],
|
||||
});
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
|
||||
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
|
||||
items: [],
|
||||
});
|
||||
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }), {
|
||||
items: [],
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
|
||||
{ items: [] },
|
||||
);
|
||||
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
|
||||
|
||||
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true))
|
||||
?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
|
||||
?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }))
|
||||
?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
queryClient.getQueryState(
|
||||
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
|
||||
)?.isInvalidated,
|
||||
).toBe(false);
|
||||
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
82
packages/app/src/git/query-keys.ts
Normal file
82
packages/app/src/git/query-keys.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Query, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface CheckoutQueryIdentity {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
type CheckoutQueryKey = readonly unknown[];
|
||||
|
||||
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
export function checkoutDiffQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||
}
|
||||
|
||||
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
export function prPaneTimelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number | null;
|
||||
}) {
|
||||
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
|
||||
}
|
||||
|
||||
export async function invalidateCheckoutGitQueriesForClient(
|
||||
queryClient: QueryClient,
|
||||
identity: CheckoutQueryIdentity,
|
||||
) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: checkoutStatusQueryKey(identity.serverId, identity.cwd),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("checkoutDiff", identity),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("prPaneTimeline", identity),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function checkoutQueryPredicate(
|
||||
queryKind: CheckoutQueryKey[0],
|
||||
identity: CheckoutQueryIdentity,
|
||||
): (query: Query) => boolean {
|
||||
return (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
isCheckoutQueryKey(key) &&
|
||||
key[0] === queryKind &&
|
||||
key[1] === identity.serverId &&
|
||||
key[2] === identity.cwd
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function isCheckoutQueryKey(key: readonly unknown[]): key is CheckoutQueryKey {
|
||||
return (
|
||||
key.length >= 3 &&
|
||||
typeof key[0] === "string" &&
|
||||
typeof key[1] === "string" &&
|
||||
typeof key[2] === "string"
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
|
||||
import { router, type Href } from "expo-router";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { buildGitActions, narrowPullRequestState, type GitActions } from "@/git/policy";
|
||||
import type { CheckoutPrMergeMethod } from "@server/shared/messages";
|
||||
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
@@ -393,15 +395,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
toast.error("Worktree path unavailable");
|
||||
return;
|
||||
}
|
||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
navigateToWorkspace(serverId, targetWorkingDir);
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to archive worktree");
|
||||
});
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const archivedWorkspaceId =
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: worktreePath,
|
||||
}) ?? worktreePath;
|
||||
router.replace(
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId,
|
||||
archivedWorkspaceId,
|
||||
workspaces: workspaces?.values() ?? [],
|
||||
}) as Href,
|
||||
);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => {
|
||||
toastActionError(err, "Failed to archive worktree");
|
||||
});
|
||||
}, [cwd, runArchiveWorktree, serverId, status, toast, toastActionError]);
|
||||
|
||||
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
|
||||
|
||||
@@ -3,16 +3,7 @@ import { useEffect, useId, useMemo } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||
|
||||
function checkoutDiffQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||
}
|
||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
serverId: string;
|
||||
|
||||
@@ -2,10 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
|
||||
|
||||
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
}
|
||||
import { checkoutPrStatusQueryKey } from "@/git/query-keys";
|
||||
|
||||
interface UseCheckoutPrStatusQueryOptions {
|
||||
serverId: string;
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-status-query";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import { useCheckoutStatusQuery } from "./use-status-query";
|
||||
|
||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
|
||||
|
||||
@@ -2,13 +2,10 @@ import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-quer
|
||||
import { useEffect } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
|
||||
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
||||
|
||||
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutStatusQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery, type QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { ComboboxOption } from "@/components/ui/combobox";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/actions-store";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
interface UseBranchSwitcherInput {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TextInput } from "react-native";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
@@ -11,15 +10,14 @@ import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { buildHostAgentDetailRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { buildSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
|
||||
@@ -204,18 +202,9 @@ export function useCommandCenter() {
|
||||
// Don't restore focus back to the prior element after we navigate.
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: useSessionStore.getState().sessions[agent.serverId]?.workspaces?.values(),
|
||||
workspaceDirectory: agent.cwd,
|
||||
});
|
||||
if (!workspaceId) {
|
||||
router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as Href);
|
||||
return;
|
||||
}
|
||||
navigateToPreparedWorkspaceTab({
|
||||
navigateToAgent({
|
||||
serverId: agent.serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId: agent.id },
|
||||
agentId: agent.id,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -12,11 +12,8 @@ import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} from "@server/shared/messages";
|
||||
import {
|
||||
prPaneTimelineQueryKey,
|
||||
usePrPaneData,
|
||||
type UsePrPaneDataResult,
|
||||
} from "./use-pr-pane-data";
|
||||
import { checkoutPrStatusQueryKey, prPaneTimelineQueryKey } from "@/git/query-keys";
|
||||
import { usePrPaneData, type UsePrPaneDataResult } from "./use-pr-pane-data";
|
||||
import { useWorkspacePrHint } from "@/git/use-pr-status-query";
|
||||
|
||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
@@ -335,7 +332,7 @@ describe("usePrPaneData", () => {
|
||||
).toBeUndefined();
|
||||
|
||||
mockClient.checkoutPrStatus.mockResolvedValue(statusPayload());
|
||||
hook.queryClient.invalidateQueries({ queryKey: ["checkoutPrStatus", serverId, cwd] });
|
||||
hook.queryClient.invalidateQueries({ queryKey: checkoutPrStatusQueryKey(serverId, cwd) });
|
||||
|
||||
await waitForExpectation(() => {
|
||||
expect(mockClient.pullRequestTimeline).toHaveBeenCalledWith({
|
||||
@@ -434,7 +431,7 @@ describe("usePrPaneData", () => {
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.data?.checks[0]?.status).toBe("success");
|
||||
});
|
||||
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(
|
||||
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(
|
||||
statusPayload({
|
||||
requestId: "server-push",
|
||||
status: status({
|
||||
@@ -474,8 +471,10 @@ describe("usePrPaneData", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(initial);
|
||||
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, "/other-repo"])).toBeUndefined();
|
||||
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(initial);
|
||||
expect(
|
||||
queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, "/other-repo")),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes repoOwner and repoName to the timeline request when present", async () => {
|
||||
@@ -719,7 +718,7 @@ describe("usePrPaneData", () => {
|
||||
|
||||
const refreshDeferred = createDeferred<CheckoutPrStatusPayload>();
|
||||
mockClient.checkoutPrStatus.mockReturnValue(refreshDeferred.promise);
|
||||
hook.queryClient.invalidateQueries({ queryKey: ["checkoutPrStatus", serverId, cwd] });
|
||||
hook.queryClient.invalidateQueries({ queryKey: checkoutPrStatusQueryKey(serverId, cwd) });
|
||||
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.isLoading).toBe(false);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "@server/shared/messages";
|
||||
import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { prPaneTimelineQueryKey } from "@/git/query-keys";
|
||||
|
||||
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
@@ -152,18 +153,6 @@ export function usePrPaneData({
|
||||
};
|
||||
}
|
||||
|
||||
export function prPaneTimelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number | null;
|
||||
}) {
|
||||
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
|
||||
}
|
||||
|
||||
function firstNonSuppressedError({
|
||||
statusPayloadError,
|
||||
statusError,
|
||||
|
||||
@@ -58,6 +58,7 @@ import type { StreamItem } from "@/types/stream";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
|
||||
interface ChatAgentStateShape {
|
||||
@@ -1258,14 +1259,14 @@ function ActiveAgentComposer({
|
||||
const paneContext = usePaneContext();
|
||||
const { workspaceId } = paneContext;
|
||||
const subagentRows = useSubagentsForParent({
|
||||
serverId: paneContext.serverId,
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
});
|
||||
const handleOpenSubagent = useCallback(
|
||||
(subagentId: string) => {
|
||||
paneContext.openTab({ kind: "agent", agentId: subagentId });
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
},
|
||||
[paneContext],
|
||||
[serverId],
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const agentInputDraft = useAgentInputDraft({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -639,6 +640,9 @@ export function NewWorkspaceScreen({
|
||||
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
|
||||
addImagesRef.current = addImages;
|
||||
}, []);
|
||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||
addImagesRef.current?.(files);
|
||||
}, []);
|
||||
|
||||
const renderPickerOption = useCallback(
|
||||
({
|
||||
@@ -713,82 +717,84 @@ export function NewWorkspaceScreen({
|
||||
: "No matching refs.";
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
New workspace
|
||||
</Text>
|
||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||
{workspaceTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
leftStyle={styles.headerLeft}
|
||||
borderless
|
||||
/>
|
||||
<View style={contentStyle}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.centered}>
|
||||
<Composer
|
||||
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel="Create"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={pendingAction !== null}
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
statusControls={statusControlsWithDisabled}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
/>
|
||||
<Animated.View testID="new-workspace-ref-picker-row" style={optionsRowStyle}>
|
||||
<View>
|
||||
<RefPickerTrigger
|
||||
pickerAnchorRef={pickerAnchorRef}
|
||||
onPress={openPicker}
|
||||
disabled={isPending}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
selectedItem={selectedItem}
|
||||
triggerLabel={triggerLabel}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedOptionId}
|
||||
onSelect={handleSelectOption}
|
||||
searchable
|
||||
searchPlaceholder="Search branches and PRs"
|
||||
title="Start from"
|
||||
open={pickerOpen}
|
||||
onOpenChange={handlePickerOpenChange}
|
||||
onSearchQueryChange={setPickerSearchQuery}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={pickerAnchorRef}
|
||||
emptyText={pickerEmptyText}
|
||||
renderOption={renderPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
<FileDropZone onFilesDropped={handleFilesDropped}>
|
||||
<View style={styles.container}>
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
New workspace
|
||||
</Text>
|
||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||
{workspaceTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
leftStyle={styles.headerLeft}
|
||||
borderless
|
||||
/>
|
||||
<View style={contentStyle}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.centered}>
|
||||
<Composer
|
||||
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel="Create"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={pendingAction !== null}
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
statusControls={statusControlsWithDisabled}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
/>
|
||||
<Animated.View testID="new-workspace-ref-picker-row" style={optionsRowStyle}>
|
||||
<View>
|
||||
<RefPickerTrigger
|
||||
pickerAnchorRef={pickerAnchorRef}
|
||||
onPress={openPicker}
|
||||
disabled={isPending}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
selectedItem={selectedItem}
|
||||
triggerLabel={triggerLabel}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedOptionId}
|
||||
onSelect={handleSelectOption}
|
||||
searchable
|
||||
searchPlaceholder="Search branches and PRs"
|
||||
title="Start from"
|
||||
open={pickerOpen}
|
||||
onOpenChange={handlePickerOpenChange}
|
||||
onSearchQueryChange={setPickerSearchQuery}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={pickerAnchorRef}
|
||||
emptyText={pickerEmptyText}
|
||||
renderOption={renderPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,8 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||
import { checkoutStatusQueryKey, type CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
@@ -132,9 +133,10 @@ import {
|
||||
} from "@/screens/workspace/workspace-route-state";
|
||||
import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
workspaceAgentVisibilityEqual,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
buildWorkspacePaneContentModel,
|
||||
@@ -1918,16 +1920,17 @@ function WorkspaceScreenContent({
|
||||
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
|
||||
});
|
||||
|
||||
reconcileWorkspaceTabs(persistenceKey, {
|
||||
agentsHydrated: hasHydratedAgents,
|
||||
terminalsHydrated: terminalsQuery.isSuccess,
|
||||
activeAgentIds: Array.from(workspaceAgentVisibility.activeAgentIds),
|
||||
autoOpenAgentIds: Array.from(workspaceAgentVisibility.autoOpenAgentIds),
|
||||
knownAgentIds: Array.from(workspaceAgentVisibility.knownAgentIds),
|
||||
knownTerminalIds,
|
||||
standaloneTerminalIds,
|
||||
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
|
||||
});
|
||||
reconcileWorkspaceTabs(
|
||||
persistenceKey,
|
||||
buildWorkspaceTabSnapshot({
|
||||
agentVisibility: workspaceAgentVisibility,
|
||||
agentsHydrated: hasHydratedAgents,
|
||||
terminalsHydrated: terminalsQuery.isSuccess,
|
||||
knownTerminalIds,
|
||||
standaloneTerminalIds,
|
||||
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
hasHydratedAgents,
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
type WorkspaceAgentVisibility,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { selectSubagentsForParent } from "@/subagents";
|
||||
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import { useSessionStore, type Agent } from "./session-store";
|
||||
@@ -151,15 +152,17 @@ function deriveVisibilityFromSession(): WorkspaceAgentVisibility {
|
||||
}
|
||||
|
||||
function reconcileWorkspaceTabs(workspaceKey: string, visibility: WorkspaceAgentVisibility): void {
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: visibility.activeAgentIds,
|
||||
autoOpenAgentIds: visibility.autoOpenAgentIds,
|
||||
knownAgentIds: visibility.knownAgentIds,
|
||||
standaloneTerminalIds: [],
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(
|
||||
workspaceKey,
|
||||
buildWorkspaceTabSnapshot({
|
||||
agentVisibility: visibility,
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
knownTerminalIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
hasActivePendingDraftCreate: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkspaceTabIds(workspaceKey: string): string[] {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Pure-data entry point for callers that don't want React Native deps in
|
||||
// their dependency graph (e.g. workspace-agent-visibility.ts is plain JS
|
||||
// their dependency graph (e.g. workspace-tabs/agent-visibility.ts is plain
|
||||
// data derivation and its tests run without an RN environment).
|
||||
//
|
||||
// The full module entry `@/subagents` re-exports these too, alongside the
|
||||
|
||||
94
packages/app/src/tool-calls/presentation.test.ts
Normal file
94
packages/app/src/tool-calls/presentation.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildToolCallPresentation, type ToolCallPresentationIcon } from "./presentation";
|
||||
|
||||
const fakeIcons = {
|
||||
brain: (() => null) as ToolCallPresentationIcon,
|
||||
eye: (() => null) as ToolCallPresentationIcon,
|
||||
wrench: (() => null) as ToolCallPresentationIcon,
|
||||
};
|
||||
|
||||
function fakeResolveIcon(
|
||||
toolName: string,
|
||||
detail: ToolCallDetail | undefined,
|
||||
): ToolCallPresentationIcon {
|
||||
if (detail?.type === "plan") {
|
||||
return fakeIcons.brain;
|
||||
}
|
||||
if (detail?.type === "read") {
|
||||
return fakeIcons.eye;
|
||||
}
|
||||
if (toolName === "exec_command") {
|
||||
return fakeIcons.wrench;
|
||||
}
|
||||
return fakeIcons.wrench;
|
||||
}
|
||||
|
||||
describe("tool-call presentation", () => {
|
||||
it("builds badge, detail, icon, and file-open policy in one model", () => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "read_file",
|
||||
status: "completed",
|
||||
error: null,
|
||||
cwd: "/tmp/repo",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "/tmp/repo/src/index.ts",
|
||||
content: "console.log('hi');",
|
||||
},
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
|
||||
expect(presentation).toMatchObject({
|
||||
displayName: "Read",
|
||||
summary: "src/index.ts",
|
||||
icon: fakeIcons.eye,
|
||||
isLoadingDetails: false,
|
||||
hasDetails: true,
|
||||
canOpenDetails: true,
|
||||
openFilePath: "/tmp/repo/src/index.ts",
|
||||
isPlan: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks running calls without meaningful detail as loading details", () => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "exec_command",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {},
|
||||
output: null,
|
||||
},
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
|
||||
expect(presentation).toMatchObject({
|
||||
displayName: "Exec Command",
|
||||
icon: fakeIcons.wrench,
|
||||
isLoadingDetails: true,
|
||||
hasDetails: false,
|
||||
canOpenDetails: true,
|
||||
openFilePath: null,
|
||||
isPlan: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps plan calls out of the expandable badge path", () => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "ExitPlanMode",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plan",
|
||||
text: "1. Do the thing",
|
||||
},
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
|
||||
expect(presentation.isPlan).toBe(true);
|
||||
expect(presentation.icon).toBe(fakeIcons.brain);
|
||||
});
|
||||
});
|
||||
79
packages/app/src/tool-calls/presentation.ts
Normal file
79
packages/app/src/tool-calls/presentation.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import type { ToolCallDisplayInput } from "@/utils/tool-call-display";
|
||||
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
||||
import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path";
|
||||
import {
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "@/utils/tool-call-detail-state";
|
||||
|
||||
type ToolCallStatus = "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
export type ToolCallPresentationIcon = ComponentType<{ size?: number; color?: string }>;
|
||||
|
||||
interface BuildToolCallPresentationInput {
|
||||
toolName: string;
|
||||
status: ToolCallStatus;
|
||||
error: unknown;
|
||||
detail?: ToolCallDetail;
|
||||
cwd?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
resolveIcon: ToolCallIconResolver;
|
||||
}
|
||||
|
||||
export interface ToolCallPresentation {
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
errorText?: string;
|
||||
icon: ToolCallPresentationIcon;
|
||||
isLoadingDetails: boolean;
|
||||
hasDetails: boolean;
|
||||
canOpenDetails: boolean;
|
||||
openFilePath: string | null;
|
||||
isPlan: boolean;
|
||||
}
|
||||
|
||||
export type ToolCallIconResolver = (
|
||||
toolName: string,
|
||||
detail: ToolCallDetail | undefined,
|
||||
) => ToolCallPresentationIcon;
|
||||
|
||||
function displayStatus(status: ToolCallStatus): ToolCallDisplayInput["status"] {
|
||||
return status === "executing" ? "running" : status;
|
||||
}
|
||||
|
||||
function displayDetail(detail: ToolCallDetail | undefined): ToolCallDetail {
|
||||
return detail ?? { type: "unknown", input: null, output: null };
|
||||
}
|
||||
|
||||
export function buildToolCallPresentation(
|
||||
input: BuildToolCallPresentationInput,
|
||||
): ToolCallPresentation {
|
||||
const detailForDisplay = displayDetail(input.detail);
|
||||
const displayModel = buildToolCallDisplayModel({
|
||||
name: input.toolName,
|
||||
status: displayStatus(input.status),
|
||||
error: input.error ?? null,
|
||||
detail: detailForDisplay,
|
||||
metadata: input.metadata,
|
||||
cwd: input.cwd,
|
||||
});
|
||||
const isLoadingDetails = isPendingToolCallDetail({
|
||||
detail: input.detail,
|
||||
status: input.status,
|
||||
error: input.error,
|
||||
});
|
||||
const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(input.detail);
|
||||
|
||||
return {
|
||||
displayName: displayModel.displayName,
|
||||
summary: displayModel.summary,
|
||||
errorText: displayModel.errorText,
|
||||
icon: input.resolveIcon(input.toolName, input.detail),
|
||||
isLoadingDetails,
|
||||
hasDetails,
|
||||
canOpenDetails: hasDetails || isLoadingDetails,
|
||||
openFilePath: extractToolCallFilePath(input.detail),
|
||||
isPlan: input.detail?.type === "plan",
|
||||
};
|
||||
}
|
||||
133
packages/app/src/utils/navigate-to-agent.test.ts
Normal file
133
packages/app/src/utils/navigate-to-agent.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { routerMock } = vi.hoisted(() => ({
|
||||
routerMock: {
|
||||
dismissTo: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: routerMock,
|
||||
useLocalSearchParams: () => ({}),
|
||||
usePathname: () => "/",
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
|
||||
function createWorkspace(): WorkspaceDescriptor {
|
||||
return {
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "worktree",
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createAgent(input: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
id: AGENT_ID,
|
||||
provider: "codex",
|
||||
status: "closed",
|
||||
createdAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: "Archived agent",
|
||||
cwd: "/repo/worktree",
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
archivedAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
describe("navigateToAgent", () => {
|
||||
beforeEach(() => {
|
||||
routerMock.dismissTo.mockReset();
|
||||
routerMock.navigate.mockReset();
|
||||
routerMock.replace.mockReset();
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, createWorkspace()]]));
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("opens archived agent details through the resolved workspace", () => {
|
||||
useSessionStore.getState().setAgentDetails(SERVER_ID, new Map([[AGENT_ID, createAgent()]]));
|
||||
|
||||
const route = navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true });
|
||||
|
||||
expect(route).toBe("/h/server-1/workspace/workspace-1");
|
||||
expect(routerMock.navigate).not.toHaveBeenCalled();
|
||||
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-1");
|
||||
const key = `${SERVER_ID}:${WORKSPACE_ID}`;
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toEqual([
|
||||
expect.objectContaining({ target: { kind: "agent", agentId: AGENT_ID } }),
|
||||
]);
|
||||
expect(useWorkspaceLayoutStore.getState().pinnedAgentIdsByWorkspace[key]).toEqual(
|
||||
new Set([AGENT_ID]),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the host agent route when the workspace is unknown", () => {
|
||||
const route = navigateToAgent({ serverId: SERVER_ID, agentId: "missing-agent" });
|
||||
|
||||
expect(route).toBe("/h/server-1/agent/missing-agent");
|
||||
expect(routerMock.navigate).toHaveBeenCalledWith("/h/server-1/agent/missing-agent");
|
||||
expect(routerMock.dismissTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
35
packages/app/src/utils/navigate-to-agent.ts
Normal file
35
packages/app/src/utils/navigate-to-agent.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
interface NavigateToAgentInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
currentPathname?: string | null;
|
||||
pin?: boolean;
|
||||
}
|
||||
|
||||
export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: session?.workspaces.values(),
|
||||
workspaceDirectory: agent?.cwd,
|
||||
});
|
||||
|
||||
if (!workspaceId) {
|
||||
const route = buildHostAgentDetailRoute(input.serverId, input.agentId);
|
||||
router.navigate(route as Href);
|
||||
return route;
|
||||
}
|
||||
|
||||
return navigateToPreparedWorkspaceTab({
|
||||
serverId: input.serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId: input.agentId },
|
||||
currentPathname: input.currentPathname,
|
||||
pin: input.pin,
|
||||
});
|
||||
}
|
||||
@@ -1,41 +1,16 @@
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
|
||||
export type SidebarAttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||
import {
|
||||
deriveAgentStateBucket,
|
||||
type AgentAttentionReason,
|
||||
type AgentStateBucketInput,
|
||||
} from "@server/shared/agent-state-bucket";
|
||||
|
||||
export type SidebarStateBucket = "needs_input" | "failed" | "running" | "attention" | "done";
|
||||
export type SidebarAttentionReason = AgentAttentionReason;
|
||||
|
||||
export function deriveSidebarStateBucket(input: {
|
||||
status: AgentLifecycleStatus;
|
||||
pendingPermissionCount?: number;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: SidebarAttentionReason;
|
||||
}): SidebarStateBucket {
|
||||
if ((input.pendingPermissionCount ?? 0) > 0) {
|
||||
return "needs_input";
|
||||
}
|
||||
// Legacy fallback for snapshots persisted before permission state was decoupled
|
||||
// from unread attention.
|
||||
if (input.attentionReason === "permission") {
|
||||
return "needs_input";
|
||||
}
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
return "failed";
|
||||
}
|
||||
if (input.status === "running") {
|
||||
return "running";
|
||||
}
|
||||
if (input.requiresAttention) {
|
||||
// Unread/attention-needed completed agents are active in sidebar logic.
|
||||
return "attention";
|
||||
}
|
||||
return "done";
|
||||
export function deriveSidebarStateBucket(input: AgentStateBucketInput): SidebarStateBucket {
|
||||
return deriveAgentStateBucket(input);
|
||||
}
|
||||
|
||||
export function isSidebarActiveAgent(input: {
|
||||
status: AgentLifecycleStatus;
|
||||
pendingPermissionCount?: number;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: SidebarAttentionReason;
|
||||
}): boolean {
|
||||
export function isSidebarActiveAgent(input: AgentStateBucketInput): boolean {
|
||||
return deriveSidebarStateBucket(input) !== "done";
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
shouldPruneWorkspaceAgentTab,
|
||||
workspaceAgentVisibilityEqual,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
|
||||
function makeAgent(input: {
|
||||
id: string;
|
||||
@@ -265,6 +266,34 @@ describe("workspace agent visibility", () => {
|
||||
expect(result.knownAgentIds).toEqual(new Set(["recent-agent"]));
|
||||
});
|
||||
|
||||
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
|
||||
const agentVisibility = {
|
||||
activeAgentIds: new Set(["active-agent"]),
|
||||
autoOpenAgentIds: new Set(["root-agent"]),
|
||||
knownAgentIds: new Set(["active-agent", "archived-agent"]),
|
||||
};
|
||||
|
||||
expect(
|
||||
buildWorkspaceTabSnapshot({
|
||||
agentVisibility,
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
knownTerminalIds: ["terminal-1", "script-terminal"],
|
||||
standaloneTerminalIds: ["terminal-1"],
|
||||
hasActivePendingDraftCreate: false,
|
||||
}),
|
||||
).toEqual({
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: agentVisibility.activeAgentIds,
|
||||
autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
|
||||
knownAgentIds: agentVisibility.knownAgentIds,
|
||||
knownTerminalIds: ["terminal-1", "script-terminal"],
|
||||
standaloneTerminalIds: ["terminal-1"],
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceAgentVisibilityEqual", () => {
|
||||
it("returns true for identical sets", () => {
|
||||
const a = {
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
|
||||
import { shouldAutoOpenAgentTab } from "@/subagents/policies";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
@@ -52,6 +53,26 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
return { activeAgentIds, autoOpenAgentIds, knownAgentIds };
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabSnapshot(input: {
|
||||
agentVisibility: WorkspaceAgentVisibility;
|
||||
agentsHydrated: boolean;
|
||||
terminalsHydrated: boolean;
|
||||
knownTerminalIds: Iterable<string>;
|
||||
standaloneTerminalIds: Iterable<string>;
|
||||
hasActivePendingDraftCreate: boolean;
|
||||
}): WorkspaceTabSnapshot {
|
||||
return {
|
||||
agentsHydrated: input.agentsHydrated,
|
||||
terminalsHydrated: input.terminalsHydrated,
|
||||
activeAgentIds: input.agentVisibility.activeAgentIds,
|
||||
autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
|
||||
knownAgentIds: input.agentVisibility.knownAgentIds,
|
||||
knownTerminalIds: input.knownTerminalIds,
|
||||
standaloneTerminalIds: input.standaloneTerminalIds,
|
||||
hasActivePendingDraftCreate: input.hasActivePendingDraftCreate,
|
||||
};
|
||||
}
|
||||
|
||||
export function workspaceAgentVisibilityEqual(
|
||||
a: WorkspaceAgentVisibility,
|
||||
b: WorkspaceAgentVisibility,
|
||||
191
packages/app/src/workspace/workspace-archive.test.ts
Normal file
191
packages/app/src/workspace/workspace-archive.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
archiveWorkspaceOptimistically,
|
||||
archiveWorkspacesOptimistically,
|
||||
type WorkspaceArchiveTarget,
|
||||
} from "@/workspace/workspace-archive";
|
||||
|
||||
const SERVER_ID = "workspace-archive-test";
|
||||
|
||||
type ArchiveWorkspacePayload = Awaited<ReturnType<DaemonClient["archiveWorkspace"]>>;
|
||||
|
||||
function archivePayload(input: {
|
||||
workspaceId: string;
|
||||
error?: string | null;
|
||||
}): ArchiveWorkspacePayload {
|
||||
return {
|
||||
requestId: "request",
|
||||
workspaceId: input.workspaceId,
|
||||
archivedAt: null,
|
||||
error: input.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
||||
return {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project",
|
||||
projectRootPath: "/repo/project",
|
||||
workspaceDirectory: "/repo/project/workspace-1",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "workspace-1",
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function target(input?: Partial<WorkspaceArchiveTarget>): WorkspaceArchiveTarget {
|
||||
const base = workspace();
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: base.id,
|
||||
workspaceDirectory: base.workspaceDirectory,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function createClient(
|
||||
archiveWorkspace: DaemonClient["archiveWorkspace"],
|
||||
): Pick<DaemonClient, "archiveWorkspace"> {
|
||||
return { archiveWorkspace };
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve: (value: T) => void = () => {};
|
||||
let reject: (error: unknown) => void = () => {};
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function storedWorkspace(id: string): WorkspaceDescriptor | undefined {
|
||||
return useSessionStore.getState().sessions[SERVER_ID]?.workspaces.get(id);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-1" });
|
||||
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-2" });
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
|
||||
describe("archiveWorkspaceOptimistically", () => {
|
||||
it("hides the workspace and marks the archive pending while the daemon call runs", async () => {
|
||||
const archived = workspace();
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
|
||||
const releaseArchive = deferred<ArchiveWorkspacePayload>();
|
||||
const client = createClient(vi.fn(async () => releaseArchive.promise));
|
||||
|
||||
const archive = archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace: target(),
|
||||
});
|
||||
|
||||
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: archived.id,
|
||||
workspaceDirectory: archived.workspaceDirectory,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
releaseArchive.resolve(archivePayload({ workspaceId: archived.id }));
|
||||
await archive;
|
||||
|
||||
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restores the workspace and clears pending state when the daemon rejects the archive", async () => {
|
||||
const archived = workspace();
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
|
||||
const client = createClient(
|
||||
vi.fn(async () => archivePayload({ workspaceId: archived.id, error: "nope" })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace: target(),
|
||||
}),
|
||||
).rejects.toThrow("nope");
|
||||
|
||||
expect(storedWorkspace(archived.id)).toEqual(archived);
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: archived.id,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("runs the after-hide hook after local state is hidden", async () => {
|
||||
const archived = workspace();
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
|
||||
const client = createClient(vi.fn(async () => archivePayload({ workspaceId: archived.id })));
|
||||
const afterHide = vi.fn(() => {
|
||||
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace: target(),
|
||||
afterHide,
|
||||
});
|
||||
|
||||
expect(afterHide).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveWorkspacesOptimistically", () => {
|
||||
it("returns failures and restores only the workspaces whose archive failed", async () => {
|
||||
const first = workspace({ id: "workspace-1" });
|
||||
const second = workspace({
|
||||
id: "workspace-2",
|
||||
workspaceDirectory: "/repo/project/workspace-2",
|
||||
name: "workspace-2",
|
||||
});
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first, second]);
|
||||
const client = createClient(
|
||||
vi.fn(async (workspaceId) =>
|
||||
archivePayload({
|
||||
workspaceId,
|
||||
error: workspaceId === second.id ? "failed" : null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const failures = await archiveWorkspacesOptimistically({
|
||||
client,
|
||||
workspaces: [
|
||||
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
|
||||
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]?.workspaceId).toBe(second.id);
|
||||
expect(storedWorkspace(first.id)).toBeUndefined();
|
||||
expect(storedWorkspace(second.id)).toEqual(second);
|
||||
});
|
||||
});
|
||||
122
packages/app/src/workspace/workspace-archive.ts
Normal file
122
packages/app/src/workspace/workspace-archive.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
|
||||
export interface WorkspaceArchiveTarget {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory?: string | null;
|
||||
}
|
||||
|
||||
interface WorkspaceArchiveClient {
|
||||
archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
interface OptimisticWorkspaceArchiveSnapshot {
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceArchiveFailure {
|
||||
workspaceId: string;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFailure {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"workspaceId" in error &&
|
||||
typeof error.workspaceId === "string" &&
|
||||
"error" in error
|
||||
);
|
||||
}
|
||||
|
||||
function hideWorkspaceOptimistically(
|
||||
workspace: WorkspaceArchiveTarget,
|
||||
): OptimisticWorkspaceArchiveSnapshot {
|
||||
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return { workspace: snapshot };
|
||||
}
|
||||
|
||||
function restoreOptimisticallyHiddenWorkspace(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
snapshot: OptimisticWorkspaceArchiveSnapshot;
|
||||
}): void {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (input.snapshot.workspace) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]);
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveWorkspaceOrThrow(input: {
|
||||
client: WorkspaceArchiveClient;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
const payload = await input.client.archiveWorkspace(input.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceOptimistically(input: {
|
||||
client: WorkspaceArchiveClient;
|
||||
workspace: WorkspaceArchiveTarget;
|
||||
afterHide?: () => void;
|
||||
}): Promise<void> {
|
||||
const snapshot = hideWorkspaceOptimistically(input.workspace);
|
||||
input.afterHide?.();
|
||||
|
||||
try {
|
||||
await archiveWorkspaceOrThrow({
|
||||
client: input.client,
|
||||
workspaceId: input.workspace.workspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: input.workspace.serverId,
|
||||
workspaceId: input.workspace.workspaceId,
|
||||
snapshot,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveWorkspacesOptimistically(input: {
|
||||
client: WorkspaceArchiveClient;
|
||||
workspaces: WorkspaceArchiveTarget[];
|
||||
}): Promise<WorkspaceArchiveFailure[]> {
|
||||
const results = await Promise.allSettled(
|
||||
input.workspaces.map(async (workspace) => {
|
||||
try {
|
||||
await archiveWorkspaceOptimistically({
|
||||
client: input.client,
|
||||
workspace,
|
||||
});
|
||||
} catch (error) {
|
||||
throw { workspaceId: workspace.workspaceId, error } satisfies WorkspaceArchiveFailure;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results.flatMap((result) =>
|
||||
result.status === "rejected" && isWorkspaceArchiveFailure(result.reason) ? [result.reason] : [],
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.72",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -24,13 +24,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.71",
|
||||
"@getpaseo/server": "0.1.72",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"tree-kill": "^1.2.2",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "^2.8.2"
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mime-types": "^3.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.72",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.72",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.72",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.72",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.72",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -58,8 +58,8 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/highlight": "0.1.71",
|
||||
"@getpaseo/relay": "0.1.71",
|
||||
"@getpaseo/highlight": "0.1.72",
|
||||
"@getpaseo/relay": "0.1.72",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
@@ -69,7 +69,7 @@
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv": "^8.20.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AgentStorage } from "./agent-storage.js";
|
||||
import { createAllClients, shutdownProviders } from "./provider-registry.js";
|
||||
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
|
||||
import { isProviderAvailable } from "../daemon-e2e/agent-configs.js";
|
||||
import { OpenCodeServerManager } from "./providers/opencode-agent.js";
|
||||
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.4-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
|
||||
@@ -96,7 +96,8 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
|
||||
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
{ provider: "opencode", model: "opencode/gpt-5-nano" },
|
||||
{ provider: "opencode", model: "opencode/minimax-m2.5-free" },
|
||||
{ provider: "opencode", model: "opencode/nemotron-3-super-free" },
|
||||
] as const;
|
||||
|
||||
interface SchemaValidator<T> {
|
||||
|
||||
37
packages/server/src/server/agent/create-agent-title.ts
Normal file
37
packages/server/src/server/agent/create-agent-title.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
|
||||
|
||||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||
|
||||
function deriveInitialAgentTitle(prompt: string): string | null {
|
||||
const firstContentLine = prompt
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
if (!firstContentLine) {
|
||||
return null;
|
||||
}
|
||||
const normalized = firstContentLine.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const clamped = normalized.slice(0, MAX_INITIAL_AGENT_TITLE_CHARS).trim();
|
||||
return clamped.length > 0 ? clamped : null;
|
||||
}
|
||||
|
||||
export function resolveCreateAgentTitles(options: {
|
||||
configTitle?: string | null;
|
||||
initialPrompt?: string | null;
|
||||
}): { explicitTitle: string | null; provisionalTitle: string | null } {
|
||||
const explicitTitle =
|
||||
typeof options.configTitle === "string" && options.configTitle.trim().length > 0
|
||||
? options.configTitle.trim()
|
||||
: null;
|
||||
const trimmedPrompt = options.initialPrompt?.trim();
|
||||
const provisionalTitle =
|
||||
explicitTitle ?? (trimmedPrompt ? deriveInitialAgentTitle(trimmedPrompt) : null);
|
||||
|
||||
return {
|
||||
explicitTitle,
|
||||
provisionalTitle,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,28 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js";
|
||||
import type { PersistedAgentDescriptor } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineItem, PersistedAgentDescriptor } from "./agent-sdk-types.js";
|
||||
import {
|
||||
ImportSessionsRequestError,
|
||||
importProviderSession,
|
||||
listImportableProviderSessions,
|
||||
normalizeImportAgentRequest,
|
||||
} from "./import-sessions.js";
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeDescriptor(args: {
|
||||
provider?: string;
|
||||
sessionId: string;
|
||||
@@ -40,6 +54,49 @@ function makeDescriptor(args: {
|
||||
};
|
||||
}
|
||||
|
||||
function makeManagedAgent(args: {
|
||||
id?: string;
|
||||
provider?: string;
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
nativeHandle?: string;
|
||||
title?: string | null;
|
||||
}): ManagedAgent {
|
||||
const provider = args.provider ?? "codex";
|
||||
return {
|
||||
id: args.id ?? "00000000-0000-4000-8000-000000000632",
|
||||
provider,
|
||||
cwd: args.cwd,
|
||||
capabilities: TEST_CAPABILITIES,
|
||||
config: { provider, cwd: args.cwd, title: args.title },
|
||||
createdAt: new Date("2026-04-30T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-30T00:00:00.000Z"),
|
||||
availableModes: [],
|
||||
currentModeId: null,
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
inFlightPermissionResponses: new Set(),
|
||||
pendingReplacement: false,
|
||||
persistence: {
|
||||
provider,
|
||||
sessionId: args.sessionId,
|
||||
...(args.nativeHandle ? { nativeHandle: args.nativeHandle } : {}),
|
||||
metadata: { provider, cwd: args.cwd },
|
||||
},
|
||||
historyPrimed: true,
|
||||
lastUserMessageAt: null,
|
||||
attention: { requiresAttention: false },
|
||||
foregroundTurnWaiters: new Set(),
|
||||
finalizedForegroundTurnIds: new Set(),
|
||||
unsubscribeSession: null,
|
||||
internal: false,
|
||||
labels: {},
|
||||
lifecycle: "closed",
|
||||
session: null,
|
||||
activeForegroundTurnId: null,
|
||||
} satisfies ManagedAgent;
|
||||
}
|
||||
|
||||
function makeRequest(
|
||||
overrides: Partial<FetchRecentProviderSessionsRequestMessage> = {},
|
||||
): FetchRecentProviderSessionsRequestMessage {
|
||||
@@ -263,3 +320,144 @@ test("normalizeImportAgentRequest accepts new and legacy import handle shapes",
|
||||
providerHandleId: "thread-2",
|
||||
});
|
||||
});
|
||||
|
||||
test("importProviderSession resumes by provider handle, hydrates the timeline, and applies title metadata", async () => {
|
||||
const cwd = "/tmp/imported-agent";
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
{ type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" },
|
||||
{ type: "assistant_message", text: "I will inspect the provider listing." },
|
||||
];
|
||||
const snapshot = makeManagedAgent({
|
||||
id: "00000000-0000-4000-8000-000000000633",
|
||||
provider: "custom-codex",
|
||||
cwd,
|
||||
sessionId: "thread-imported",
|
||||
nativeHandle: "provider-thread-imported",
|
||||
title: null,
|
||||
});
|
||||
const descriptor = makeDescriptor({
|
||||
provider: "custom-codex",
|
||||
sessionId: "thread-imported",
|
||||
nativeHandle: "provider-thread-imported",
|
||||
cwd,
|
||||
title: null,
|
||||
firstPrompt: "Trace recent provider sessions",
|
||||
lastActivityAt: "2026-04-30T00:00:00.000Z",
|
||||
});
|
||||
const agentManager = {
|
||||
findPersistedAgent: vi.fn().mockResolvedValue(descriptor),
|
||||
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
|
||||
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
|
||||
getTimeline: vi.fn().mockReturnValue(timeline),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
notifyAgentState: vi.fn(),
|
||||
} as unknown as AgentManager;
|
||||
const agentStorage = {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as AgentStorage;
|
||||
const scheduleAgentMetadataGeneration = vi.fn();
|
||||
|
||||
const result = await importProviderSession({
|
||||
request: {
|
||||
requestId: "import-thread",
|
||||
provider: "custom-codex",
|
||||
providerHandleId: "provider-thread-imported",
|
||||
cwd,
|
||||
},
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||
deps: { scheduleAgentMetadataGeneration },
|
||||
});
|
||||
|
||||
expect(agentManager.findPersistedAgent).toHaveBeenCalledWith(
|
||||
"custom-codex",
|
||||
"provider-thread-imported",
|
||||
);
|
||||
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
|
||||
descriptor.persistence,
|
||||
{ cwd },
|
||||
undefined,
|
||||
{ labels: undefined },
|
||||
);
|
||||
expect(agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(snapshot.id);
|
||||
expect(agentManager.setTitle).toHaveBeenCalledWith(snapshot.id, "Trace recent provider sessions");
|
||||
expect(scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd,
|
||||
initialPrompt: "Trace recent provider sessions\n\nkeep it tight",
|
||||
explicitTitle: null,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ snapshot, timelineSize: 2 });
|
||||
});
|
||||
|
||||
test("importProviderSession builds a fallback handle when a non-OpenCode provider has no descriptor", async () => {
|
||||
const cwd = "/tmp/imported-agent";
|
||||
const snapshot = makeManagedAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
sessionId: "thread-imported",
|
||||
nativeHandle: "thread-imported",
|
||||
});
|
||||
const agentManager = {
|
||||
findPersistedAgent: vi.fn().mockResolvedValue(null),
|
||||
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
|
||||
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
|
||||
getTimeline: vi.fn().mockReturnValue([]),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
notifyAgentState: vi.fn(),
|
||||
} as unknown as AgentManager;
|
||||
const agentStorage = {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as AgentStorage;
|
||||
|
||||
await importProviderSession({
|
||||
request: {
|
||||
requestId: "import-thread",
|
||||
provider: "codex",
|
||||
providerHandleId: "thread-imported",
|
||||
cwd,
|
||||
},
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||
});
|
||||
|
||||
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "codex",
|
||||
sessionId: "thread-imported",
|
||||
nativeHandle: "thread-imported",
|
||||
metadata: { provider: "codex", cwd },
|
||||
},
|
||||
{ cwd },
|
||||
undefined,
|
||||
{ labels: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
test("importProviderSession requires cwd for missing OpenCode descriptors", async () => {
|
||||
const agentManager = {
|
||||
findPersistedAgent: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as AgentManager;
|
||||
|
||||
await expect(
|
||||
importProviderSession({
|
||||
request: {
|
||||
requestId: "import-thread",
|
||||
provider: "opencode",
|
||||
providerHandleId: "thread-imported",
|
||||
},
|
||||
agentManager,
|
||||
agentStorage: { list: vi.fn() } as unknown as AgentStorage,
|
||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import type { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
AgentTimelineItem,
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import { resolveCreateAgentTitles } from "./create-agent-title.js";
|
||||
import { unarchiveAgentState } from "./agent-prompt.js";
|
||||
import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js";
|
||||
import type {
|
||||
FetchRecentProviderSessionsRequestMessage,
|
||||
ImportAgentRequestMessageSchema,
|
||||
RecentProviderSessionDescriptorPayload,
|
||||
} from "../../shared/messages.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
|
||||
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
|
||||
|
||||
@@ -49,6 +56,23 @@ export interface ListImportableProviderSessionsResult {
|
||||
filteredAlreadyImportedCount: number;
|
||||
}
|
||||
|
||||
export interface ImportProviderSessionInput {
|
||||
request: NormalizedImportAgentRequest;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
deps?: {
|
||||
scheduleAgentMetadataGeneration?: typeof scheduleAgentMetadataGeneration;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportProviderSessionResult {
|
||||
snapshot: ManagedAgent;
|
||||
timelineSize: number;
|
||||
}
|
||||
|
||||
// COMPAT(import-agent-request-v1): accept legacy {provider, sessionId} shape
|
||||
// alongside the new {providerId, providerHandleId} shape. Old clients
|
||||
// (< target daemon floor) send the legacy fields. Drop the fallbacks and the
|
||||
@@ -118,6 +142,99 @@ export async function listImportableProviderSessions(
|
||||
return { entries, filteredAlreadyImportedCount };
|
||||
}
|
||||
|
||||
export async function importProviderSession(
|
||||
input: ImportProviderSessionInput,
|
||||
): Promise<ImportProviderSessionResult> {
|
||||
const { provider, providerHandleId, cwd, labels } = input.request;
|
||||
const descriptor = await input.agentManager.findPersistedAgent(provider, providerHandleId);
|
||||
if (!descriptor && provider === "opencode" && !cwd) {
|
||||
throw new Error(
|
||||
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
|
||||
);
|
||||
}
|
||||
|
||||
const handle = descriptor
|
||||
? applyImportCwdOverride(descriptor.persistence, cwd)
|
||||
: buildImportPersistenceHandle({ provider, providerHandleId, cwd });
|
||||
const overrides = cwd ? ({ cwd } satisfies Partial<AgentSessionConfig>) : undefined;
|
||||
|
||||
await unarchiveAgentByHandle(input.agentStorage, input.agentManager, handle);
|
||||
const snapshot = await input.agentManager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
overrides,
|
||||
undefined,
|
||||
{
|
||||
labels,
|
||||
},
|
||||
);
|
||||
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
|
||||
await input.agentManager.hydrateTimelineFromProvider(snapshot.id);
|
||||
await applyImportedAgentTitle({
|
||||
snapshot,
|
||||
agentManager: input.agentManager,
|
||||
workspaceGitService: input.workspaceGitService,
|
||||
paseoHome: input.paseoHome,
|
||||
logger: input.logger,
|
||||
scheduleAgentMetadataGeneration:
|
||||
input.deps?.scheduleAgentMetadataGeneration ?? scheduleAgentMetadataGeneration,
|
||||
});
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
timelineSize: input.agentManager.getTimeline(snapshot.id).length,
|
||||
};
|
||||
}
|
||||
|
||||
async function unarchiveAgentByHandle(
|
||||
agentStorage: AgentStorage,
|
||||
agentManager: AgentManager,
|
||||
handle: AgentPersistenceHandle,
|
||||
): Promise<void> {
|
||||
const records = await agentStorage.list();
|
||||
const matched = records.find(
|
||||
(record) =>
|
||||
record.persistence?.provider === handle.provider &&
|
||||
record.persistence?.sessionId === handle.sessionId,
|
||||
);
|
||||
if (!matched) {
|
||||
return;
|
||||
}
|
||||
await unarchiveAgentState(agentStorage, agentManager, matched.id);
|
||||
}
|
||||
|
||||
async function applyImportedAgentTitle(input: {
|
||||
snapshot: ManagedAgent;
|
||||
agentManager: AgentManager;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration;
|
||||
}): Promise<void> {
|
||||
const initialPrompt = getFirstUserMessageText(input.agentManager.getTimeline(input.snapshot.id));
|
||||
if (!initialPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
|
||||
configTitle: input.snapshot.config.title,
|
||||
initialPrompt,
|
||||
});
|
||||
if (!explicitTitle && provisionalTitle) {
|
||||
await input.agentManager.setTitle(input.snapshot.id, provisionalTitle);
|
||||
}
|
||||
|
||||
input.scheduleAgentMetadataGeneration({
|
||||
agentManager: input.agentManager,
|
||||
agentId: input.snapshot.id,
|
||||
cwd: input.snapshot.cwd,
|
||||
workspaceGitService: input.workspaceGitService,
|
||||
initialPrompt,
|
||||
explicitTitle,
|
||||
paseoHome: input.paseoHome,
|
||||
logger: input.logger,
|
||||
});
|
||||
}
|
||||
|
||||
function parseRecentProviderSessionsSince(since: string | undefined): number | null {
|
||||
if (!since) {
|
||||
return null;
|
||||
@@ -129,6 +246,54 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function buildImportPersistenceHandle(input: {
|
||||
provider: AgentProvider;
|
||||
providerHandleId: string;
|
||||
cwd?: string;
|
||||
}): AgentPersistenceHandle {
|
||||
const cwd = input.cwd ?? process.cwd();
|
||||
return {
|
||||
provider: input.provider,
|
||||
sessionId: input.providerHandleId,
|
||||
nativeHandle: input.providerHandleId,
|
||||
metadata: {
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyImportCwdOverride(
|
||||
handle: AgentPersistenceHandle,
|
||||
cwd: string | undefined,
|
||||
): AgentPersistenceHandle {
|
||||
if (!cwd) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
return {
|
||||
...handle,
|
||||
metadata: {
|
||||
...handle.metadata,
|
||||
provider: handle.provider,
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getFirstUserMessageText(timeline: readonly AgentTimelineItem[]): string | null {
|
||||
for (const item of timeline) {
|
||||
if (item.type !== "user_message") {
|
||||
continue;
|
||||
}
|
||||
const text = item.text.trim();
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function collectImportedProviderSessionHandles(
|
||||
agentManager: Pick<AgentManager, "listAgents">,
|
||||
agentStorage: Pick<AgentStorage, "list">,
|
||||
|
||||
@@ -13,7 +13,6 @@ const mockState = vi.hoisted(() => {
|
||||
claude: [] as ConstructorEntry[],
|
||||
codex: [] as ConstructorEntry[],
|
||||
copilot: [] as ConstructorEntry[],
|
||||
opencode: [] as ConstructorEntry[],
|
||||
pi: [] as ConstructorEntry[],
|
||||
genericAcp: [] as Array<{
|
||||
command: string[];
|
||||
@@ -26,7 +25,6 @@ const mockState = vi.hoisted(() => {
|
||||
this.constructorArgs.claude = [];
|
||||
this.constructorArgs.codex = [];
|
||||
this.constructorArgs.copilot = [];
|
||||
this.constructorArgs.opencode = [];
|
||||
this.constructorArgs.pi = [];
|
||||
this.constructorArgs.genericAcp = [];
|
||||
this.isCommandAvailable.mockReset();
|
||||
@@ -188,51 +186,6 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/opencode-agent.js", () => ({
|
||||
OpenCodeAgentClient: class OpenCodeAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
readonly provider = "opencode";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(_logger: unknown, runtimeSettings?: unknown) {
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
mockState.constructorArgs.opencode.push({ runtimeSettings });
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
OpenCodeServerManager: {
|
||||
getInstance: vi.fn(() => ({
|
||||
shutdown: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/pi-direct-agent.js", () => ({
|
||||
PiDirectAgentClient: class PiDirectAgentClient {
|
||||
readonly capabilities = {
|
||||
|
||||
@@ -25,7 +25,8 @@ import { ClaudeAgentClient } from "./providers/claude/agent.js";
|
||||
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
||||
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
|
||||
import { PiDirectAgentClient } from "./providers/pi-direct-agent.js";
|
||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||
import {
|
||||
|
||||
@@ -378,9 +378,9 @@ describe("ClaudeAgentSession integration", () => {
|
||||
streamSession(
|
||||
handle.session,
|
||||
[
|
||||
"Use the Task tool to start a background sub-agent.",
|
||||
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
|
||||
"Do not wait for task completion.",
|
||||
"Use the Bash tool with run_in_background.",
|
||||
"Run exactly: sleep 3",
|
||||
"Do not wait for the task result.",
|
||||
"Reply immediately with exactly: SPAWNED",
|
||||
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
|
||||
].join(" "),
|
||||
|
||||
@@ -98,6 +98,122 @@ function markdownImageSource(markdown: string): string {
|
||||
return match[1].replace(/\\\)/g, ")");
|
||||
}
|
||||
|
||||
function createChildProcessStub(): ChildProcessWithoutNullStreams {
|
||||
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
||||
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
|
||||
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
|
||||
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
child.kill = vi.fn((signal?: NodeJS.Signals | number) => {
|
||||
queueMicrotask(() => child.emit("exit", null, signal ?? null));
|
||||
return true;
|
||||
}) as ChildProcessWithoutNullStreams["kill"];
|
||||
return child;
|
||||
}
|
||||
|
||||
function createScriptedCodexPeer(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
handlers: Record<string, (params: unknown) => unknown>,
|
||||
) {
|
||||
const messages: Record<string, unknown>[] = [];
|
||||
const errors: Error[] = [];
|
||||
const waiters = new Set<{
|
||||
predicate: (message: Record<string, unknown>) => boolean;
|
||||
resolve: (message: Record<string, unknown>) => void;
|
||||
}>();
|
||||
let buffer = "";
|
||||
|
||||
const processMessage = (message: Record<string, unknown>) => {
|
||||
messages.push(message);
|
||||
for (const waiter of Array.from(waiters)) {
|
||||
if (waiter.predicate(message)) {
|
||||
waiters.delete(waiter);
|
||||
waiter.resolve(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof message.id !== "number" || typeof message.method !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = handlers[message.method];
|
||||
if (!handler) {
|
||||
errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(handler(message.params))
|
||||
.then((result) => {
|
||||
child.stdout.write(`${JSON.stringify({ id: message.id, result })}\n`);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: message.id,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
})}\n`,
|
||||
);
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
child.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
for (;;) {
|
||||
const newlineIndex = buffer.indexOf("\n");
|
||||
if (newlineIndex === -1) {
|
||||
break;
|
||||
}
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
processMessage(parsed as Record<string, unknown>);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
assertNoErrors() {
|
||||
if (errors.length > 0) {
|
||||
throw errors[0];
|
||||
}
|
||||
},
|
||||
waitForMessage(
|
||||
predicate: (message: Record<string, unknown>) => boolean,
|
||||
label: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const existing = messages.find(predicate);
|
||||
if (existing) {
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
waiters.delete(waiter);
|
||||
reject(new Error(`Timed out waiting for ${label}`));
|
||||
}, 1000);
|
||||
const waiter = {
|
||||
predicate,
|
||||
resolve: (message: Record<string, unknown>) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(message);
|
||||
},
|
||||
};
|
||||
waiters.add(waiter);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Codex app-server provider", () => {
|
||||
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
@@ -184,6 +300,103 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("round-trips server-initiated command approvals through the real app-server transport", async () => {
|
||||
const child = createChildProcessStub();
|
||||
const peer = createScriptedCodexPeer(child, {
|
||||
initialize: () => ({}),
|
||||
"collaborationMode/list": () => ({ data: [] }),
|
||||
"skills/list": () => ({ data: [] }),
|
||||
});
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.connect();
|
||||
peer.assertNoErrors();
|
||||
|
||||
const permissionRequested = new Promise<
|
||||
Extract<AgentStreamEvent, { type: "permission_requested" }>
|
||||
>((resolve, reject) => {
|
||||
const existing = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested",
|
||||
);
|
||||
if (existing) {
|
||||
resolve(existing);
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error("Timed out waiting for permission_requested"));
|
||||
}, 1000);
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
if (event.type !== "permission_requested") {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(event);
|
||||
});
|
||||
});
|
||||
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 41,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
itemId: "exec-approval-1",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
command: "git restore README.md",
|
||||
cwd: "/workspace/project",
|
||||
reason: "requires escalated permissions",
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const permissionEvent = await permissionRequested;
|
||||
expect(permissionEvent.request).toMatchObject({
|
||||
id: "permission-exec-approval-1",
|
||||
provider: "codex",
|
||||
name: "CodexBash",
|
||||
kind: "tool",
|
||||
title: "Run command: git restore README.md",
|
||||
description: "requires escalated permissions",
|
||||
input: {
|
||||
command: "git restore README.md",
|
||||
cwd: "/workspace/project",
|
||||
},
|
||||
metadata: {
|
||||
itemId: "exec-approval-1",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
},
|
||||
});
|
||||
|
||||
await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" });
|
||||
|
||||
await expect(
|
||||
peer.waitForMessage(
|
||||
(message) =>
|
||||
message.id === 41 &&
|
||||
!("method" in message) &&
|
||||
JSON.stringify(message.result) === JSON.stringify({ decision: "accept" }),
|
||||
"command approval response",
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
id: 41,
|
||||
result: { decision: "accept" },
|
||||
});
|
||||
peer.assertNoErrors();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
|
||||
const cwd = path.join(tempDir, "repo", "packages", "app");
|
||||
|
||||
@@ -38,7 +38,6 @@ import * as fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { z } from "zod";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { curateAgentActivity } from "../activity-curator.js";
|
||||
@@ -53,10 +52,10 @@ import {
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||
import {
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
type ProviderImageOutput,
|
||||
@@ -83,11 +82,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||
const INTERRUPT_TIMEOUT_MS = 2_000;
|
||||
const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
const CODEX_PROVIDER = "codex" as const;
|
||||
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
||||
const ASSISTANT_MESSAGE_BOUNDARY_MARKDOWN = "\n\n---\n\n";
|
||||
@@ -627,53 +623,11 @@ function toCodexMcpConfig(config: McpServerConfig): CodexMcpServerConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
interface JsonRpcRequest {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { code?: number; message: string };
|
||||
}
|
||||
|
||||
interface JsonRpcNotification {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
function isJsonRpcResponse(msg: unknown): msg is JsonRpcResponse {
|
||||
if (!isRecord(msg)) return false;
|
||||
if (typeof msg.id !== "number") return false;
|
||||
return msg.result !== undefined || !!msg.error;
|
||||
}
|
||||
|
||||
function isJsonRpcRequest(msg: unknown): msg is JsonRpcRequest {
|
||||
if (!isRecord(msg)) return false;
|
||||
return typeof msg.id === "number" && typeof msg.method === "string";
|
||||
}
|
||||
|
||||
function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
|
||||
if (!isRecord(msg)) return false;
|
||||
return typeof msg.method === "string" && typeof msg.id !== "number";
|
||||
}
|
||||
|
||||
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
type RequestHandler = (params: unknown) => unknown;
|
||||
|
||||
type NotificationHandler = (method: string, params: unknown) => void;
|
||||
|
||||
// Codex app-server API response types
|
||||
interface CodexReasoningEffortEntry {
|
||||
reasoningEffort?: string;
|
||||
@@ -727,171 +681,6 @@ function filterCodexThreadsByCwd(
|
||||
return threads.filter((thread) => typeof thread.cwd === "string" && thread.cwd === cwd);
|
||||
}
|
||||
|
||||
class CodexAppServerClient {
|
||||
private readonly rl: readline.Interface;
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
private readonly requestHandlers = new Map<string, RequestHandler>();
|
||||
private notificationHandler: NotificationHandler | null = null;
|
||||
private nextId = 1;
|
||||
private disposed = false;
|
||||
private stderrBuffer = "";
|
||||
|
||||
constructor(
|
||||
private readonly child: ChildProcessWithoutNullStreams,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.rl = readline.createInterface({ input: child.stdout });
|
||||
this.rl.on("line", (line) => this.handleLine(line));
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
this.stderrBuffer += chunk.toString();
|
||||
if (this.stderrBuffer.length > 8192) {
|
||||
this.stderrBuffer = this.stderrBuffer.slice(-8192);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
this.logger.error({ err }, "Codex app-server child process error");
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.disposed = true;
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const message =
|
||||
code === 0 && !signal
|
||||
? "Codex app-server exited"
|
||||
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
|
||||
const error = new Error(`${message}\n${this.stderrBuffer}`.trim());
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.disposed = true;
|
||||
});
|
||||
}
|
||||
|
||||
setNotificationHandler(handler: NotificationHandler): void {
|
||||
this.notificationHandler = handler;
|
||||
}
|
||||
|
||||
setRequestHandler(method: string, handler: RequestHandler): void {
|
||||
this.requestHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error("Codex app-server client is closed"));
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const payload: JsonRpcRequest = { id, method, params };
|
||||
const serialized = JSON.stringify(payload);
|
||||
this.child.stdin.write(`${serialized}\n`);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Codex app-server request timed out for ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
notify(method: string, params?: unknown): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
const payload: JsonRpcNotification = { method, params };
|
||||
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
private writeJsonRpcResponse(response: JsonRpcResponse): void {
|
||||
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.child.stdin.write(`${JSON.stringify(response)}\n`);
|
||||
} catch (error) {
|
||||
this.logger.debug({ error }, "Failed to write Codex app-server JSON-RPC response");
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.rl.close();
|
||||
try {
|
||||
this.child.stdin.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const result = await terminateWithTreeKill(this.child, {
|
||||
gracefulTimeoutMs: APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
||||
forceTimeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||
onForceSignal: () => {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS },
|
||||
"Codex app-server did not exit after SIGTERM; sending SIGKILL",
|
||||
);
|
||||
},
|
||||
});
|
||||
if (result === "kill-timeout") {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS },
|
||||
"Codex app-server did not report exit after SIGKILL",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLine(line: string): Promise<void> {
|
||||
if (!line.trim()) return;
|
||||
const raw: unknown = JSON.parse(line);
|
||||
if (!isRecord(raw)) {
|
||||
this.logger.warn({ line }, "Parsed JSON is not an object");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJsonRpcResponse(raw)) {
|
||||
const id = raw.id;
|
||||
if (raw.result !== undefined || raw.error) {
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
if (raw.error) {
|
||||
pending.reject(new Error(raw.error.message ?? "Unknown error"));
|
||||
} else {
|
||||
pending.resolve(raw.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Server-initiated request
|
||||
if (isJsonRpcRequest(raw)) {
|
||||
const request = raw;
|
||||
const handler = this.requestHandlers.get(request.method);
|
||||
try {
|
||||
const result = handler ? await handler(request.params) : {};
|
||||
this.writeJsonRpcResponse({ id: request.id, result });
|
||||
} catch (error) {
|
||||
this.writeJsonRpcResponse({
|
||||
id: request.id,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isJsonRpcNotification(raw)) {
|
||||
this.notificationHandler?.(raw.method, raw.params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
||||
const usage = toObjectRecord(tokenUsage);
|
||||
if (!usage) return undefined;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { CodexAppServerClient } from "./app-server-transport.js";
|
||||
|
||||
function createChildProcessStub(): ChildProcessWithoutNullStreams {
|
||||
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
||||
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
|
||||
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
|
||||
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
child.kill = (() => true) as ChildProcessWithoutNullStreams["kill"];
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("Codex app-server transport", () => {
|
||||
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
|
||||
const child = createChildProcessStub();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
|
||||
const request = client.request("model/list", {});
|
||||
child.stdout.write("Codex ha iniciado en modo localizado\n");
|
||||
child.stdout.write('{"id":1,"result":{"data":[]}}\n');
|
||||
|
||||
await expect(request).resolves.toEqual({ data: [] });
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
test.each([
|
||||
"item/commandExecution/requestApproval",
|
||||
"item/fileChange/requestApproval",
|
||||
"item/tool/requestUserInput",
|
||||
"tool/requestUserInput",
|
||||
])("answers server-initiated %s requests through registered handlers", async (method) => {
|
||||
const child = createChildProcessStub();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const handlerCalls: unknown[] = [];
|
||||
client.setRequestHandler(method, async (params) => {
|
||||
handlerCalls.push(params);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const response = new Promise<string>((resolve) => {
|
||||
child.stdin.once("data", (chunk) => resolve(chunk.toString()));
|
||||
});
|
||||
child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
|
||||
|
||||
await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n');
|
||||
expect(handlerCalls).toEqual([{}]);
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.stdin.end();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import readline from "node:readline";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
const STDERR_BUFFER_LIMIT = 8192;
|
||||
|
||||
interface JsonRpcRequest {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
interface JsonRpcNotification {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
type RequestHandler = (params: unknown) => unknown;
|
||||
type NotificationHandler = (method: string, params: unknown) => void;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isJsonRpcResponse(msg: unknown): msg is JsonRpcResponse {
|
||||
if (!isRecord(msg)) return false;
|
||||
return typeof msg.id === "number";
|
||||
}
|
||||
|
||||
function isJsonRpcRequest(msg: unknown): msg is JsonRpcRequest {
|
||||
if (!isRecord(msg)) return false;
|
||||
return typeof msg.id === "number" && typeof msg.method === "string";
|
||||
}
|
||||
|
||||
function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
|
||||
if (!isRecord(msg)) return false;
|
||||
return typeof msg.method === "string" && msg.id === undefined;
|
||||
}
|
||||
|
||||
export class CodexAppServerClient {
|
||||
private readonly rl: readline.Interface;
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
private readonly requestHandlers = new Map<string, RequestHandler>();
|
||||
private notificationHandler: NotificationHandler | null = null;
|
||||
private nextId = 1;
|
||||
private disposed = false;
|
||||
private stderrBuffer = "";
|
||||
|
||||
constructor(
|
||||
private readonly child: ChildProcessWithoutNullStreams,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.rl = readline.createInterface({ input: child.stdout });
|
||||
this.rl.on("line", (line) => {
|
||||
void this.handleLine(line).catch((error) => {
|
||||
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
|
||||
});
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
this.stderrBuffer += chunk.toString();
|
||||
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
|
||||
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
this.logger.error({ err }, "Codex app-server child process error");
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.disposed = true;
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const message =
|
||||
code === 0 && !signal
|
||||
? "Codex app-server exited"
|
||||
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
|
||||
const error = new Error(`${message}\n${this.stderrBuffer}`.trim());
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.disposed = true;
|
||||
});
|
||||
}
|
||||
|
||||
setNotificationHandler(handler: NotificationHandler): void {
|
||||
this.notificationHandler = handler;
|
||||
}
|
||||
|
||||
setRequestHandler(method: string, handler: RequestHandler): void {
|
||||
this.requestHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error("Codex app-server client is closed"));
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const payload: JsonRpcRequest = { id, method, params };
|
||||
const serialized = JSON.stringify(payload);
|
||||
this.child.stdin.write(`${serialized}\n`);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Codex app-server request timed out for ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
notify(method: string, params?: unknown): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
const payload: JsonRpcNotification = { method, params };
|
||||
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.rl.close();
|
||||
try {
|
||||
this.child.stdin.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const result = await terminateWithTreeKill(this.child, {
|
||||
gracefulTimeoutMs: APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
||||
forceTimeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||
onForceSignal: () => {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS },
|
||||
"Codex app-server did not exit after SIGTERM; sending SIGKILL",
|
||||
);
|
||||
},
|
||||
});
|
||||
if (result === "kill-timeout") {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS },
|
||||
"Codex app-server did not report exit after SIGKILL",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private writeJsonRpcResponse(response: JsonRpcResponse): void {
|
||||
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.child.stdin.write(`${JSON.stringify(response)}\n`);
|
||||
} catch (error) {
|
||||
this.logger.debug({ error }, "Failed to write Codex app-server JSON-RPC response");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLine(line: string): Promise<void> {
|
||||
if (!line.trim()) return;
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(line);
|
||||
} catch (error) {
|
||||
this.logger.warn({ error, line }, "Ignoring non-JSON Codex app-server stdout line");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(raw)) {
|
||||
this.logger.warn({ line }, "Parsed JSON is not an object");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJsonRpcResponse(raw)) {
|
||||
const id = raw.id;
|
||||
if (raw.result !== undefined || raw.error) {
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
if (raw.error) {
|
||||
pending.reject(new Error(raw.error.message ?? "Unknown error"));
|
||||
} else {
|
||||
pending.resolve(raw.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJsonRpcRequest(raw)) {
|
||||
const request = raw;
|
||||
const handler = this.requestHandlers.get(request.method);
|
||||
try {
|
||||
const result = handler ? await handler(request.params) : {};
|
||||
this.writeJsonRpcResponse({ id: request.id, result });
|
||||
} catch (error) {
|
||||
this.writeJsonRpcResponse({
|
||||
id: request.id,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isJsonRpcNotification(raw)) {
|
||||
this.notificationHandler?.(raw.method, raw.params);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,28 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
createEventStream,
|
||||
idleEvent,
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
|
||||
interface MockOpenCodeClientOptions {
|
||||
agents?: unknown[];
|
||||
events?: unknown[];
|
||||
}
|
||||
|
||||
function createEventStream(events: unknown[]): AsyncGenerator<never> {
|
||||
return (async function* () {
|
||||
for (const event of events) {
|
||||
yield event as never;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function mockServerManager(): void {
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
acquire: vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
}),
|
||||
} as never);
|
||||
}
|
||||
|
||||
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
|
||||
const promptAsync = vi.fn().mockResolvedValue({});
|
||||
const permissionReply = vi.fn().mockResolvedValue({});
|
||||
const questionReply = vi.fn().mockResolvedValue({});
|
||||
const questionReject = vi.fn().mockResolvedValue({});
|
||||
const appAgents = vi.fn().mockResolvedValue({ data: options.agents ?? [] });
|
||||
const events = options.events ?? [idleEvent()];
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.appAgentsResponse = { data: options.agents ?? [] };
|
||||
openCodeClient.eventStream = createEventStream(options.events ?? [idleEvent()]);
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
promptAsync,
|
||||
abort: vi.fn().mockResolvedValue({}),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({ data: { connected: [], all: [] } }),
|
||||
},
|
||||
event: {
|
||||
subscribe: vi.fn().mockResolvedValue({ stream: createEventStream(events) }),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
app: {
|
||||
agents: appAgents,
|
||||
},
|
||||
permission: {
|
||||
reply: permissionReply,
|
||||
},
|
||||
question: {
|
||||
reply: questionReply,
|
||||
reject: questionReject,
|
||||
},
|
||||
} as never);
|
||||
|
||||
return { appAgents, permissionReply, promptAsync, questionReject, questionReply };
|
||||
}
|
||||
|
||||
function idleEvent(): unknown {
|
||||
return {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
return { openCodeClient, runtime };
|
||||
}
|
||||
|
||||
function toolPermissionEvent(): unknown {
|
||||
@@ -116,20 +63,15 @@ function questionEvent(): unknown {
|
||||
}
|
||||
|
||||
describe("OpenCode full-access mode", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("includes virtual full-access mode with dynamic OpenCode agents", async () => {
|
||||
mockServerManager();
|
||||
mockOpenCodeClient({
|
||||
const { runtime } = mockOpenCodeClient({
|
||||
agents: [
|
||||
{ name: "build", mode: "primary", hidden: false, description: "Build agent" },
|
||||
{ name: "paseo-custom", mode: "primary", hidden: false, description: "Custom agent" },
|
||||
],
|
||||
});
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
|
||||
|
||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "full-access", "paseo-custom"]);
|
||||
@@ -140,10 +82,9 @@ describe("OpenCode full-access mode", () => {
|
||||
});
|
||||
|
||||
test("reports full-access but sends prompts through OpenCode build agent", async () => {
|
||||
mockServerManager();
|
||||
const { promptAsync } = mockOpenCodeClient();
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient();
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -154,20 +95,21 @@ describe("OpenCode full-access mode", () => {
|
||||
|
||||
await session.run("Implement the change");
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ agent: "build" }));
|
||||
expect(openCodeClient.calls.sessionPromptAsync).toHaveLength(1);
|
||||
expect(openCodeClient.calls.sessionPromptAsync[0]).toEqual(
|
||||
expect.objectContaining({ agent: "build" }),
|
||||
);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("auto-approves tool permissions in full-access without surfacing them", async () => {
|
||||
mockServerManager();
|
||||
const { permissionReply } = mockOpenCodeClient({
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient({
|
||||
events: [toolPermissionEvent(), idleEvent()],
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -177,8 +119,8 @@ describe("OpenCode full-access mode", () => {
|
||||
|
||||
await session.run("Run verification");
|
||||
|
||||
expect(permissionReply).toHaveBeenCalledTimes(1);
|
||||
expect(permissionReply).toHaveBeenCalledWith({
|
||||
expect(openCodeClient.calls.permissionReply).toHaveLength(1);
|
||||
expect(openCodeClient.calls.permissionReply[0]).toEqual({
|
||||
requestID: "permission-1",
|
||||
directory: "/tmp/project",
|
||||
reply: "once",
|
||||
@@ -190,13 +132,12 @@ describe("OpenCode full-access mode", () => {
|
||||
});
|
||||
|
||||
test("keeps questions separate from full-access tool auto-approval", async () => {
|
||||
mockServerManager();
|
||||
const { permissionReply, questionReply } = mockOpenCodeClient({
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient({
|
||||
events: [questionEvent(), idleEvent()],
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -221,13 +162,13 @@ describe("OpenCode full-access mode", () => {
|
||||
updatedInput: { answers: { Decision: "Proceed" } },
|
||||
});
|
||||
|
||||
expect(questionReply).toHaveBeenCalledTimes(1);
|
||||
expect(questionReply).toHaveBeenCalledWith({
|
||||
expect(openCodeClient.calls.questionReply).toHaveLength(1);
|
||||
expect(openCodeClient.calls.questionReply[0]).toEqual({
|
||||
requestID: "question-1",
|
||||
directory: "/tmp/project",
|
||||
answers: [["Proceed"]],
|
||||
});
|
||||
expect(permissionReply).not.toHaveBeenCalled();
|
||||
expect(openCodeClient.calls.permissionReply).toEqual([]);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
|
||||
await session.close();
|
||||
|
||||
@@ -1,61 +1,46 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const providerList = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
data: {
|
||||
connected: ["zai"],
|
||||
all: [
|
||||
{
|
||||
id: "zai",
|
||||
name: "Z.AI",
|
||||
models: {
|
||||
"glm-5.1": {
|
||||
name: "GLM 5.1",
|
||||
limit: { context: 128_000 },
|
||||
},
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListImplementation = () =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
data: {
|
||||
connected: ["zai"],
|
||||
all: [
|
||||
{
|
||||
id: "zai",
|
||||
name: "Z.AI",
|
||||
models: {
|
||||
"glm-5.1": {
|
||||
name: "GLM 5.1",
|
||||
limit: { context: 128_000 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}, 15_000);
|
||||
}),
|
||||
);
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
provider: {
|
||||
list: providerList,
|
||||
},
|
||||
} as never);
|
||||
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
acquire: vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
}),
|
||||
} as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
@@ -67,28 +52,23 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
|
||||
label: "GLM 5.1",
|
||||
},
|
||||
]);
|
||||
expect(openCodeClient.calls.providerList).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("passes explicit refresh force through server acquisition", async () => {
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
} as never);
|
||||
const acquire = vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
});
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never);
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
|
||||
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
|
||||
|
||||
expect(acquire).toHaveBeenCalledWith({ force: true });
|
||||
expect(runtime.acquisitions).toEqual([{ force: true, releaseCount: 1 }]);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
@@ -24,39 +22,12 @@ function createDeferred<T>(): {
|
||||
}
|
||||
|
||||
describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
app: {
|
||||
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
} as never);
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
acquire: vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
}),
|
||||
} as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.listCommands?.()).resolves.toEqual(
|
||||
@@ -72,49 +43,11 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
});
|
||||
|
||||
test("executes compact through the OpenCode summarize endpoint", async () => {
|
||||
const command = vi.fn();
|
||||
const summarize = vi.fn().mockResolvedValue({ data: {} });
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
command,
|
||||
summarize,
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
event: {
|
||||
subscribe: vi.fn().mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
yield {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
})(),
|
||||
}),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
app: {
|
||||
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
acquire: vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
}),
|
||||
} as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.run("/compact")).resolves.toMatchObject({
|
||||
@@ -123,55 +56,30 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
timeline: [],
|
||||
usage: undefined,
|
||||
});
|
||||
expect(summarize).toHaveBeenCalledWith({ sessionID: "session-1", directory: "/tmp" });
|
||||
expect(command).not.toHaveBeenCalled();
|
||||
expect(openCodeClient.calls.sessionSummarize).toEqual([
|
||||
{ sessionID: "session-1", directory: "/tmp" },
|
||||
]);
|
||||
expect(openCodeClient.calls.sessionCommand).toEqual([]);
|
||||
});
|
||||
|
||||
test("waits for SSE completion when slash commands hit a header timeout", async () => {
|
||||
const idleEventGate = createDeferred<void>();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
openCodeClient.sessionCommandError = new Error("fetch failed: Headers Timeout Error");
|
||||
openCodeClient.commandListResponse = {
|
||||
data: [{ name: "help", description: "Show help", hints: [] }],
|
||||
};
|
||||
openCodeClient.eventStream = (async function* () {
|
||||
await idleEventGate.promise;
|
||||
yield {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
})();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
command: vi.fn().mockRejectedValue(new Error("fetch failed: Headers Timeout Error")),
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
event: {
|
||||
subscribe: vi.fn().mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
await idleEventGate.promise;
|
||||
yield {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
})(),
|
||||
}),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: [{ name: "help", description: "Show help", hints: [] }],
|
||||
}),
|
||||
},
|
||||
app: {
|
||||
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
acquire: vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
}),
|
||||
} as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
const runPromise = session.run("/help");
|
||||
@@ -186,3 +94,14 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createOpenCodeClientWithConnectedProvider(): TestOpenCodeClient {
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
};
|
||||
return openCodeClient;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage as OpenCodeAssistantMessage,
|
||||
type Event as OpenCodeEvent,
|
||||
type FilePartInput as OpenCodeFilePartInput,
|
||||
@@ -11,7 +9,7 @@ import {
|
||||
type Part as OpenCodePart,
|
||||
type TextPartInput as OpenCodeTextPartInput,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
import net from "node:net";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -43,17 +41,12 @@ import type {
|
||||
ToolCallDetail,
|
||||
ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
createProviderEnvSpec,
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
import { OpenCodeServerManager } from "./opencode/server-manager.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
@@ -63,6 +56,11 @@ import {
|
||||
} from "./diagnostic-utils.js";
|
||||
import { runProviderTurn } from "./provider-runner.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import {
|
||||
createSdkOpenCodeClient,
|
||||
type OpenCodeRuntime,
|
||||
type OpenCodeServerAcquisition,
|
||||
} from "./opencode/runtime.js";
|
||||
|
||||
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -159,8 +157,6 @@ type OpenCodeMcpConfig =
|
||||
|
||||
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
|
||||
const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
|
||||
const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
|
||||
{ name: "compact", description: "Compact the current session", argumentHint: "" },
|
||||
{ name: "summarize", description: "Compact the current session", argumentHint: "" },
|
||||
@@ -251,16 +247,6 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch
|
||||
}),
|
||||
);
|
||||
|
||||
async function resolveOpenCodeBinary(): Promise<string> {
|
||||
const found = await findExecutable("opencode");
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
throw new Error(
|
||||
"OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.",
|
||||
);
|
||||
}
|
||||
|
||||
function toOpenCodeMcpConfig(config: McpServerConfig): OpenCodeMcpConfig {
|
||||
if (config.type === "stdio") {
|
||||
return {
|
||||
@@ -414,22 +400,6 @@ function isAlreadyPresentMcpError(error: unknown): boolean {
|
||||
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
async function findAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (address && typeof address === "object") {
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
} else {
|
||||
server.close(() => reject(new Error("Failed to get port")));
|
||||
}
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePartDedupeKey(
|
||||
part: { id: string; messageID: string },
|
||||
partType: "text" | "reasoning",
|
||||
@@ -928,299 +898,54 @@ export const __openCodeInternals = {
|
||||
},
|
||||
};
|
||||
|
||||
export class OpenCodeServerManager {
|
||||
private static instance: OpenCodeServerManager | null = null;
|
||||
private static exitHandlerRegistered = false;
|
||||
private currentServer: OpenCodeServerGeneration | null = null;
|
||||
private retiredServers = new Set<OpenCodeServerGeneration>();
|
||||
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly runtimeSettingsKey: string;
|
||||
interface OpenCodeAgentClientDeps {
|
||||
runtime?: OpenCodeRuntime;
|
||||
}
|
||||
|
||||
private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
|
||||
this.logger = logger;
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {});
|
||||
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
|
||||
constructor(private readonly serverManager: OpenCodeServerManager) {}
|
||||
|
||||
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
|
||||
return this.serverManager.acquire(options);
|
||||
}
|
||||
|
||||
static getInstance(
|
||||
logger: Logger,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
): OpenCodeServerManager {
|
||||
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
|
||||
if (!OpenCodeServerManager.instance) {
|
||||
OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
|
||||
OpenCodeServerManager.registerExitHandler();
|
||||
} else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) {
|
||||
logger.warn(
|
||||
{
|
||||
existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey,
|
||||
requestedRuntimeSettings: nextSettingsKey,
|
||||
},
|
||||
"OpenCode server manager already initialized with different runtime settings",
|
||||
);
|
||||
}
|
||||
return OpenCodeServerManager.instance;
|
||||
async ensureServerRunning(): Promise<{ port: number; url: string }> {
|
||||
return this.serverManager.ensureRunning();
|
||||
}
|
||||
|
||||
private static registerExitHandler(): void {
|
||||
if (OpenCodeServerManager.exitHandlerRegistered) {
|
||||
return;
|
||||
}
|
||||
OpenCodeServerManager.exitHandlerRegistered = true;
|
||||
|
||||
const cleanup = () => {
|
||||
const instance = OpenCodeServerManager.instance;
|
||||
void instance?.shutdown();
|
||||
};
|
||||
|
||||
process.on("exit", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
process.on("SIGINT", cleanup);
|
||||
}
|
||||
|
||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||
const acquisition = await this.acquire({ force: false });
|
||||
acquisition.release();
|
||||
return acquisition.server;
|
||||
}
|
||||
|
||||
async acquire(options: { force: boolean }): Promise<{
|
||||
server: { port: number; url: string };
|
||||
release: () => void;
|
||||
}> {
|
||||
const server = options.force
|
||||
? await this.getForcedRefreshServer()
|
||||
: await this.getCurrentServer();
|
||||
server.refCount += 1;
|
||||
let released = false;
|
||||
return {
|
||||
server: { port: server.port, url: server.url },
|
||||
release: () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
server.refCount -= 1;
|
||||
this.cleanupRetiredServers();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> {
|
||||
if (this.forcedRefreshPromise) {
|
||||
return this.forcedRefreshPromise;
|
||||
}
|
||||
|
||||
this.forcedRefreshPromise = Promise.resolve()
|
||||
.then(async () => {
|
||||
await this.rotateCurrentServer();
|
||||
return this.getCurrentServer();
|
||||
})
|
||||
.finally(() => {
|
||||
this.forcedRefreshPromise = null;
|
||||
});
|
||||
return this.forcedRefreshPromise;
|
||||
}
|
||||
|
||||
private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
|
||||
if (this.startPromise) {
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
if (this.currentServer && !this.currentServer.process.killed) {
|
||||
return this.currentServer;
|
||||
}
|
||||
|
||||
this.startPromise = this.startServer();
|
||||
try {
|
||||
const result = await this.startPromise;
|
||||
if (!result.retired) {
|
||||
this.currentServer = result;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
this.startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async rotateCurrentServer(): Promise<void> {
|
||||
const existing = this.currentServer;
|
||||
if (existing) {
|
||||
existing.retired = true;
|
||||
this.retiredServers.add(existing);
|
||||
this.currentServer = null;
|
||||
this.cleanupRetiredServers();
|
||||
}
|
||||
if (this.startPromise) {
|
||||
const pending = await this.startPromise;
|
||||
pending.retired = true;
|
||||
this.retiredServers.add(pending);
|
||||
this.currentServer = null;
|
||||
this.cleanupRetiredServers();
|
||||
}
|
||||
}
|
||||
|
||||
private async startServer(): Promise<OpenCodeServerGeneration> {
|
||||
const port = await findAvailablePort();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
const launchPrefix = await resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveOpenCodeBinary,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const serverProcess = spawnProcess(
|
||||
launchPrefix.command,
|
||||
[...launchPrefix.args, "serve", "--port", String(port)],
|
||||
{
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
|
||||
},
|
||||
);
|
||||
|
||||
let started = false;
|
||||
let stderrBuffer = "";
|
||||
let stdoutBuffer = "";
|
||||
const STARTUP_BUFFER_CAP = 8192;
|
||||
const appendCapped = (current: string, chunk: string): string => {
|
||||
if (current.length >= STARTUP_BUFFER_CAP) {
|
||||
return current;
|
||||
}
|
||||
const remaining = STARTUP_BUFFER_CAP - current.length;
|
||||
return current + chunk.slice(0, remaining);
|
||||
};
|
||||
const buildStartupErrorMessage = (headline: string): string => {
|
||||
const sections = [headline];
|
||||
const stderrTrimmed = stderrBuffer.trim();
|
||||
if (stderrTrimmed.length > 0) {
|
||||
sections.push(`stderr: ${stderrTrimmed}`);
|
||||
}
|
||||
const stdoutTrimmed = stdoutBuffer.trim();
|
||||
if (stdoutTrimmed.length > 0) {
|
||||
sections.push(`stdout: ${stdoutTrimmed}`);
|
||||
}
|
||||
return sections.join("\n");
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
if (!started) {
|
||||
reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
serverProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdoutBuffer = appendCapped(stdoutBuffer, output);
|
||||
if (output.includes("listening on") && !started) {
|
||||
started = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
process: serverProcess,
|
||||
port,
|
||||
url,
|
||||
refCount: 0,
|
||||
retired: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stderrBuffer = appendCapped(stderrBuffer, output);
|
||||
this.logger.error({ stderr: output.trim() }, "OpenCode server stderr");
|
||||
});
|
||||
|
||||
serverProcess.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
const headline = error instanceof Error ? error.message : String(error);
|
||||
reject(new Error(buildStartupErrorMessage(headline)));
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
if (!started) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
|
||||
}
|
||||
if (this.currentServer?.process === serverProcess) {
|
||||
this.currentServer = null;
|
||||
}
|
||||
for (const retired of Array.from(this.retiredServers)) {
|
||||
if (retired.process === serverProcess) {
|
||||
this.retiredServers.delete(retired);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
|
||||
return createSdkOpenCodeClient(options);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
const servers = [
|
||||
...(this.currentServer ? [this.currentServer] : []),
|
||||
...Array.from(this.retiredServers),
|
||||
];
|
||||
await Promise.all(servers.map((server) => this.killServer(server)));
|
||||
this.currentServer = null;
|
||||
this.retiredServers.clear();
|
||||
await this.serverManager.shutdown();
|
||||
}
|
||||
|
||||
private cleanupRetiredServers(): void {
|
||||
for (const server of Array.from(this.retiredServers)) {
|
||||
if (server.refCount === 0) {
|
||||
this.retiredServers.delete(server);
|
||||
void this.killServer(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async killServer(server: OpenCodeServerGeneration): Promise<void> {
|
||||
if (server.process.killed) {
|
||||
return;
|
||||
}
|
||||
const result = await terminateWithTreeKill(server.process, {
|
||||
gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
||||
forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||
onForceSignal: () => {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS },
|
||||
"OpenCode server did not exit after SIGTERM; sending SIGKILL",
|
||||
);
|
||||
},
|
||||
});
|
||||
if (result === "kill-timeout") {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS },
|
||||
"OpenCode server did not report exit after SIGKILL",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenCodeServerGeneration {
|
||||
process: ChildProcess;
|
||||
port: number;
|
||||
url: string;
|
||||
refCount: number;
|
||||
retired: boolean;
|
||||
}
|
||||
|
||||
export class OpenCodeAgentClient implements AgentClient {
|
||||
readonly provider = "opencode" as const;
|
||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||
|
||||
private readonly serverManager: OpenCodeServerManager;
|
||||
private readonly runtime: OpenCodeRuntime;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly modelContextWindows = new Map<string, number>();
|
||||
private readonly storageRoot: string;
|
||||
|
||||
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings, storageRoot?: string) {
|
||||
constructor(
|
||||
logger: Logger,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
storageRoot?: string,
|
||||
deps: OpenCodeAgentClientDeps = {},
|
||||
) {
|
||||
this.logger = logger.child({ module: "agent", provider: "opencode" });
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
|
||||
this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
|
||||
this.runtime =
|
||||
deps.runtime ??
|
||||
new ProductionOpenCodeRuntime(
|
||||
OpenCodeServerManager.getInstance(this.logger, runtimeSettings),
|
||||
);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -1229,9 +954,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.serverManager.acquire({ force: false });
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const { url } = acquisition.server;
|
||||
const client = createOpencodeClient({
|
||||
const client = this.runtime.createClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1285,9 +1010,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
...overrides,
|
||||
};
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.serverManager.acquire({ force: false });
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const { url } = acquisition.server;
|
||||
const client = createOpencodeClient({
|
||||
const client = this.runtime.createClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1310,9 +1035,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const acquisition = await this.serverManager.acquire({ force: options.force });
|
||||
const acquisition = await this.runtime.acquireServer({ force: options.force });
|
||||
const { url } = acquisition.server;
|
||||
const client = createOpencodeClient({
|
||||
const client = this.runtime.createClient({
|
||||
baseUrl: url,
|
||||
directory: options.cwd,
|
||||
});
|
||||
@@ -1373,10 +1098,10 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
|
||||
const acquisition = await this.serverManager.acquire({ force: options.force });
|
||||
const acquisition = await this.runtime.acquireServer({ force: options.force });
|
||||
const { url } = acquisition.server;
|
||||
const directory = options.cwd;
|
||||
const client = createOpencodeClient({ baseUrl: url, directory });
|
||||
const client = this.runtime.createClient({ baseUrl: url, directory });
|
||||
|
||||
try {
|
||||
const response = await withTimeout(
|
||||
@@ -1427,7 +1152,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
try {
|
||||
const { url } = await this.serverManager.ensureRunning();
|
||||
const { url } = await this.runtime.ensureServerRunning();
|
||||
serverStatus = `Running (${url})`;
|
||||
} catch (error) {
|
||||
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;
|
||||
|
||||
@@ -2,20 +2,14 @@ import { EventEmitter } from "node:events";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeServerManager } from "./opencode-agent.js";
|
||||
import { OpenCodeServerManager, type OpenCodeServerGeneration } from "./opencode/server-manager.js";
|
||||
|
||||
type FakeServerProcess = EventEmitter & {
|
||||
killed: boolean;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
interface FakeGeneration {
|
||||
process: FakeServerProcess;
|
||||
port: number;
|
||||
url: string;
|
||||
refCount: number;
|
||||
retired: boolean;
|
||||
}
|
||||
type FakeGeneration = OpenCodeServerGeneration & { process: FakeServerProcess };
|
||||
|
||||
describe("OpenCodeServerManager generations", () => {
|
||||
test("rotation creates a new current server without killing a referenced old server", async () => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type OpencodeClient,
|
||||
type OpencodeClientConfig,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
export interface OpenCodeServerAcquisition {
|
||||
server: { port: number; url: string };
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
export interface OpenCodeRuntime {
|
||||
acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
|
||||
ensureServerRunning(): Promise<{ port: number; url: string }>;
|
||||
createClient(options: { baseUrl: string; directory: string }): OpencodeClient;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createSdkOpenCodeClient(options: {
|
||||
baseUrl: string;
|
||||
directory: string;
|
||||
}): OpencodeClient {
|
||||
return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import net from "node:net";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { findExecutable } from "../../../../utils/executable.js";
|
||||
import { spawnProcess } from "../../../../utils/spawn.js";
|
||||
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
|
||||
import {
|
||||
createProviderEnvSpec,
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../../provider-launch-config.js";
|
||||
|
||||
const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
|
||||
export interface OpenCodeServerAcquisition {
|
||||
server: { port: number; url: string };
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
export interface OpenCodeServerManagerLike {
|
||||
ensureRunning(): Promise<{ port: number; url: string }>;
|
||||
acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
|
||||
}
|
||||
|
||||
export interface OpenCodeServerGeneration {
|
||||
process: ChildProcess;
|
||||
port: number;
|
||||
url: string;
|
||||
refCount: number;
|
||||
retired: boolean;
|
||||
}
|
||||
|
||||
export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
private static instance: OpenCodeServerManager | null = null;
|
||||
private static exitHandlerRegistered = false;
|
||||
private currentServer: OpenCodeServerGeneration | null = null;
|
||||
private retiredServers = new Set<OpenCodeServerGeneration>();
|
||||
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly runtimeSettingsKey: string;
|
||||
|
||||
private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
|
||||
this.logger = logger;
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {});
|
||||
}
|
||||
|
||||
static getInstance(
|
||||
logger: Logger,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
): OpenCodeServerManager {
|
||||
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
|
||||
if (!OpenCodeServerManager.instance) {
|
||||
OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
|
||||
OpenCodeServerManager.registerExitHandler();
|
||||
} else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) {
|
||||
logger.warn(
|
||||
{
|
||||
existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey,
|
||||
requestedRuntimeSettings: nextSettingsKey,
|
||||
},
|
||||
"OpenCode server manager already initialized with different runtime settings",
|
||||
);
|
||||
}
|
||||
return OpenCodeServerManager.instance;
|
||||
}
|
||||
|
||||
private static registerExitHandler(): void {
|
||||
if (OpenCodeServerManager.exitHandlerRegistered) {
|
||||
return;
|
||||
}
|
||||
OpenCodeServerManager.exitHandlerRegistered = true;
|
||||
|
||||
const cleanup = () => {
|
||||
const instance = OpenCodeServerManager.instance;
|
||||
void instance?.shutdown();
|
||||
};
|
||||
|
||||
process.on("exit", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
process.on("SIGINT", cleanup);
|
||||
}
|
||||
|
||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||
const acquisition = await this.acquire({ force: false });
|
||||
acquisition.release();
|
||||
return acquisition.server;
|
||||
}
|
||||
|
||||
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
|
||||
const server = options.force
|
||||
? await this.getForcedRefreshServer()
|
||||
: await this.getCurrentServer();
|
||||
server.refCount += 1;
|
||||
let released = false;
|
||||
return {
|
||||
server: { port: server.port, url: server.url },
|
||||
release: () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
server.refCount -= 1;
|
||||
this.cleanupRetiredServers();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> {
|
||||
if (this.forcedRefreshPromise) {
|
||||
return this.forcedRefreshPromise;
|
||||
}
|
||||
|
||||
this.forcedRefreshPromise = Promise.resolve()
|
||||
.then(async () => {
|
||||
await this.rotateCurrentServer();
|
||||
return this.getCurrentServer();
|
||||
})
|
||||
.finally(() => {
|
||||
this.forcedRefreshPromise = null;
|
||||
});
|
||||
return this.forcedRefreshPromise;
|
||||
}
|
||||
|
||||
private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
|
||||
if (this.startPromise) {
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
if (this.currentServer && !this.currentServer.process.killed) {
|
||||
return this.currentServer;
|
||||
}
|
||||
|
||||
this.startPromise = this.startServer();
|
||||
try {
|
||||
const result = await this.startPromise;
|
||||
if (!result.retired) {
|
||||
this.currentServer = result;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
this.startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async rotateCurrentServer(): Promise<void> {
|
||||
const existing = this.currentServer;
|
||||
if (existing) {
|
||||
existing.retired = true;
|
||||
this.retiredServers.add(existing);
|
||||
this.currentServer = null;
|
||||
this.cleanupRetiredServers();
|
||||
}
|
||||
if (this.startPromise) {
|
||||
const pending = await this.startPromise;
|
||||
pending.retired = true;
|
||||
this.retiredServers.add(pending);
|
||||
this.currentServer = null;
|
||||
this.cleanupRetiredServers();
|
||||
}
|
||||
}
|
||||
|
||||
private async startServer(): Promise<OpenCodeServerGeneration> {
|
||||
const port = await findAvailablePort();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
const launchPrefix = await resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveOpenCodeBinary,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const serverProcess = spawnProcess(
|
||||
launchPrefix.command,
|
||||
[...launchPrefix.args, "serve", "--port", String(port)],
|
||||
{
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
|
||||
},
|
||||
);
|
||||
|
||||
let started = false;
|
||||
let stderrBuffer = "";
|
||||
let stdoutBuffer = "";
|
||||
const STARTUP_BUFFER_CAP = 8192;
|
||||
const appendCapped = (current: string, chunk: string): string => {
|
||||
if (current.length >= STARTUP_BUFFER_CAP) {
|
||||
return current;
|
||||
}
|
||||
const remaining = STARTUP_BUFFER_CAP - current.length;
|
||||
return current + chunk.slice(0, remaining);
|
||||
};
|
||||
const buildStartupErrorMessage = (headline: string): string => {
|
||||
const sections = [headline];
|
||||
const stderrTrimmed = stderrBuffer.trim();
|
||||
if (stderrTrimmed.length > 0) {
|
||||
sections.push(`stderr: ${stderrTrimmed}`);
|
||||
}
|
||||
const stdoutTrimmed = stdoutBuffer.trim();
|
||||
if (stdoutTrimmed.length > 0) {
|
||||
sections.push(`stdout: ${stdoutTrimmed}`);
|
||||
}
|
||||
return sections.join("\n");
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
if (!started) {
|
||||
reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
serverProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdoutBuffer = appendCapped(stdoutBuffer, output);
|
||||
if (output.includes("listening on") && !started) {
|
||||
started = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
process: serverProcess,
|
||||
port,
|
||||
url,
|
||||
refCount: 0,
|
||||
retired: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stderrBuffer = appendCapped(stderrBuffer, output);
|
||||
this.logger.error({ stderr: output.trim() }, "OpenCode server stderr");
|
||||
});
|
||||
|
||||
serverProcess.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
const headline = error instanceof Error ? error.message : String(error);
|
||||
reject(new Error(buildStartupErrorMessage(headline)));
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
if (!started) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
|
||||
}
|
||||
if (this.currentServer?.process === serverProcess) {
|
||||
this.currentServer = null;
|
||||
}
|
||||
for (const retired of Array.from(this.retiredServers)) {
|
||||
if (retired.process === serverProcess) {
|
||||
this.retiredServers.delete(retired);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
const servers = [
|
||||
...(this.currentServer ? [this.currentServer] : []),
|
||||
...Array.from(this.retiredServers),
|
||||
];
|
||||
await Promise.all(servers.map((server) => this.killServer(server)));
|
||||
this.currentServer = null;
|
||||
this.retiredServers.clear();
|
||||
}
|
||||
|
||||
private cleanupRetiredServers(): void {
|
||||
for (const server of Array.from(this.retiredServers)) {
|
||||
if (server.refCount === 0) {
|
||||
this.retiredServers.delete(server);
|
||||
void this.killServer(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async killServer(server: OpenCodeServerGeneration): Promise<void> {
|
||||
if (server.process.killed) {
|
||||
return;
|
||||
}
|
||||
const result = await terminateWithTreeKill(server.process, {
|
||||
gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
||||
forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||
onForceSignal: () => {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS },
|
||||
"OpenCode server did not exit after SIGTERM; sending SIGKILL",
|
||||
);
|
||||
},
|
||||
});
|
||||
if (result === "kill-timeout") {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS },
|
||||
"OpenCode server did not report exit after SIGKILL",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOpenCodeBinary(): Promise<string> {
|
||||
const found = await findExecutable("opencode");
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
throw new Error(
|
||||
"OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.",
|
||||
);
|
||||
}
|
||||
|
||||
function findAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
server.close(() => {
|
||||
if (typeof address === "object" && address) {
|
||||
resolve(address.port);
|
||||
} else {
|
||||
reject(new Error("Failed to allocate port"));
|
||||
}
|
||||
});
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js";
|
||||
|
||||
export interface TestOpenCodeServerAcquisition {
|
||||
force: boolean;
|
||||
released: boolean;
|
||||
}
|
||||
|
||||
export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
readonly acquisitions: TestOpenCodeServerAcquisition[] = [];
|
||||
readonly server = { port: 1234, url: "http://127.0.0.1:1234" };
|
||||
ensureRunningCount = 0;
|
||||
|
||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||
this.ensureRunningCount += 1;
|
||||
return this.server;
|
||||
}
|
||||
|
||||
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
|
||||
const acquisition: TestOpenCodeServerAcquisition = {
|
||||
force: options.force,
|
||||
released: false,
|
||||
};
|
||||
this.acquisitions.push(acquisition);
|
||||
return {
|
||||
server: this.server,
|
||||
release: () => {
|
||||
acquisition.released = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createTestOpenCodeServerManager(): TestOpenCodeServerManager {
|
||||
return new TestOpenCodeServerManager();
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js";
|
||||
|
||||
interface OpenCodeResponse {
|
||||
data?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export class TestOpenCodeRuntime implements OpenCodeRuntime {
|
||||
readonly acquisitions: Array<{ force: boolean; releaseCount: number }> = [];
|
||||
readonly clientCreations: Array<{ baseUrl: string; directory: string }> = [];
|
||||
private readonly clients: TestOpenCodeClient[] = [];
|
||||
|
||||
server = { port: 1234, url: "http://127.0.0.1:1234" };
|
||||
|
||||
enqueueClient(client: TestOpenCodeClient): void {
|
||||
this.clients.push(client);
|
||||
}
|
||||
|
||||
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
|
||||
const acquisition = { force: options.force, releaseCount: 0 };
|
||||
this.acquisitions.push(acquisition);
|
||||
return {
|
||||
server: this.server,
|
||||
release: () => {
|
||||
acquisition.releaseCount += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async ensureServerRunning(): Promise<{ port: number; url: string }> {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
|
||||
this.clientCreations.push(options);
|
||||
const client = this.clients.shift() ?? new TestOpenCodeClient();
|
||||
return client.asSdkClient();
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class TestOpenCodeClient {
|
||||
readonly calls = {
|
||||
appAgents: [] as unknown[],
|
||||
commandList: [] as unknown[],
|
||||
eventSubscribe: [] as unknown[],
|
||||
permissionReply: [] as unknown[],
|
||||
providerList: [] as unknown[],
|
||||
questionReject: [] as unknown[],
|
||||
questionReply: [] as unknown[],
|
||||
sessionAbort: [] as unknown[],
|
||||
sessionCommand: [] as unknown[],
|
||||
sessionCreate: [] as unknown[],
|
||||
sessionDelete: [] as unknown[],
|
||||
sessionMessages: [] as unknown[],
|
||||
sessionPromptAsync: [] as unknown[],
|
||||
sessionSummarize: [] as unknown[],
|
||||
sessionUpdate: [] as unknown[],
|
||||
};
|
||||
|
||||
appAgentsResponse: OpenCodeResponse = { data: [] };
|
||||
commandListResponse: OpenCodeResponse = { data: [] };
|
||||
eventStream: AsyncIterable<unknown> = createEventStream([idleEvent()]);
|
||||
permissionReplyResponse: OpenCodeResponse = {};
|
||||
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
|
||||
providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null;
|
||||
questionRejectResponse: OpenCodeResponse = {};
|
||||
questionReplyResponse: OpenCodeResponse = {};
|
||||
sessionAbortResponse: OpenCodeResponse = {};
|
||||
sessionCommandError: unknown = null;
|
||||
sessionCommandResponse: OpenCodeResponse = {};
|
||||
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
|
||||
sessionDeleteResponse: OpenCodeResponse = {};
|
||||
sessionMessagesResponse: OpenCodeResponse = { data: [] };
|
||||
sessionPromptAsyncResponse: OpenCodeResponse = {};
|
||||
sessionSummarizeResponse: OpenCodeResponse = { data: {} };
|
||||
sessionUpdateResponse: OpenCodeResponse = {};
|
||||
|
||||
asSdkClient(): OpencodeClient {
|
||||
return {
|
||||
app: {
|
||||
agents: async (parameters: unknown) => {
|
||||
this.calls.appAgents.push(parameters);
|
||||
return this.appAgentsResponse;
|
||||
},
|
||||
},
|
||||
command: {
|
||||
list: async (parameters: unknown) => {
|
||||
this.calls.commandList.push(parameters);
|
||||
return this.commandListResponse;
|
||||
},
|
||||
},
|
||||
event: {
|
||||
subscribe: async (parameters: unknown, options: unknown) => {
|
||||
this.calls.eventSubscribe.push({ parameters, options });
|
||||
return { stream: this.eventStream };
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
add: async () => ({}),
|
||||
connect: async () => ({}),
|
||||
},
|
||||
permission: {
|
||||
reply: async (parameters: unknown) => {
|
||||
this.calls.permissionReply.push(parameters);
|
||||
return this.permissionReplyResponse;
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list: async (parameters: unknown) => {
|
||||
this.calls.providerList.push(parameters);
|
||||
return this.providerListImplementation
|
||||
? await this.providerListImplementation()
|
||||
: this.providerListResponse;
|
||||
},
|
||||
},
|
||||
question: {
|
||||
reject: async (parameters: unknown) => {
|
||||
this.calls.questionReject.push(parameters);
|
||||
return this.questionRejectResponse;
|
||||
},
|
||||
reply: async (parameters: unknown) => {
|
||||
this.calls.questionReply.push(parameters);
|
||||
return this.questionReplyResponse;
|
||||
},
|
||||
},
|
||||
session: {
|
||||
abort: async (parameters: unknown) => {
|
||||
this.calls.sessionAbort.push(parameters);
|
||||
return this.sessionAbortResponse;
|
||||
},
|
||||
command: async (parameters: unknown) => {
|
||||
this.calls.sessionCommand.push(parameters);
|
||||
if (this.sessionCommandError) {
|
||||
throw this.sessionCommandError;
|
||||
}
|
||||
return this.sessionCommandResponse;
|
||||
},
|
||||
create: async (parameters: unknown) => {
|
||||
this.calls.sessionCreate.push(parameters);
|
||||
return this.sessionCreateResponse;
|
||||
},
|
||||
delete: async (parameters: unknown) => {
|
||||
this.calls.sessionDelete.push(parameters);
|
||||
return this.sessionDeleteResponse;
|
||||
},
|
||||
messages: async (parameters: unknown) => {
|
||||
this.calls.sessionMessages.push(parameters);
|
||||
return this.sessionMessagesResponse;
|
||||
},
|
||||
promptAsync: async (parameters: unknown) => {
|
||||
this.calls.sessionPromptAsync.push(parameters);
|
||||
return this.sessionPromptAsyncResponse;
|
||||
},
|
||||
summarize: async (parameters: unknown) => {
|
||||
this.calls.sessionSummarize.push(parameters);
|
||||
return this.sessionSummarizeResponse;
|
||||
},
|
||||
update: async (parameters: unknown) => {
|
||||
this.calls.sessionUpdate.push(parameters);
|
||||
return this.sessionUpdateResponse;
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient;
|
||||
}
|
||||
}
|
||||
|
||||
export function createEventStream(events: unknown[]): AsyncGenerator<unknown> {
|
||||
return (async function* () {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function idleEvent(): unknown {
|
||||
return {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
@@ -9,6 +9,7 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import {
|
||||
PiDirectAgentClient,
|
||||
PiDirectAgentSession,
|
||||
type PiDirectSessionRuntimeAdapter,
|
||||
type PiDirectSessionAdapter,
|
||||
} from "./pi-direct-agent.js";
|
||||
|
||||
@@ -43,6 +44,16 @@ function createPiSession(prompt: () => Promise<void>): PiDirectSessionAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
function createPiRuntime(
|
||||
session: PiDirectSessionAdapter,
|
||||
dispose: () => Promise<void> = vi.fn(async () => undefined),
|
||||
): PiDirectSessionRuntimeAdapter {
|
||||
return {
|
||||
session,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
function createPiModel(provider: string, id: string): Model<Api> {
|
||||
return {
|
||||
provider,
|
||||
@@ -61,7 +72,7 @@ function createPiModel(provider: string, id: string): Model<Api> {
|
||||
describe("PiDirectAgentSession", () => {
|
||||
test("treats SDK request abort rejections as turn cancellations", async () => {
|
||||
const session = new PiDirectAgentSession(
|
||||
createPiSession(() => Promise.reject(new Error("Request was aborted."))),
|
||||
createPiRuntime(createPiSession(() => Promise.reject(new Error("Request was aborted.")))),
|
||||
{ find: vi.fn(), getAll: vi.fn(() => []) },
|
||||
{
|
||||
provider: "pi",
|
||||
@@ -87,7 +98,7 @@ describe("PiDirectAgentSession", () => {
|
||||
test("setModel creates a minimal model for new ids under a known provider", async () => {
|
||||
const sdkSession = createPiSession(async () => undefined);
|
||||
const session = new PiDirectAgentSession(
|
||||
sdkSession,
|
||||
createPiRuntime(sdkSession),
|
||||
{
|
||||
find: vi.fn(() => undefined),
|
||||
getAll: vi.fn(() => [createPiModel("openrouter", "known-model")]),
|
||||
@@ -196,12 +207,15 @@ export default function(pi) {
|
||||
const agentDir = join(testRoot, "agent");
|
||||
const cwd = join(testRoot, "project");
|
||||
const extensionDir = join(cwd, ".pi", "extensions");
|
||||
const shutdownMarker = join(testRoot, "shutdown.txt");
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
|
||||
await mkdir(extensionDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(extensionDir, "dummy-command.ts"),
|
||||
`
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
export default function(pi) {
|
||||
pi.registerProvider("paseo-dummy", {
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
@@ -224,6 +238,10 @@ export default function(pi) {
|
||||
description: "Dummy extension command",
|
||||
handler: async () => {}
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
writeFileSync(${JSON.stringify(shutdownMarker)}, "closed");
|
||||
});
|
||||
}
|
||||
`,
|
||||
"utf-8",
|
||||
@@ -237,6 +255,7 @@ export default function(pi) {
|
||||
cwd,
|
||||
model: "paseo-dummy/extension-model",
|
||||
});
|
||||
let closed = false;
|
||||
|
||||
try {
|
||||
await expect(session.listCommands()).resolves.toContainEqual({
|
||||
@@ -244,8 +263,13 @@ export default function(pi) {
|
||||
description: "Dummy extension command",
|
||||
argumentHint: "",
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
closed = true;
|
||||
await expect(readFile(shutdownMarker, "utf-8")).resolves.toBe("closed");
|
||||
} finally {
|
||||
if (!closed) {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (previousAgentDir === undefined) {
|
||||
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
createAgentSessionFromServices,
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
getAgentDir,
|
||||
type AgentSession as PiAgentSession,
|
||||
type AgentSessionEvent,
|
||||
type AgentSessionServices,
|
||||
type BashToolInput,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
type EditToolInput,
|
||||
type FindToolInput,
|
||||
type GrepToolInput,
|
||||
@@ -104,7 +107,7 @@ interface StartTurnResult {
|
||||
}
|
||||
|
||||
interface PiDirectSessionResult {
|
||||
session: PiAgentSession;
|
||||
runtime: PiDirectSessionRuntimeAdapter;
|
||||
modelRegistry: ModelRegistry;
|
||||
}
|
||||
|
||||
@@ -128,6 +131,11 @@ export type PiDirectSessionAdapter = Pick<
|
||||
| "thinkingLevel"
|
||||
>;
|
||||
|
||||
export interface PiDirectSessionRuntimeAdapter {
|
||||
readonly session: PiDirectSessionAdapter;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
type PiDirectModelRegistry = Pick<ModelRegistry, "find" | "getAll">;
|
||||
|
||||
interface PiToolResultObject {
|
||||
@@ -835,18 +843,23 @@ export class PiDirectAgentSession implements AgentSession {
|
||||
private latestUsage: AgentUsage | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly session: PiDirectSessionAdapter,
|
||||
private readonly runtime: PiDirectSessionRuntimeAdapter,
|
||||
private readonly modelRegistry: PiDirectModelRegistry,
|
||||
private readonly config: AgentSessionConfig,
|
||||
) {
|
||||
const session = this.session;
|
||||
this.lastKnownThinkingOptionId =
|
||||
normalizePiThinkingOption(config.thinkingOptionId) ?? session.thinkingLevel ?? null;
|
||||
|
||||
this.session.subscribe((event) => {
|
||||
session.subscribe((event) => {
|
||||
this.handleSessionEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
private get session(): PiDirectSessionAdapter {
|
||||
return this.runtime.session;
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
return this.session.sessionId;
|
||||
}
|
||||
@@ -1307,7 +1320,7 @@ export class PiDirectAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.session.dispose();
|
||||
await this.runtime.dispose();
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
@@ -1350,9 +1363,10 @@ export class PiDirectAgentClient implements AgentClient {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
}
|
||||
|
||||
private async getSessionServices(cwd: string): Promise<AgentSessionServices> {
|
||||
private async getSessionServices(cwd: string, agentDir?: string): Promise<AgentSessionServices> {
|
||||
return createAgentSessionServices({
|
||||
cwd,
|
||||
...(agentDir ? { agentDir } : {}),
|
||||
...(this.modelRegistry ? { modelRegistry: this.modelRegistry } : {}),
|
||||
});
|
||||
}
|
||||
@@ -1369,29 +1383,57 @@ export class PiDirectAgentClient implements AgentClient {
|
||||
return findModelInRegistry(registry, parsedReference);
|
||||
}
|
||||
|
||||
private async createSdkSession(config: AgentSessionConfig): Promise<PiDirectSessionResult> {
|
||||
const thinkingLevel =
|
||||
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL;
|
||||
const services = await this.getSessionServices(config.cwd);
|
||||
const model = this.resolveConfiguredModel(services.modelRegistry, config.model);
|
||||
private async createSdkRuntime(
|
||||
config: AgentSessionConfig,
|
||||
sessionManager: SessionManager,
|
||||
options: { defaultThinkingLevel?: ThinkingLevel } = {},
|
||||
): Promise<PiDirectSessionResult> {
|
||||
const createRuntime: CreateAgentSessionRuntimeFactory = async ({
|
||||
cwd,
|
||||
agentDir,
|
||||
sessionManager: runtimeSessionManager,
|
||||
sessionStartEvent,
|
||||
}) => {
|
||||
const thinkingLevel =
|
||||
normalizePiThinkingOption(config.thinkingOptionId) ?? options.defaultThinkingLevel;
|
||||
const services = await this.getSessionServices(cwd, agentDir);
|
||||
const model = this.resolveConfiguredModel(services.modelRegistry, config.model);
|
||||
|
||||
const { session } = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager: SessionManager.create(config.cwd),
|
||||
thinkingLevel,
|
||||
...(model ? { model } : {}),
|
||||
return {
|
||||
...(await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager: runtimeSessionManager,
|
||||
sessionStartEvent,
|
||||
...(thinkingLevel ? { thinkingLevel } : {}),
|
||||
...(model ? { model } : {}),
|
||||
})),
|
||||
services,
|
||||
diagnostics: services.diagnostics,
|
||||
};
|
||||
};
|
||||
|
||||
const runtime = await createAgentSessionRuntime(createRuntime, {
|
||||
cwd: sessionManager.getCwd(),
|
||||
agentDir: getAgentDir(),
|
||||
sessionManager,
|
||||
});
|
||||
await session.bindExtensions({});
|
||||
applySystemPrompt(session, config.systemPrompt);
|
||||
return { session, modelRegistry: services.modelRegistry };
|
||||
await runtime.session.bindExtensions({});
|
||||
applySystemPrompt(runtime.session, config.systemPrompt);
|
||||
return { runtime, modelRegistry: runtime.services.modelRegistry };
|
||||
}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const { session, modelRegistry } = await this.createSdkSession(config);
|
||||
return new PiDirectAgentSession(session, modelRegistry, config);
|
||||
const { runtime, modelRegistry } = await this.createSdkRuntime(
|
||||
config,
|
||||
SessionManager.create(config.cwd),
|
||||
{
|
||||
defaultThinkingLevel: DEFAULT_PI_THINKING_LEVEL,
|
||||
},
|
||||
);
|
||||
return new PiDirectAgentSession(runtime, modelRegistry, config);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
@@ -1404,9 +1446,10 @@ export class PiDirectAgentClient implements AgentClient {
|
||||
throw new Error("Pi resume requires a native session file handle");
|
||||
}
|
||||
|
||||
const resumedManager = SessionManager.open(sessionFile);
|
||||
const initialManager = SessionManager.open(sessionFile);
|
||||
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
|
||||
const cwd = overrides?.cwd ?? persistenceMetadata.cwd ?? resumedManager.getCwd();
|
||||
const cwd = overrides?.cwd ?? persistenceMetadata.cwd ?? initialManager.getCwd();
|
||||
const resumedManager = SessionManager.open(sessionFile, undefined, cwd);
|
||||
const mergedConfig: AgentSessionConfig = {
|
||||
provider: PI_PROVIDER,
|
||||
cwd,
|
||||
@@ -1425,18 +1468,8 @@ export class PiDirectAgentClient implements AgentClient {
|
||||
modeId: overrides?.modeId,
|
||||
};
|
||||
|
||||
const services = await this.getSessionServices(mergedConfig.cwd);
|
||||
const model = this.resolveConfiguredModel(services.modelRegistry, mergedConfig.model);
|
||||
const thinkingLevel = normalizePiThinkingOption(mergedConfig.thinkingOptionId);
|
||||
const { session } = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager: resumedManager,
|
||||
...(model ? { model } : {}),
|
||||
...(thinkingLevel ? { thinkingLevel } : {}),
|
||||
});
|
||||
await session.bindExtensions({});
|
||||
applySystemPrompt(session, mergedConfig.systemPrompt);
|
||||
return new PiDirectAgentSession(session, services.modelRegistry, mergedConfig);
|
||||
const { runtime, modelRegistry } = await this.createSdkRuntime(mergedConfig, resumedManager);
|
||||
return new PiDirectAgentSession(runtime, modelRegistry, mergedConfig);
|
||||
}
|
||||
|
||||
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CheckoutPrStatusSchema } from "../../shared/messages.js";
|
||||
import { normalizeCheckoutPrStatusPayload } from "./status-projection.js";
|
||||
|
||||
describe("checkout status projection", () => {
|
||||
test("includes repository identity fields on the PR status wire payload", () => {
|
||||
const payload = normalizeCheckoutPrStatusPayload({
|
||||
number: 123,
|
||||
repoOwner: "internal-owner",
|
||||
repoName: "internal-repo",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
title: "Ship PR pane",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/pr-pane",
|
||||
isMerged: false,
|
||||
isDraft: true,
|
||||
mergeable: "MERGEABLE",
|
||||
checks: [
|
||||
{
|
||||
name: "typecheck",
|
||||
status: "success",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/1",
|
||||
workflow: "CI",
|
||||
duration: "1m 20s",
|
||||
},
|
||||
],
|
||||
checksStatus: "success",
|
||||
reviewDecision: "approved",
|
||||
});
|
||||
|
||||
expect(payload).toHaveProperty("repoOwner", "internal-owner");
|
||||
expect(payload).toHaveProperty("repoName", "internal-repo");
|
||||
expect(payload).toHaveProperty("mergeable", "MERGEABLE");
|
||||
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
|
||||
});
|
||||
});
|
||||
134
packages/server/src/server/checkout/status-projection.ts
Normal file
134
packages/server/src/server/checkout/status-projection.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
CheckoutPrStatusResponse,
|
||||
CheckoutStatusResponse,
|
||||
SessionOutboundMessage,
|
||||
} from "../../shared/messages.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
||||
|
||||
type CheckoutPrStatusPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "checkout_pr_status_response" }
|
||||
>["payload"];
|
||||
type CheckoutPrStatusPayloadStatus = NonNullable<CheckoutPrStatusPayload["status"]>;
|
||||
|
||||
export function buildCheckoutStatusPayloadFromSnapshot({
|
||||
cwd,
|
||||
requestId,
|
||||
snapshot,
|
||||
}: {
|
||||
cwd: string;
|
||||
requestId: string;
|
||||
snapshot: WorkspaceGitRuntimeSnapshot;
|
||||
}): CheckoutStatusResponse["payload"] {
|
||||
if (!snapshot.git.isGit) {
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
repoRoot: null,
|
||||
currentBranch: null,
|
||||
isDirty: null,
|
||||
baseRef: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: false,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
error: null,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot.git.repoRoot === null || snapshot.git.isDirty === null) {
|
||||
throw new Error("Workspace git snapshot is missing required checkout status fields");
|
||||
}
|
||||
|
||||
if (snapshot.git.isPaseoOwnedWorktree) {
|
||||
if (snapshot.git.mainRepoRoot === null || snapshot.git.baseRef === null) {
|
||||
throw new Error("Workspace git snapshot is missing required worktree status fields");
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
currentBranch: snapshot.git.currentBranch ?? null,
|
||||
isDirty: snapshot.git.isDirty,
|
||||
baseRef: snapshot.git.baseRef,
|
||||
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
||||
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||
hasRemote: snapshot.git.hasRemote,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
error: null,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
currentBranch: snapshot.git.currentBranch ?? null,
|
||||
isDirty: snapshot.git.isDirty,
|
||||
baseRef: snapshot.git.baseRef ?? null,
|
||||
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
||||
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||
hasRemote: snapshot.git.hasRemote,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
error: null,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCheckoutPrStatusPayloadFromSnapshot({
|
||||
cwd,
|
||||
requestId,
|
||||
snapshot,
|
||||
}: {
|
||||
cwd: string;
|
||||
requestId: string;
|
||||
snapshot: WorkspaceGitRuntimeSnapshot;
|
||||
}): CheckoutPrStatusResponse["payload"] {
|
||||
return {
|
||||
cwd,
|
||||
status: normalizeCheckoutPrStatusPayload(snapshot.github.pullRequest),
|
||||
githubFeaturesEnabled: snapshot.github.featuresEnabled,
|
||||
error: snapshot.github.error
|
||||
? {
|
||||
code: "UNKNOWN",
|
||||
message: snapshot.github.error.message,
|
||||
}
|
||||
: null,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCheckoutPrStatusPayload(
|
||||
status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"],
|
||||
): CheckoutPrStatusPayloadStatus | null {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
number: status.number,
|
||||
url: status.url,
|
||||
title: status.title,
|
||||
state: status.state,
|
||||
repoOwner: status.repoOwner,
|
||||
repoName: status.repoName,
|
||||
baseRefName: status.baseRefName,
|
||||
headRefName: status.headRefName,
|
||||
isMerged: status.isMerged,
|
||||
isDraft: status.isDraft ?? false,
|
||||
mergeable: status.mergeable ?? "UNKNOWN",
|
||||
checks: status.checks ?? [],
|
||||
checksStatus: status.checksStatus,
|
||||
reviewDecision: status.reviewDecision,
|
||||
};
|
||||
}
|
||||
@@ -173,6 +173,44 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
expect(branchAfterSecond).toBe("renamed-from-agent-context");
|
||||
});
|
||||
|
||||
test("falls back to a numeric suffix when the desired branch name already exists", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
execFileSync("git", ["branch", "renamed-from-agent-context"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "renamed-from-agent-context-2"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "dazzling-yak",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
);
|
||||
|
||||
const result = await attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Build the agent context name" },
|
||||
generateBranchNameFromContext: async () => "renamed-from-agent-context",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
attempted: true,
|
||||
renamed: true,
|
||||
branchName: "renamed-from-agent-context-3",
|
||||
});
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("renamed-from-agent-context-3");
|
||||
});
|
||||
|
||||
test("renames the branch even when the app supplies a random placeholder slug", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type CreateWorktreeCoreInput,
|
||||
} from "./worktree-core.js";
|
||||
import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
|
||||
import { getCurrentBranch, renameCurrentBranch } from "../utils/checkout-git.js";
|
||||
import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
|
||||
import {
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
||||
readPaseoWorktreeMetadata,
|
||||
@@ -85,6 +85,7 @@ export async function attemptFirstAgentBranchAutoName(options: {
|
||||
}) => Promise<string | null>;
|
||||
getCurrentBranch?: typeof getCurrentBranch;
|
||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||
localBranchExists?: typeof localBranchExists;
|
||||
}): Promise<AttemptFirstAgentBranchAutoNameResult> {
|
||||
const firstAgentContext = options.firstAgentContext;
|
||||
if (!firstAgentContext || !buildAgentBranchNameSeed(firstAgentContext)) {
|
||||
@@ -129,15 +130,50 @@ export async function attemptFirstAgentBranchAutoName(options: {
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
const localBranchExistsImpl = options.localBranchExists ?? localBranchExists;
|
||||
const targetName = await findAvailableBranchName({
|
||||
cwd: options.cwd,
|
||||
desiredName: branchName,
|
||||
placeholderBranchName,
|
||||
localBranchExists: localBranchExistsImpl,
|
||||
});
|
||||
if (!targetName) {
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
const renameCurrentBranchImpl = options.renameCurrentBranch ?? renameCurrentBranch;
|
||||
const renamedBranch = await renameCurrentBranchImpl(options.cwd, branchName);
|
||||
const renamedBranch = await renameCurrentBranchImpl(options.cwd, targetName);
|
||||
return {
|
||||
attempted: true,
|
||||
renamed: true,
|
||||
branchName: renamedBranch.currentBranch ?? branchName,
|
||||
branchName: renamedBranch.currentBranch ?? targetName,
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_BRANCH_NAME_SUFFIX_ATTEMPTS = 50;
|
||||
|
||||
async function findAvailableBranchName(options: {
|
||||
cwd: string;
|
||||
desiredName: string;
|
||||
placeholderBranchName: string;
|
||||
localBranchExists: (cwd: string, branchName: string) => Promise<boolean>;
|
||||
}): Promise<string | null> {
|
||||
const { cwd, desiredName, placeholderBranchName } = options;
|
||||
if (!(await options.localBranchExists(cwd, desiredName))) {
|
||||
return desiredName;
|
||||
}
|
||||
for (let suffix = 2; suffix <= MAX_BRANCH_NAME_SUFFIX_ATTEMPTS; suffix++) {
|
||||
const candidate = `${desiredName}-${suffix}`;
|
||||
if (candidate === placeholderBranchName) {
|
||||
continue;
|
||||
}
|
||||
if (!(await options.localBranchExists(cwd, candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
|
||||
}): void {
|
||||
|
||||
@@ -27,6 +27,9 @@ describe("ScheduleService", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Drain pending background persists before deleting the dir to avoid
|
||||
// ENOTEMPTY races when AgentManager flushes a snapshot mid-cleanup.
|
||||
await agentStorage.flush();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveCreateAgentTitles } from "./session.js";
|
||||
import { resolveCreateAgentTitles } from "./agent/create-agent-title.js";
|
||||
|
||||
describe("resolveCreateAgentTitles", () => {
|
||||
test("derives a provisional title from prompt when explicit title is absent", () => {
|
||||
|
||||
@@ -6,21 +6,17 @@ import { join } from "path";
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { CheckoutPrStatusSchema } from "../shared/messages.js";
|
||||
import type { WorkspaceDescriptorPayload } from "../shared/messages.js";
|
||||
import { decodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js";
|
||||
import { normalizeCheckoutPrStatusPayload, Session } from "./session.js";
|
||||
import { Session } from "./session.js";
|
||||
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentTimelineItem,
|
||||
ListModesOptions,
|
||||
ListModelsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import type { ProviderDefinition } from "./agent/provider-registry.js";
|
||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import type { SessionOptions } from "./session.js";
|
||||
@@ -71,8 +67,6 @@ interface SessionHandlerInternals {
|
||||
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
|
||||
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
|
||||
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
|
||||
handleFetchRecentProviderSessions(params: unknown): Promise<unknown>;
|
||||
handleImportAgentRequest(params: unknown): Promise<unknown>;
|
||||
describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
|
||||
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
|
||||
handleValidateBranchRequest(params: unknown): Promise<unknown>;
|
||||
@@ -935,296 +929,6 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("session agent import", () => {
|
||||
test("filters already imported provider sessions by native handle", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const cwd = "/tmp/import-filter";
|
||||
const importedDescriptor: PersistedAgentDescriptor = {
|
||||
provider: "claude",
|
||||
sessionId: "provider-session-imported",
|
||||
cwd,
|
||||
title: "Already imported",
|
||||
lastActivityAt: new Date("2026-04-30T12:00:00.000Z"),
|
||||
persistence: {
|
||||
provider: "claude",
|
||||
sessionId: "provider-session-imported",
|
||||
nativeHandle: "provider-native-imported",
|
||||
},
|
||||
timeline: [{ type: "user_message", text: "already imported" }],
|
||||
};
|
||||
const visibleDescriptor: PersistedAgentDescriptor = {
|
||||
provider: "claude",
|
||||
sessionId: "provider-session-visible",
|
||||
cwd,
|
||||
title: "Still importable",
|
||||
lastActivityAt: new Date("2026-04-30T11:00:00.000Z"),
|
||||
persistence: {
|
||||
provider: "claude",
|
||||
sessionId: "provider-session-visible",
|
||||
nativeHandle: "provider-native-visible",
|
||||
},
|
||||
timeline: [{ type: "user_message", text: "still importable" }],
|
||||
};
|
||||
const agentManager = {
|
||||
listAgents: vi.fn(() => [
|
||||
{
|
||||
provider: "claude",
|
||||
persistence: {
|
||||
provider: "claude",
|
||||
sessionId: "stored-session-id",
|
||||
nativeHandle: "provider-native-imported",
|
||||
},
|
||||
} as ManagedAgent,
|
||||
]),
|
||||
listImportablePersistedAgents: vi
|
||||
.fn()
|
||||
.mockResolvedValue([importedDescriptor, visibleDescriptor]),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
};
|
||||
const agentStorage = {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const session = createSessionForTest({ messages });
|
||||
Object.assign(session, { agentManager, agentStorage });
|
||||
|
||||
await asSessionInternals(session).handleFetchRecentProviderSessions({
|
||||
type: "fetch_recent_provider_sessions_request",
|
||||
requestId: "recent-provider-sessions",
|
||||
cwd,
|
||||
providers: ["claude"],
|
||||
limit: 15,
|
||||
});
|
||||
|
||||
expect(agentManager.listImportablePersistedAgents).toHaveBeenCalledWith({
|
||||
limit: 200,
|
||||
providerFilter: new Set(["claude"]),
|
||||
cwd,
|
||||
});
|
||||
expect(messages).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "fetch_recent_provider_sessions_response",
|
||||
payload: expect.objectContaining({
|
||||
requestId: "recent-provider-sessions",
|
||||
filteredAlreadyImportedCount: 1,
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
providerId: "claude",
|
||||
providerHandleId: "provider-native-visible",
|
||||
title: "Still importable",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("imports by provider handle id and hydrates title metadata from the first user message", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const cwd = "/tmp/imported-agent";
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
{ type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" },
|
||||
{ type: "assistant_message", text: "I will inspect the provider listing." },
|
||||
];
|
||||
const snapshot = {
|
||||
id: "00000000-0000-4000-8000-000000000633",
|
||||
provider: "custom-codex",
|
||||
cwd,
|
||||
capabilities: TEST_CAPABILITIES,
|
||||
config: { provider: "custom-codex", cwd },
|
||||
createdAt: new Date("2026-04-30T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-30T00:00:00.000Z"),
|
||||
availableModes: [],
|
||||
currentModeId: null,
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
inFlightPermissionResponses: new Set(),
|
||||
pendingReplacement: false,
|
||||
persistence: {
|
||||
provider: "custom-codex",
|
||||
sessionId: "thread-imported",
|
||||
nativeHandle: "provider-thread-imported",
|
||||
metadata: { provider: "custom-codex", cwd },
|
||||
},
|
||||
historyPrimed: true,
|
||||
lastUserMessageAt: null,
|
||||
attention: { requiresAttention: false },
|
||||
foregroundTurnWaiters: new Set(),
|
||||
finalizedForegroundTurnIds: new Set(),
|
||||
unsubscribeSession: null,
|
||||
internal: false,
|
||||
labels: {},
|
||||
lifecycle: "closed",
|
||||
session: null,
|
||||
activeForegroundTurnId: null,
|
||||
} satisfies ManagedAgent;
|
||||
const agentManager = {
|
||||
listAgents: vi.fn(() => []),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
findPersistedAgent: vi.fn().mockResolvedValue({
|
||||
provider: "custom-codex",
|
||||
sessionId: "thread-imported",
|
||||
cwd,
|
||||
title: null,
|
||||
firstPromptPreview: "Trace recent provider sessions",
|
||||
lastPromptPreview: "Trace recent provider sessions",
|
||||
lastActivityAt: new Date("2026-04-30T00:00:00.000Z"),
|
||||
persistence: snapshot.persistence,
|
||||
}),
|
||||
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
|
||||
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
|
||||
getTimeline: vi.fn().mockReturnValue(timeline),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
notifyAgentState: vi.fn(),
|
||||
};
|
||||
const agentStorage = {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
const session = createSessionForTest({ messages });
|
||||
Object.assign(session, { agentManager, agentStorage });
|
||||
|
||||
await asSessionInternals(session).handleImportAgentRequest({
|
||||
type: "import_agent_request",
|
||||
providerId: "custom-codex",
|
||||
providerHandleId: "provider-thread-imported",
|
||||
cwd,
|
||||
requestId: "import-thread",
|
||||
});
|
||||
|
||||
expect(agentManager.findPersistedAgent).toHaveBeenCalledWith(
|
||||
"custom-codex",
|
||||
"provider-thread-imported",
|
||||
);
|
||||
expect(agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(snapshot.id);
|
||||
expect(agentManager.setTitle).toHaveBeenCalledWith(
|
||||
snapshot.id,
|
||||
"Trace recent provider sessions",
|
||||
);
|
||||
expect(agentMetadataMocks.scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd,
|
||||
initialPrompt: "Trace recent provider sessions\n\nkeep it tight",
|
||||
explicitTitle: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("sets a provisional title and schedules auto-title generation from the first hydrated user message", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const cwd = "/tmp/imported-agent";
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
{ type: "user_message", text: "Investigate flaky checkout status\n\ninclude logs" },
|
||||
{ type: "assistant_message", text: "I will inspect the checkout flow." },
|
||||
];
|
||||
const snapshot = {
|
||||
id: "00000000-0000-4000-8000-000000000632",
|
||||
provider: "codex",
|
||||
cwd,
|
||||
capabilities: TEST_CAPABILITIES,
|
||||
config: { provider: "codex", cwd },
|
||||
createdAt: new Date("2026-04-30T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-30T00:00:00.000Z"),
|
||||
availableModes: [],
|
||||
currentModeId: null,
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
inFlightPermissionResponses: new Set(),
|
||||
pendingReplacement: false,
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "thread-imported",
|
||||
nativeHandle: "thread-imported",
|
||||
metadata: { provider: "codex", cwd },
|
||||
},
|
||||
historyPrimed: true,
|
||||
lastUserMessageAt: null,
|
||||
attention: { requiresAttention: false },
|
||||
foregroundTurnWaiters: new Set(),
|
||||
finalizedForegroundTurnIds: new Set(),
|
||||
unsubscribeSession: null,
|
||||
internal: false,
|
||||
labels: {},
|
||||
lifecycle: "closed",
|
||||
session: null,
|
||||
activeForegroundTurnId: null,
|
||||
} satisfies ManagedAgent;
|
||||
const agentManager = {
|
||||
listAgents: vi.fn(() => []),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
findPersistedAgent: vi.fn().mockResolvedValue(null),
|
||||
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
|
||||
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
|
||||
getTimeline: vi.fn().mockReturnValue(timeline),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
notifyAgentState: vi.fn(),
|
||||
};
|
||||
const agentStorage = {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
const session = createSessionForTest({ messages });
|
||||
Object.assign(session, { agentManager, agentStorage });
|
||||
|
||||
await asSessionInternals(session).handleImportAgentRequest({
|
||||
type: "import_agent_request",
|
||||
provider: "codex",
|
||||
sessionId: "thread-imported",
|
||||
cwd,
|
||||
requestId: "import-thread",
|
||||
});
|
||||
|
||||
expect(agentManager.setTitle).toHaveBeenCalledWith(
|
||||
snapshot.id,
|
||||
"Investigate flaky checkout status",
|
||||
);
|
||||
expect(agentMetadataMocks.scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd,
|
||||
initialPrompt: "Investigate flaky checkout status\n\ninclude logs",
|
||||
explicitTitle: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session PR status payload normalization", () => {
|
||||
test("includes repository identity fields on the wire", () => {
|
||||
const payload = normalizeCheckoutPrStatusPayload({
|
||||
number: 123,
|
||||
repoOwner: "internal-owner",
|
||||
repoName: "internal-repo",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
title: "Ship PR pane",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/pr-pane",
|
||||
isMerged: false,
|
||||
isDraft: true,
|
||||
mergeable: "MERGEABLE",
|
||||
checks: [
|
||||
{
|
||||
name: "typecheck",
|
||||
status: "success",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/1",
|
||||
workflow: "CI",
|
||||
duration: "1m 20s",
|
||||
},
|
||||
],
|
||||
checksStatus: "success",
|
||||
reviewDecision: "approved",
|
||||
});
|
||||
|
||||
expect(payload).toHaveProperty("repoOwner", "internal-owner");
|
||||
expect(payload).toHaveProperty("repoName", "internal-repo");
|
||||
expect(payload).toHaveProperty("mergeable", "MERGEABLE");
|
||||
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session provider refresh cwd routing", () => {
|
||||
test("routes no-cwd provider snapshot refreshes through settings refresh", async () => {
|
||||
const {
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
type FileExplorerRequest,
|
||||
type FileDownloadTokenRequest,
|
||||
type GitSetupOptions,
|
||||
type CheckoutPrStatusResponse,
|
||||
type CheckoutStatusResponse,
|
||||
type StartWorkspaceScriptRequest,
|
||||
type CloseItemsRequest,
|
||||
type SubscribeCheckoutDiffRequest,
|
||||
@@ -83,6 +81,7 @@ import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-sto
|
||||
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
|
||||
import { getErrorMessage, getErrorMessageOr } from "../shared/error-utils.js";
|
||||
import { getAgentStatusPriority } from "../shared/agent-state-bucket.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
|
||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||
@@ -98,13 +97,13 @@ import type {
|
||||
ManagedAgent,
|
||||
} from "./agent/agent-manager.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
||||
import { resolveCreateAgentTitles } from "./agent/create-agent-title.js";
|
||||
import {
|
||||
buildStoredAgentPayload,
|
||||
resolveEffectiveThinkingOptionId,
|
||||
resolveStoredAgentPayloadUpdatedAt,
|
||||
toAgentPayload,
|
||||
} from "./agent/agent-projections.js";
|
||||
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent/agent-title-limits.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
@@ -129,13 +128,13 @@ import type {
|
||||
AgentRunOptions,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
ProviderSnapshotEntry,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import {
|
||||
ImportSessionsRequestError,
|
||||
importProviderSession,
|
||||
listImportableProviderSessions,
|
||||
normalizeImportAgentRequest,
|
||||
} from "./agent/import-sessions.js";
|
||||
@@ -192,6 +191,10 @@ import { expandTilde } from "../utils/path.js";
|
||||
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
||||
import { toCheckoutError } from "./checkout-git-utils.js";
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import {
|
||||
buildCheckoutPrStatusPayloadFromSnapshot,
|
||||
buildCheckoutStatusPayloadFromSnapshot,
|
||||
} from "./checkout/status-projection.js";
|
||||
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
|
||||
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
|
||||
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
|
||||
@@ -236,7 +239,6 @@ import {
|
||||
} from "./worktree-session.js";
|
||||
import { toWorktreeWireError } from "./worktree-errors.js";
|
||||
|
||||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
|
||||
|
||||
interface ResolveKnownProjectRootForConfigInput {
|
||||
@@ -393,53 +395,6 @@ function beginAgentDeleteIfSupported(agentStorage: AgentStorage, agentId: string
|
||||
}
|
||||
}
|
||||
|
||||
function deriveInitialAgentTitle(prompt: string): string | null {
|
||||
const firstContentLine = prompt
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
if (!firstContentLine) {
|
||||
return null;
|
||||
}
|
||||
const normalized = firstContentLine.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const clamped = normalized.slice(0, MAX_INITIAL_AGENT_TITLE_CHARS).trim();
|
||||
return clamped.length > 0 ? clamped : null;
|
||||
}
|
||||
|
||||
export function resolveCreateAgentTitles(options: {
|
||||
configTitle?: string | null;
|
||||
initialPrompt?: string | null;
|
||||
}): { explicitTitle: string | null; provisionalTitle: string | null } {
|
||||
const explicitTitle =
|
||||
typeof options.configTitle === "string" && options.configTitle.trim().length > 0
|
||||
? options.configTitle.trim()
|
||||
: null;
|
||||
const trimmedPrompt = options.initialPrompt?.trim();
|
||||
const provisionalTitle =
|
||||
explicitTitle ?? (trimmedPrompt ? deriveInitialAgentTitle(trimmedPrompt) : null);
|
||||
|
||||
return {
|
||||
explicitTitle,
|
||||
provisionalTitle,
|
||||
};
|
||||
}
|
||||
|
||||
function getFirstUserMessageText(timeline: readonly AgentTimelineItem[]): string | null {
|
||||
for (const item of timeline) {
|
||||
if (item.type !== "user_message") {
|
||||
continue;
|
||||
}
|
||||
const text = item.text.trim();
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const FETCH_AGENTS_SORT_KEYS = ["status_priority", "created_at", "updated_at", "title"] as const;
|
||||
|
||||
export function resolveWaitForFinishError(options: {
|
||||
@@ -636,11 +591,6 @@ export type SessionLifecycleIntent =
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type CheckoutPrStatusPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "checkout_pr_status_response" }
|
||||
>["payload"];
|
||||
type CheckoutPrStatusPayloadStatus = NonNullable<CheckoutPrStatusPayload["status"]>;
|
||||
type PullRequestTimelinePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "pull_request_timeline_response" }
|
||||
@@ -674,45 +624,6 @@ class VoiceFeatureUnavailableError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface BuildImportPersistenceHandleInput {
|
||||
provider: AgentProvider;
|
||||
providerHandleId: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
function buildImportPersistenceHandle(
|
||||
input: BuildImportPersistenceHandleInput,
|
||||
): AgentPersistenceHandle {
|
||||
const cwd = input.cwd ?? process.cwd();
|
||||
return {
|
||||
provider: input.provider,
|
||||
sessionId: input.providerHandleId,
|
||||
nativeHandle: input.providerHandleId,
|
||||
metadata: {
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyImportCwdOverride(
|
||||
handle: AgentPersistenceHandle,
|
||||
cwd: string | undefined,
|
||||
): AgentPersistenceHandle {
|
||||
if (!cwd) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
return {
|
||||
...handle,
|
||||
metadata: {
|
||||
...handle.metadata,
|
||||
provider: handle.provider,
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function convertPCMToWavBuffer(
|
||||
pcmBuffer: Buffer,
|
||||
sampleRate: number,
|
||||
@@ -3201,39 +3112,22 @@ export class Session {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { provider, providerHandleId, cwd, labels, requestId } = normalized;
|
||||
const { provider, providerHandleId, requestId } = normalized;
|
||||
this.sessionLogger.info(
|
||||
{ providerHandleId, provider },
|
||||
`Importing agent ${providerHandleId} (${provider})`,
|
||||
);
|
||||
|
||||
try {
|
||||
const descriptor = await this.agentManager.findPersistedAgent(provider, providerHandleId);
|
||||
if (!descriptor && provider === "opencode" && !cwd) {
|
||||
throw new Error(
|
||||
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
|
||||
);
|
||||
}
|
||||
|
||||
const handle = descriptor
|
||||
? applyImportCwdOverride(descriptor.persistence, cwd)
|
||||
: buildImportPersistenceHandle({ provider, providerHandleId, cwd });
|
||||
const overrides = cwd ? ({ cwd } satisfies Partial<AgentSessionConfig>) : undefined;
|
||||
|
||||
await this.unarchiveAgentByHandle(handle);
|
||||
const snapshot = await this.agentManager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
overrides,
|
||||
undefined,
|
||||
{
|
||||
labels,
|
||||
},
|
||||
);
|
||||
await unarchiveAgentState(this.agentStorage, this.agentManager, snapshot.id);
|
||||
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
|
||||
await this.applyImportedAgentTitle(snapshot);
|
||||
const { snapshot, timelineSize } = await importProviderSession({
|
||||
request: normalized,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
paseoHome: this.paseoHome,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
|
||||
const agentPayload = await this.buildAgentPayload(snapshot);
|
||||
this.emit({
|
||||
type: "status",
|
||||
@@ -3268,32 +3162,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async applyImportedAgentTitle(snapshot: ManagedAgent): Promise<void> {
|
||||
const initialPrompt = getFirstUserMessageText(this.agentManager.getTimeline(snapshot.id));
|
||||
if (!initialPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
|
||||
configTitle: snapshot.config.title,
|
||||
initialPrompt,
|
||||
});
|
||||
if (!explicitTitle && provisionalTitle) {
|
||||
await this.agentManager.setTitle(snapshot.id, provisionalTitle);
|
||||
}
|
||||
|
||||
scheduleAgentMetadataGeneration({
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd: snapshot.cwd,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
initialPrompt,
|
||||
explicitTitle,
|
||||
paseoHome: this.paseoHome,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRefreshAgentRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "refresh_agent_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -4489,7 +4357,7 @@ export class Session {
|
||||
const snapshot = await this.workspaceGitService.getSnapshot(resolvedCwd);
|
||||
this.emit({
|
||||
type: "checkout_status_response",
|
||||
payload: this.buildCheckoutStatusPayloadFromSnapshot({
|
||||
payload: buildCheckoutStatusPayloadFromSnapshot({
|
||||
cwd,
|
||||
requestId,
|
||||
snapshot,
|
||||
@@ -4838,116 +4706,18 @@ export class Session {
|
||||
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
|
||||
}
|
||||
|
||||
private buildCheckoutStatusPayloadFromSnapshot({
|
||||
cwd,
|
||||
requestId,
|
||||
snapshot,
|
||||
}: {
|
||||
cwd: string;
|
||||
requestId: string;
|
||||
snapshot: WorkspaceGitRuntimeSnapshot;
|
||||
}): CheckoutStatusResponse["payload"] {
|
||||
if (!snapshot.git.isGit) {
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
repoRoot: null,
|
||||
currentBranch: null,
|
||||
isDirty: null,
|
||||
baseRef: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: false,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
error: null,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot.git.repoRoot === null || snapshot.git.isDirty === null) {
|
||||
throw new Error("Workspace git snapshot is missing required checkout status fields");
|
||||
}
|
||||
|
||||
if (snapshot.git.isPaseoOwnedWorktree) {
|
||||
if (snapshot.git.mainRepoRoot === null || snapshot.git.baseRef === null) {
|
||||
throw new Error("Workspace git snapshot is missing required worktree status fields");
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
currentBranch: snapshot.git.currentBranch ?? null,
|
||||
isDirty: snapshot.git.isDirty,
|
||||
baseRef: snapshot.git.baseRef,
|
||||
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
||||
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||
hasRemote: snapshot.git.hasRemote,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
error: null,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
currentBranch: snapshot.git.currentBranch ?? null,
|
||||
isDirty: snapshot.git.isDirty,
|
||||
baseRef: snapshot.git.baseRef ?? null,
|
||||
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
||||
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||
hasRemote: snapshot.git.hasRemote,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
error: null,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
private buildCheckoutPrStatusPayloadFromSnapshot({
|
||||
cwd,
|
||||
requestId,
|
||||
snapshot,
|
||||
}: {
|
||||
cwd: string;
|
||||
requestId: string;
|
||||
snapshot: WorkspaceGitRuntimeSnapshot;
|
||||
}): CheckoutPrStatusResponse["payload"] {
|
||||
return {
|
||||
cwd,
|
||||
status: normalizeCheckoutPrStatusPayload(snapshot.github.pullRequest),
|
||||
githubFeaturesEnabled: snapshot.github.featuresEnabled,
|
||||
error: snapshot.github.error
|
||||
? {
|
||||
code: "UNKNOWN",
|
||||
message: snapshot.github.error.message,
|
||||
}
|
||||
: null,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
private emitCheckoutStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void {
|
||||
try {
|
||||
const requestId = `subscription:${cwd}`;
|
||||
this.emit({
|
||||
type: "checkout_status_update",
|
||||
payload: {
|
||||
...this.buildCheckoutStatusPayloadFromSnapshot({
|
||||
...buildCheckoutStatusPayloadFromSnapshot({
|
||||
cwd,
|
||||
requestId,
|
||||
snapshot,
|
||||
}),
|
||||
prStatus: this.buildCheckoutPrStatusPayloadFromSnapshot({
|
||||
prStatus: buildCheckoutPrStatusPayloadFromSnapshot({
|
||||
cwd,
|
||||
requestId,
|
||||
snapshot,
|
||||
@@ -5382,18 +5152,11 @@ export class Session {
|
||||
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
||||
this.emit({
|
||||
type: "checkout_pr_status_response",
|
||||
payload: {
|
||||
payload: buildCheckoutPrStatusPayloadFromSnapshot({
|
||||
cwd,
|
||||
status: normalizeCheckoutPrStatusPayload(snapshot.github.pullRequest),
|
||||
githubFeaturesEnabled: snapshot.github.featuresEnabled,
|
||||
error: snapshot.github.error
|
||||
? {
|
||||
code: "UNKNOWN",
|
||||
message: snapshot.github.error.message,
|
||||
}
|
||||
: null,
|
||||
requestId,
|
||||
},
|
||||
snapshot,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
@@ -5862,24 +5625,6 @@ export class Session {
|
||||
return this.isProviderVisibleToClient(payload.provider) ? payload : null;
|
||||
}
|
||||
|
||||
private getStatusPriority(agent: AgentSnapshotPayload): number {
|
||||
const attentionReason = agent.attentionReason ?? null;
|
||||
const hasPendingPermission = (agent.pendingPermissions?.length ?? 0) > 0;
|
||||
if (hasPendingPermission || attentionReason === "permission") {
|
||||
return 0;
|
||||
}
|
||||
if (agent.status === "error" || attentionReason === "error") {
|
||||
return 1;
|
||||
}
|
||||
if (agent.status === "running") {
|
||||
return 2;
|
||||
}
|
||||
if (agent.status === "initializing") {
|
||||
return 3;
|
||||
}
|
||||
return 4;
|
||||
}
|
||||
|
||||
private async buildActiveProjectPlacementsByWorkspaceCwd(): Promise<
|
||||
Map<string, ProjectPlacementPayload>
|
||||
> {
|
||||
@@ -6043,7 +5788,12 @@ export class Session {
|
||||
getSortValue: (agent, key): number | string => {
|
||||
switch (key) {
|
||||
case "status_priority":
|
||||
return this.getStatusPriority(agent);
|
||||
return getAgentStatusPriority({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions?.length ?? 0,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
});
|
||||
case "created_at":
|
||||
return Date.parse(agent.createdAt);
|
||||
case "updated_at":
|
||||
@@ -8895,30 +8645,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCheckoutPrStatusPayload(
|
||||
status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"],
|
||||
): CheckoutPrStatusPayloadStatus | null {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
number: status.number,
|
||||
url: status.url,
|
||||
title: status.title,
|
||||
state: status.state,
|
||||
repoOwner: status.repoOwner,
|
||||
repoName: status.repoName,
|
||||
baseRefName: status.baseRefName,
|
||||
headRefName: status.headRefName,
|
||||
isMerged: status.isMerged,
|
||||
isDraft: status.isDraft ?? false,
|
||||
mergeable: status.mergeable ?? "UNKNOWN",
|
||||
checks: status.checks ?? [],
|
||||
checksStatus: status.checksStatus,
|
||||
reviewDecision: status.reviewDecision,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidPullRequestTimelineIdentity(options: {
|
||||
prNumber: number;
|
||||
repoOwner: string;
|
||||
|
||||
@@ -6,8 +6,11 @@ import type {
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
WorkspaceDescriptorPayload,
|
||||
WorkspaceStateBucket,
|
||||
} from "./messages.js";
|
||||
import {
|
||||
deriveAgentStateBucket,
|
||||
getWorkspaceStateBucketPriority,
|
||||
} from "../shared/agent-state-bucket.js";
|
||||
import { SortablePager } from "./pagination/sortable-pager.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
@@ -90,14 +93,6 @@ export function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspace
|
||||
export class WorkspaceDirectory {
|
||||
private readonly archivingByWorkspaceId = new Map<string, string>();
|
||||
|
||||
private readonly workspaceStatePriority: Record<WorkspaceStateBucket, number> = {
|
||||
needs_input: 0,
|
||||
failed: 1,
|
||||
running: 2,
|
||||
attention: 3,
|
||||
done: 4,
|
||||
};
|
||||
|
||||
private readonly pager = new SortablePager<
|
||||
WorkspaceDescriptorPayload,
|
||||
FetchWorkspacesRequestSort["key"]
|
||||
@@ -109,7 +104,7 @@ export class WorkspaceDirectory {
|
||||
getSortValue: (workspace, key) => {
|
||||
switch (key) {
|
||||
case "status_priority":
|
||||
return this.workspaceStatePriority[workspace.status];
|
||||
return getWorkspaceStateBucketPriority(workspace.status);
|
||||
case "activity_at":
|
||||
return workspace.activityAt ? Date.parse(workspace.activityAt) : null;
|
||||
case "name":
|
||||
@@ -202,8 +197,15 @@ export class WorkspaceDirectory {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bucket = this.deriveStateBucket(agent);
|
||||
if (this.workspaceStatePriority[bucket] < this.workspaceStatePriority[existing.status]) {
|
||||
const bucket = deriveAgentStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions?.length ?? 0,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
});
|
||||
if (
|
||||
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
|
||||
) {
|
||||
existing.status = bucket;
|
||||
}
|
||||
}
|
||||
@@ -329,21 +331,4 @@ export class WorkspaceDirectory {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private deriveStateBucket(agent: AgentSnapshotPayload): WorkspaceStateBucket {
|
||||
const pendingPermissionCount = agent.pendingPermissions?.length ?? 0;
|
||||
if (pendingPermissionCount > 0 || agent.attentionReason === "permission") {
|
||||
return "needs_input";
|
||||
}
|
||||
if (agent.status === "error" || agent.attentionReason === "error") {
|
||||
return "failed";
|
||||
}
|
||||
if (agent.status === "running") {
|
||||
return "running";
|
||||
}
|
||||
if (agent.requiresAttention) {
|
||||
return "attention";
|
||||
}
|
||||
return "done";
|
||||
}
|
||||
}
|
||||
|
||||
92
packages/server/src/shared/agent-state-bucket.test.ts
Normal file
92
packages/server/src/shared/agent-state-bucket.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveAgentStateBucket,
|
||||
getAgentStatusPriority,
|
||||
getWorkspaceStateBucketPriority,
|
||||
} from "./agent-state-bucket.js";
|
||||
|
||||
describe("deriveAgentStateBucket", () => {
|
||||
it("prioritizes pending permissions as needs_input", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "idle",
|
||||
pendingPermissionCount: 1,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
}),
|
||||
).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("keeps legacy permission attention in needs_input", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "idle",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: true,
|
||||
attentionReason: "permission",
|
||||
}),
|
||||
).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("prioritizes error attention before running status", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "running",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
}),
|
||||
).toBe("failed");
|
||||
});
|
||||
|
||||
it("treats unread finished agents as attention", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "idle",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
}),
|
||||
).toBe("attention");
|
||||
});
|
||||
|
||||
it("treats initializing agents as done for workspace buckets", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "initializing",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
}),
|
||||
).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorkspaceStateBucketPriority", () => {
|
||||
it("orders active buckets before done", () => {
|
||||
expect(
|
||||
["done", "attention", "running", "failed", "needs_input"].sort(
|
||||
(left, right) =>
|
||||
getWorkspaceStateBucketPriority(left) - getWorkspaceStateBucketPriority(right),
|
||||
),
|
||||
).toEqual(["needs_input", "failed", "running", "attention", "done"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentStatusPriority", () => {
|
||||
it("keeps initializing agents ahead of completed agents in agent lists", () => {
|
||||
expect(getAgentStatusPriority({ status: "initializing" })).toBeLessThan(
|
||||
getAgentStatusPriority({ status: "idle" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("prioritizes pending permissions before errors and running agents", () => {
|
||||
const permission = getAgentStatusPriority({ status: "running", pendingPermissionCount: 1 });
|
||||
expect(permission).toBeLessThan(
|
||||
getAgentStatusPriority({ status: "error", pendingPermissionCount: 0 }),
|
||||
);
|
||||
expect(permission).toBeLessThan(
|
||||
getAgentStatusPriority({ status: "running", pendingPermissionCount: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
55
packages/server/src/shared/agent-state-bucket.ts
Normal file
55
packages/server/src/shared/agent-state-bucket.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { AgentLifecycleStatus } from "./agent-lifecycle.js";
|
||||
import type { WorkspaceStateBucket } from "./messages.js";
|
||||
|
||||
export type AgentAttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||
|
||||
export interface AgentStateBucketInput {
|
||||
status: AgentLifecycleStatus;
|
||||
pendingPermissionCount?: number;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: AgentAttentionReason;
|
||||
}
|
||||
|
||||
const WORKSPACE_STATE_BUCKET_PRIORITY = {
|
||||
needs_input: 0,
|
||||
failed: 1,
|
||||
running: 2,
|
||||
attention: 3,
|
||||
done: 4,
|
||||
} as const satisfies Record<WorkspaceStateBucket, number>;
|
||||
|
||||
export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceStateBucket {
|
||||
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
|
||||
return "needs_input";
|
||||
}
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
return "failed";
|
||||
}
|
||||
if (input.status === "running") {
|
||||
return "running";
|
||||
}
|
||||
if (input.requiresAttention) {
|
||||
return "attention";
|
||||
}
|
||||
return "done";
|
||||
}
|
||||
|
||||
export function getWorkspaceStateBucketPriority(bucket: WorkspaceStateBucket): number {
|
||||
return WORKSPACE_STATE_BUCKET_PRIORITY[bucket];
|
||||
}
|
||||
|
||||
export function getAgentStatusPriority(input: AgentStateBucketInput): number {
|
||||
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
|
||||
return 0;
|
||||
}
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
return 1;
|
||||
}
|
||||
if (input.status === "running") {
|
||||
return 2;
|
||||
}
|
||||
if (input.status === "initializing") {
|
||||
return 3;
|
||||
}
|
||||
return 4;
|
||||
}
|
||||
86
packages/server/src/shared/git-remote.ts
Normal file
86
packages/server/src/shared/git-remote.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]);
|
||||
|
||||
const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
|
||||
"https:": "https",
|
||||
"http:": "http",
|
||||
"ssh:": "ssh",
|
||||
};
|
||||
|
||||
export interface GitRemoteLocation {
|
||||
transport: "scp" | "ssh" | "http" | "https";
|
||||
host: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface GitHubRemoteIdentity {
|
||||
owner: string;
|
||||
name: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null {
|
||||
const location = parseGitRemoteLocation(remoteUrl);
|
||||
if (!location || !isGitHubHost(location.host)) return null;
|
||||
return parseGitHubRemoteIdentity(location.path);
|
||||
}
|
||||
|
||||
export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null {
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
|
||||
if (scpLike) {
|
||||
const host = normalizeHost(scpLike[1] ?? "");
|
||||
const path = normalizeRemotePath(scpLike[2] ?? "");
|
||||
if (!isValidRemoteHost(host) || !path) return null;
|
||||
return { transport: "scp", host, path };
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transport = TRANSPORT_BY_PROTOCOL[parsed.protocol.toLowerCase()];
|
||||
if (!transport) return null;
|
||||
|
||||
const host = normalizeHost(parsed.hostname);
|
||||
let path: string;
|
||||
try {
|
||||
path = decodeURIComponent(parsed.pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const normalizedPath = normalizeRemotePath(path);
|
||||
if (!isValidRemoteHost(host) || !normalizedPath) return null;
|
||||
|
||||
return { transport, host, path: normalizedPath };
|
||||
}
|
||||
|
||||
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
if (segments.length !== 2) return null;
|
||||
const [owner, name] = segments;
|
||||
if (!owner || !name) return null;
|
||||
return { owner, name, repo: `${owner}/${name}` };
|
||||
}
|
||||
|
||||
export function isGitHubHost(host: string): boolean {
|
||||
return GITHUB_HOSTS.has(host);
|
||||
}
|
||||
|
||||
export function normalizeHost(host: string): string {
|
||||
return host.trim().replace(/\.+$/u, "").toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeRemotePath(path: string): string | null {
|
||||
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
|
||||
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function isValidRemoteHost(host: string): boolean {
|
||||
return /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(host);
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { Task, TaskStore } from "./types.js";
|
||||
|
||||
function sortByPriorityThenCreated(a: Task, b: Task): number {
|
||||
if (a.priority !== undefined && b.priority === undefined) return -1;
|
||||
if (a.priority === undefined && b.priority !== undefined) return 1;
|
||||
if (a.priority !== undefined && b.priority !== undefined) {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
}
|
||||
return a.created.localeCompare(b.created);
|
||||
}
|
||||
import {
|
||||
buildChildrenMap,
|
||||
getTasksById,
|
||||
isTaskExecutableInOrder,
|
||||
loadScopedTaskGraph,
|
||||
sortByPriorityThenCreated,
|
||||
} from "./task-graph.js";
|
||||
|
||||
export interface ExecutionOrderResult {
|
||||
/** Tasks in execution order (done first, then pending) */
|
||||
@@ -30,55 +28,20 @@ export async function computeExecutionOrder(
|
||||
store: TaskStore,
|
||||
scopeId?: string,
|
||||
): Promise<ExecutionOrderResult> {
|
||||
const allTasks = await store.list();
|
||||
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
||||
const graph = await loadScopedTaskGraph(store, scopeId);
|
||||
|
||||
// Get scoped tasks
|
||||
let candidates: Task[];
|
||||
if (scopeId) {
|
||||
const scopeTask = await store.get(scopeId);
|
||||
const descendants = await store.getDescendants(scopeId);
|
||||
candidates = scopeTask ? [scopeTask, ...descendants] : descendants;
|
||||
} else {
|
||||
candidates = allTasks;
|
||||
}
|
||||
|
||||
// Build children map
|
||||
const childrenMap = new Map<string, Task[]>();
|
||||
for (const t of allTasks) {
|
||||
if (t.parentId) {
|
||||
const siblings = childrenMap.get(t.parentId) ?? [];
|
||||
siblings.push(t);
|
||||
childrenMap.set(t.parentId, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
const candidateIds = new Set(candidates.map((t) => t.id));
|
||||
|
||||
// Simulate execution: track done status
|
||||
// Include all done tasks (not just in scope) for dep resolution
|
||||
const simDone = new Set(allTasks.filter((t) => t.status === "done").map((t) => t.id));
|
||||
const simDone = new Set(graph.doneTaskIds);
|
||||
const remaining = new Set(
|
||||
candidates.filter((t) => t.status === "open" || t.status === "in_progress").map((t) => t.id),
|
||||
graph.candidates
|
||||
.filter((t) => t.status === "open" || t.status === "in_progress")
|
||||
.map((t) => t.id),
|
||||
);
|
||||
|
||||
const isReady = (taskId: string): boolean => {
|
||||
const task = taskMap.get(taskId);
|
||||
if (!task) return false;
|
||||
// All deps done (deps can be outside scope)
|
||||
const depsOk = task.deps.every((depId) => simDone.has(depId));
|
||||
// All children done (only consider children in scope)
|
||||
const children = (childrenMap.get(taskId) ?? []).filter((c) => candidateIds.has(c.id));
|
||||
const childrenOk = children.every((c) => simDone.has(c.id));
|
||||
return depsOk && childrenOk;
|
||||
};
|
||||
|
||||
const timeline: Task[] = [];
|
||||
const orderMap = new Map<string, number>();
|
||||
let orderIdx = 0;
|
||||
|
||||
// Done tasks first (by created date = historical execution order)
|
||||
const done = candidates
|
||||
const done = graph.candidates
|
||||
.filter((t) => t.status === "done")
|
||||
.sort((a, b) => a.created.localeCompare(b.created));
|
||||
for (const t of done) {
|
||||
@@ -88,12 +51,11 @@ export async function computeExecutionOrder(
|
||||
|
||||
// Then pending tasks in execution order
|
||||
while (remaining.size > 0) {
|
||||
const readyNow = [...remaining]
|
||||
.filter(isReady)
|
||||
.map((tid) => taskMap.get(tid)!)
|
||||
const readyNow = getTasksById(graph, remaining)
|
||||
.filter((task) => isTaskExecutableInOrder(graph, task.id, simDone))
|
||||
.sort(sortByPriorityThenCreated);
|
||||
|
||||
if (readyNow.length === 0) break; // No more can be done (cycle or blocked)
|
||||
if (readyNow.length === 0) break;
|
||||
|
||||
const next = readyNow[0];
|
||||
timeline.push(next);
|
||||
@@ -115,17 +77,8 @@ export function buildSortedChildrenMap(
|
||||
tasks: Task[],
|
||||
orderMap: Map<string, number>,
|
||||
): Map<string, Task[]> {
|
||||
const childrenMap = new Map<string, Task[]>();
|
||||
const childrenMap = buildChildrenMap(tasks);
|
||||
|
||||
for (const t of tasks) {
|
||||
if (t.parentId) {
|
||||
const siblings = childrenMap.get(t.parentId) ?? [];
|
||||
siblings.push(t);
|
||||
childrenMap.set(t.parentId, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each group by execution order
|
||||
for (const [parentId, children] of childrenMap) {
|
||||
children.sort((a, b) => {
|
||||
const orderA = orderMap.get(a.id) ?? Infinity;
|
||||
|
||||
131
packages/server/src/tasks/task-graph.ts
Normal file
131
packages/server/src/tasks/task-graph.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { Task, TaskStore } from "./types.js";
|
||||
|
||||
export interface TaskGraph {
|
||||
allTasks: Task[];
|
||||
candidates: Task[];
|
||||
taskMap: Map<string, Task>;
|
||||
childrenMap: Map<string, Task[]>;
|
||||
candidateIds: Set<string>;
|
||||
doneTaskIds: Set<string>;
|
||||
}
|
||||
|
||||
type TaskGraphStore = Pick<TaskStore, "list" | "get" | "getDescendants">;
|
||||
|
||||
export function sortByPriorityThenCreated(a: Task, b: Task): number {
|
||||
if (a.priority !== undefined && b.priority === undefined) return -1;
|
||||
if (a.priority === undefined && b.priority !== undefined) return 1;
|
||||
if (a.priority !== undefined && b.priority !== undefined) {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
}
|
||||
return a.created.localeCompare(b.created);
|
||||
}
|
||||
|
||||
export function buildTaskMap(tasks: Task[]): Map<string, Task> {
|
||||
return new Map(tasks.map((task) => [task.id, task]));
|
||||
}
|
||||
|
||||
export function buildChildrenMap(tasks: Task[]): Map<string, Task[]> {
|
||||
const childrenMap = new Map<string, Task[]>();
|
||||
for (const task of tasks) {
|
||||
if (task.parentId) {
|
||||
const siblings = childrenMap.get(task.parentId) ?? [];
|
||||
siblings.push(task);
|
||||
childrenMap.set(task.parentId, siblings);
|
||||
}
|
||||
}
|
||||
return childrenMap;
|
||||
}
|
||||
|
||||
export function isReadyTask(graph: TaskGraph, task: Task): boolean {
|
||||
return (
|
||||
task.status === "open" &&
|
||||
areTaskDepsDone(graph, task, graph.doneTaskIds) &&
|
||||
areTaskChildrenDone(graph, task.id, graph.doneTaskIds)
|
||||
);
|
||||
}
|
||||
|
||||
export function isBlockedTask(graph: TaskGraph, task: Task): boolean {
|
||||
return (
|
||||
task.status !== "draft" &&
|
||||
task.status !== "done" &&
|
||||
task.deps.length > 0 &&
|
||||
!areTaskDepsDone(graph, task, graph.doneTaskIds)
|
||||
);
|
||||
}
|
||||
|
||||
export function isTaskExecutableInOrder(
|
||||
graph: TaskGraph,
|
||||
taskId: string,
|
||||
completedTaskIds: Set<string>,
|
||||
): boolean {
|
||||
const task = graph.taskMap.get(taskId);
|
||||
return (
|
||||
task !== undefined &&
|
||||
areTaskDepsDone(graph, task, completedTaskIds) &&
|
||||
areTaskChildrenDone(graph, task.id, completedTaskIds, { scoped: true })
|
||||
);
|
||||
}
|
||||
|
||||
export function getTasksById(graph: TaskGraph, taskIds: Iterable<string>): Task[] {
|
||||
const tasks: Task[] = [];
|
||||
for (const taskId of taskIds) {
|
||||
const task = graph.taskMap.get(taskId);
|
||||
if (task) {
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export async function loadScopedTaskGraph(
|
||||
store: TaskGraphStore,
|
||||
scopeId?: string,
|
||||
): Promise<TaskGraph> {
|
||||
const allTasks = await store.list();
|
||||
const candidates = await loadScopedCandidates(store, allTasks, scopeId);
|
||||
|
||||
return {
|
||||
allTasks,
|
||||
candidates,
|
||||
taskMap: buildTaskMap(allTasks),
|
||||
childrenMap: buildChildrenMap(allTasks),
|
||||
candidateIds: new Set(candidates.map((task) => task.id)),
|
||||
doneTaskIds: new Set(allTasks.filter((task) => task.status === "done").map((task) => task.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function areTaskDepsDone(graph: TaskGraph, task: Task, completedTaskIds: Set<string>): boolean {
|
||||
return task.deps.every((depId) => {
|
||||
const dep = graph.taskMap.get(depId);
|
||||
return dep !== undefined && completedTaskIds.has(depId);
|
||||
});
|
||||
}
|
||||
|
||||
function areTaskChildrenDone(
|
||||
graph: TaskGraph,
|
||||
taskId: string,
|
||||
completedTaskIds: Set<string>,
|
||||
options?: { scoped: boolean },
|
||||
): boolean {
|
||||
const children = graph.childrenMap.get(taskId) ?? [];
|
||||
return children.every((child) => {
|
||||
if (options?.scoped === true && !graph.candidateIds.has(child.id)) {
|
||||
return true;
|
||||
}
|
||||
return completedTaskIds.has(child.id);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadScopedCandidates(
|
||||
store: TaskGraphStore,
|
||||
allTasks: Task[],
|
||||
scopeId?: string,
|
||||
): Promise<Task[]> {
|
||||
if (!scopeId) {
|
||||
return allTasks;
|
||||
}
|
||||
|
||||
const scopeTask = await store.get(scopeId);
|
||||
const descendants = await store.getDescendants(scopeId);
|
||||
return scopeTask ? [scopeTask, ...descendants] : descendants;
|
||||
}
|
||||
@@ -2,27 +2,17 @@ import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { Task, TaskStore, CreateTaskOptions, TaskStatus } from "./types.js";
|
||||
import {
|
||||
isBlockedTask,
|
||||
isReadyTask,
|
||||
loadScopedTaskGraph,
|
||||
sortByPriorityThenCreated,
|
||||
} from "./task-graph.js";
|
||||
|
||||
function generateId(): string {
|
||||
return randomBytes(4).toString("hex");
|
||||
}
|
||||
|
||||
function sortByPriorityThenCreated(a: Task, b: Task): number {
|
||||
// Tasks with priority come before tasks without
|
||||
if (a.priority !== undefined && b.priority === undefined) return -1;
|
||||
if (a.priority === undefined && b.priority !== undefined) return 1;
|
||||
|
||||
// If both have priority, lower number = higher priority
|
||||
if (a.priority !== undefined && b.priority !== undefined) {
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to created date (oldest first)
|
||||
return a.created.localeCompare(b.created);
|
||||
}
|
||||
|
||||
function serializeTask(task: Task): string {
|
||||
const frontmatterLines = [
|
||||
"---",
|
||||
@@ -267,82 +257,20 @@ export class FileTaskStore implements TaskStore {
|
||||
}
|
||||
|
||||
async getReady(scopeId?: string): Promise<Task[]> {
|
||||
const allTasks = await this.list();
|
||||
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
||||
|
||||
let candidates: Task[];
|
||||
if (scopeId) {
|
||||
// Include the scoped task itself and all its descendants (children tree)
|
||||
const scopeTask = await this.get(scopeId);
|
||||
const descendants = await this.getDescendants(scopeId);
|
||||
candidates = scopeTask ? [scopeTask, ...descendants] : descendants;
|
||||
} else {
|
||||
candidates = allTasks;
|
||||
}
|
||||
|
||||
// Build children map for quick lookup
|
||||
const childrenMap = new Map<string, Task[]>();
|
||||
for (const t of allTasks) {
|
||||
if (t.parentId) {
|
||||
const siblings = childrenMap.get(t.parentId) ?? [];
|
||||
siblings.push(t);
|
||||
childrenMap.set(t.parentId, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
const isReady = (task: Task): boolean => {
|
||||
if (task.status !== "open") return false;
|
||||
// All deps must be done
|
||||
const depsReady = task.deps.every((depId) => {
|
||||
const dep = taskMap.get(depId);
|
||||
return dep?.status === "done";
|
||||
});
|
||||
if (!depsReady) return false;
|
||||
// All children must be done (if any exist)
|
||||
const children = childrenMap.get(task.id) ?? [];
|
||||
return children.every((c) => c.status === "done");
|
||||
};
|
||||
|
||||
// Sort by priority first (lower = higher priority), then created date
|
||||
return candidates.filter(isReady).sort(sortByPriorityThenCreated);
|
||||
const graph = await loadScopedTaskGraph(this, scopeId);
|
||||
return graph.candidates
|
||||
.filter((task) => isReadyTask(graph, task))
|
||||
.sort(sortByPriorityThenCreated);
|
||||
}
|
||||
|
||||
async getBlocked(scopeId?: string): Promise<Task[]> {
|
||||
const allTasks = await this.list();
|
||||
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
||||
|
||||
let candidates: Task[];
|
||||
if (scopeId) {
|
||||
const scopeTask = await this.get(scopeId);
|
||||
const descendants = await this.getDescendants(scopeId);
|
||||
candidates = scopeTask ? [scopeTask, ...descendants] : descendants;
|
||||
} else {
|
||||
candidates = allTasks;
|
||||
}
|
||||
|
||||
const isBlocked = (task: Task): boolean => {
|
||||
if (task.status === "draft" || task.status === "done") return false;
|
||||
if (task.deps.length === 0) return false;
|
||||
return task.deps.some((depId) => {
|
||||
const dep = taskMap.get(depId);
|
||||
return dep?.status !== "done";
|
||||
});
|
||||
};
|
||||
|
||||
return candidates.filter(isBlocked);
|
||||
const graph = await loadScopedTaskGraph(this, scopeId);
|
||||
return graph.candidates.filter((task) => isBlockedTask(graph, task));
|
||||
}
|
||||
|
||||
async getClosed(scopeId?: string): Promise<Task[]> {
|
||||
let candidates: Task[];
|
||||
if (scopeId) {
|
||||
const scopeTask = await this.get(scopeId);
|
||||
const descendants = await this.getDescendants(scopeId);
|
||||
candidates = scopeTask ? [scopeTask, ...descendants] : descendants;
|
||||
} else {
|
||||
candidates = await this.list();
|
||||
}
|
||||
const { candidates } = await loadScopedTaskGraph(this, scopeId);
|
||||
|
||||
// Sort by created date (most recent first) for consistent ordering
|
||||
return candidates
|
||||
.filter((t) => t.status === "done")
|
||||
.sort((a, b) => b.created.localeCompare(a.created));
|
||||
|
||||
@@ -870,6 +870,10 @@ async function getWorktreePathForBranch(cwd: string, branchName: string): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
|
||||
return doesGitRefExist(cwd, `refs/heads/${branchName}`);
|
||||
}
|
||||
|
||||
export async function renameCurrentBranch(
|
||||
cwd: string,
|
||||
newName: string,
|
||||
|
||||
@@ -1,49 +1,32 @@
|
||||
import {
|
||||
isGitHubHost,
|
||||
normalizeHost,
|
||||
parseGitHubRemoteIdentity,
|
||||
parseGitRemoteLocation,
|
||||
type GitHubRemoteIdentity as ResolvedGitHubRemoteIdentity,
|
||||
} from "../shared/git-remote.js";
|
||||
import { findExecutable } from "./executable.js";
|
||||
import { execCommand } from "./spawn.js";
|
||||
|
||||
const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]);
|
||||
|
||||
const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
|
||||
"https:": "https",
|
||||
"http:": "http",
|
||||
"ssh:": "ssh",
|
||||
};
|
||||
|
||||
let sshExecutableLookup: Promise<string | null> | null = null;
|
||||
const sshHostnameResolutionCache = new Map<string, Promise<string | null>>();
|
||||
|
||||
interface GitRemoteLocation {
|
||||
transport: "scp" | "ssh" | "http" | "https";
|
||||
host: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface GitHubRemoteIdentity {
|
||||
owner: string;
|
||||
name: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export type SshHostnameResolver = (host: string) => Promise<string | null>;
|
||||
|
||||
export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null {
|
||||
const location = parseGitRemoteLocation(remoteUrl);
|
||||
if (!location || !GITHUB_HOSTS.has(location.host)) return null;
|
||||
return parseGitHubRemoteIdentity(location.path);
|
||||
}
|
||||
export { parseGitHubRemoteUrl, type GitHubRemoteIdentity } from "../shared/git-remote.js";
|
||||
|
||||
export async function resolveGitHubRemote(input: {
|
||||
remoteUrl: string;
|
||||
resolveSshHostname?: SshHostnameResolver;
|
||||
}): Promise<GitHubRemoteIdentity | null> {
|
||||
}): Promise<ResolvedGitHubRemoteIdentity | null> {
|
||||
const location = parseGitRemoteLocation(input.remoteUrl);
|
||||
if (!location) return null;
|
||||
if (GITHUB_HOSTS.has(location.host)) return parseGitHubRemoteIdentity(location.path);
|
||||
if (isGitHubHost(location.host)) return parseGitHubRemoteIdentity(location.path);
|
||||
if (location.transport !== "scp" && location.transport !== "ssh") return null;
|
||||
|
||||
const resolve = input.resolveSshHostname ?? resolveSshHostname;
|
||||
const resolvedHost = await resolve(location.host);
|
||||
if (!resolvedHost || !GITHUB_HOSTS.has(resolvedHost)) return null;
|
||||
if (!resolvedHost || !isGitHubHost(resolvedHost)) return null;
|
||||
return parseGitHubRemoteIdentity(location.path);
|
||||
}
|
||||
|
||||
@@ -86,60 +69,3 @@ function parseSshHostname(stdout: string): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null {
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
|
||||
if (scpLike) {
|
||||
const host = normalizeHost(scpLike[1] ?? "");
|
||||
const path = normalizeRemotePath(scpLike[2] ?? "");
|
||||
if (!isValidRemoteHost(host) || !path) return null;
|
||||
return { transport: "scp", host, path };
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transport = TRANSPORT_BY_PROTOCOL[parsed.protocol.toLowerCase()];
|
||||
if (!transport) return null;
|
||||
|
||||
const host = normalizeHost(parsed.hostname);
|
||||
let path: string;
|
||||
try {
|
||||
path = decodeURIComponent(parsed.pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const normalizedPath = normalizeRemotePath(path);
|
||||
if (!isValidRemoteHost(host) || !normalizedPath) return null;
|
||||
|
||||
return { transport, host, path: normalizedPath };
|
||||
}
|
||||
|
||||
function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
if (segments.length !== 2) return null;
|
||||
const [owner, name] = segments;
|
||||
if (!owner || !name) return null;
|
||||
return { owner, name, repo: `${owner}/${name}` };
|
||||
}
|
||||
|
||||
function normalizeRemotePath(path: string): string | null {
|
||||
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
|
||||
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
return host.trim().replace(/\.+$/u, "").toLowerCase();
|
||||
}
|
||||
|
||||
function isValidRemoteHost(host: string): boolean {
|
||||
return /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(host);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.72",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,10 +11,10 @@
|
||||
"deploy": "npm run build && wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
"@tanstack/react-router": "^1.120.3",
|
||||
"@tanstack/react-start": "^1.120.3",
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
"@tanstack/react-router": "^1.166.4",
|
||||
"@tanstack/react-start": "^1.166.4",
|
||||
"framer-motion": "^12.35.2",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.1.4",
|
||||
@@ -25,7 +25,7 @@
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"wrangler": "^4.59.1"
|
||||
"wrangler": "^4.75.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
@@ -34,7 +34,7 @@
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.0",
|
||||
"vite": "^7.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,9 @@ function Privacy() {
|
||||
<li>Session IDs</li>
|
||||
</ul>
|
||||
<p>
|
||||
All messages between your phone and daemon are end-to-end encrypted with AES-256-GCM.
|
||||
The relay cannot read your messages, see your code, or decrypt your traffic.
|
||||
All messages between your phone and daemon are end-to-end encrypted with
|
||||
XSalsa20-Poly1305. The relay cannot read your messages, see your code, or decrypt your
|
||||
traffic.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user