mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1ba7e3f3 |
+47
-11
@@ -66,12 +66,7 @@ jobs:
|
|||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
server-tests:
|
server-tests:
|
||||||
strategy:
|
runs-on: ubuntu-latest
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, windows-latest]
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
name: server-tests (${{ matrix.os }})
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -88,9 +83,6 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Install Claude Code CLI for provider tests
|
|
||||||
run: npm install -g @anthropic-ai/claude-code
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
- name: Build highlight dependency
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
@@ -103,6 +95,50 @@ jobs:
|
|||||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|
||||||
|
server-tests-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
- name: Build relay dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
- name: Run Windows-critical server tests
|
||||||
|
working-directory: packages/server
|
||||||
|
run: >
|
||||||
|
npx vitest run
|
||||||
|
src/utils/executable.probe.test.ts
|
||||||
|
src/utils/executable.test.ts
|
||||||
|
src/utils/spawn.launch-regression.test.ts
|
||||||
|
src/utils/spawn.percent-escape.test.ts
|
||||||
|
src/utils/spawn.test.ts
|
||||||
|
src/utils/tree-kill.test.ts
|
||||||
|
src/utils/run-git-command.test.ts
|
||||||
|
src/utils/checkout-git-rev-parse.test.ts
|
||||||
|
src/terminal/worker-terminal-manager.test.ts
|
||||||
|
src/server/agent/provider-registry.test.ts
|
||||||
|
src/server/agent/provider-launch-config.test.ts
|
||||||
|
src/server/agent/provider-snapshot-manager.test.ts
|
||||||
|
src/server/agent/providers/claude-agent.spawn.test.ts
|
||||||
|
src/server/agent/providers/provider-windows-launch.test.ts
|
||||||
|
src/server/agent/providers/provider-availability.test.ts
|
||||||
|
src/server/workspace-registry-model.test.ts
|
||||||
|
src/server/persisted-config.test.ts
|
||||||
|
src/server/bootstrap-provider-availability.test.ts
|
||||||
|
|
||||||
desktop-tests:
|
desktop-tests:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -180,7 +216,7 @@ jobs:
|
|||||||
run: npm run build --workspace=@getpaseo/server
|
run: npm run build --workspace=@getpaseo/server
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
- name: Install agent CLIs for provider tests
|
||||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||||
|
|
||||||
- name: Run Playwright E2E tests
|
- name: Run Playwright E2E tests
|
||||||
run: npm run test:e2e --workspace=@getpaseo/app
|
run: npm run test:e2e --workspace=@getpaseo/app
|
||||||
@@ -235,7 +271,7 @@ jobs:
|
|||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
- name: Install agent CLIs for provider tests
|
||||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||||
|
|
||||||
- name: Build highlight dependency
|
- name: Build highlight dependency
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ jobs:
|
|||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
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
|
- name: Build highlight dependency
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ CLAUDE.local.md
|
|||||||
.paseo/
|
.paseo/
|
||||||
.wrangler/
|
.wrangler/
|
||||||
**/.wrangler/
|
**/.wrangler/
|
||||||
**/.tanstack/
|
|
||||||
|
|
||||||
# Local agent/tooling artifacts (do not commit)
|
# Local agent/tooling artifacts (do not commit)
|
||||||
PLAN.md
|
PLAN.md
|
||||||
|
|||||||
+1
-1
@@ -11,5 +11,5 @@
|
|||||||
"arrowParens": "always",
|
"arrowParens": "always",
|
||||||
"bracketSameLine": false,
|
"bracketSameLine": false,
|
||||||
"bracketSpacing": true,
|
"bracketSpacing": true,
|
||||||
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
|
"ignorePatterns": ["*.lock"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.1.70 - 2026-05-08
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
|
|
||||||
- **Claude agents now require `claude` on your PATH.** Install Claude Code globally (`npm install -g @anthropic-ai/claude-code`) before running a Claude agent — Paseo no longer ships a bundled fallback binary. Same posture as Codex and OpenCode, and shrinks the desktop install by ~210 MB per platform.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **One-click ACP providers** — add Cursor, Hermes, Qwen Coder, Kimi Code, and other ACP agents from a built-in catalog instead of writing config by hand.
|
|
||||||
- Codex `/goal` slash command — set or update the goal mid-turn while a Codex agent is running.
|
|
||||||
- Claude's Sonnet 4.6 1M context model is now selectable in the model picker.
|
|
||||||
- Detect GitHub issue and PR URLs pasted into the composer search.
|
|
||||||
- `paseo worktree create` CLI command, with parity to the MCP `create_worktree` tool.
|
|
||||||
- `paseo schedule update` to edit a schedule in place without recreating it.
|
|
||||||
- `paseo schedule run-once` for cron-style triggers, plus `--mode` on `schedule` and `loop`. Background runs now default to unattended mode.
|
|
||||||
- Projects settings now lists workspaces from any remote — GitLab, Gitea, Bitbucket, self-hosted, and SSH-style URLs, not just GitHub. ([#681](https://github.com/getpaseo/paseo/pull/681) by [@krumpyzoid](https://github.com/krumpyzoid))
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- Skills now install, update, and uninstall on demand instead of silently auto-syncing on every desktop launch.
|
|
||||||
- Self-hosted relays can opt into `wss://` for TLS connections.
|
|
||||||
- Workspace open targets only show options reachable from the current daemon.
|
|
||||||
- Combobox search matches model descriptions, not just names.
|
|
||||||
- Codex image attachments render inline as path markdown.
|
|
||||||
- Subagent task notifications no longer clutter the parent agent's timeline.
|
|
||||||
- Voice mode: quieter thinking tone and small UI polish.
|
|
||||||
- Settings sidebar order: Projects now appears after General.
|
|
||||||
- Electron upgraded to 41.2.0 for the desktop app.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Claude agent: daemon no longer crashes mid-turn** when the underlying SDK fires a stray control message after the connection has been torn down.
|
|
||||||
- **Windows:** Terminals start reliably and shut down cleanly without leaving stuck processes behind.
|
|
||||||
- **Linux:** Workspace file watchers no longer storm with events on busy working trees, fixing CPU spikes on large repos. ([#794](https://github.com/getpaseo/paseo/pull/794) by [@312223105](https://github.com/312223105))
|
|
||||||
- ACP-based agents launch terminal shell commands reliably. ([#793](https://github.com/getpaseo/paseo/pull/793) by [@ebg1223](https://github.com/ebg1223))
|
|
||||||
- Checkout shortstat now counts untracked files. ([#608](https://github.com/getpaseo/paseo/issues/608), [#762](https://github.com/getpaseo/paseo/pull/762) by [@somus](https://github.com/somus))
|
|
||||||
- Relay endpoints on port 443 use TLS automatically. ([#774](https://github.com/getpaseo/paseo/pull/774) by [@caoer](https://github.com/caoer))
|
|
||||||
- Desktop CLI passthrough TTY handling — interactive commands now behave correctly when launched from the desktop app.
|
|
||||||
- The CLI honors the `PASEO_PASSWORD` environment variable for password-protected daemons.
|
|
||||||
- Daemon shutdown terminates all child processes cleanly using tree-kill.
|
|
||||||
- Agent spawn paths handle missing executables and unusual install layouts more reliably.
|
|
||||||
- OpenCode now forwards provider retry errors instead of silently swallowing them.
|
|
||||||
- Codex import no longer reverts to the wrong default mode.
|
|
||||||
- Pane keyboard shortcuts no longer fire while you're typing in an editable field.
|
|
||||||
- Cold workspace URL navigation now lands in the correct sidebar entry on web.
|
|
||||||
- Workspace navigation regression on web fixed.
|
|
||||||
- Duplicate workspace shell navigation eliminated.
|
|
||||||
- The 'Update installed' callout no longer flashes incorrectly.
|
|
||||||
- Browser pane reload focus and devtools handling.
|
|
||||||
- MCP terminal capture now includes scrollback.
|
|
||||||
- Worktree branches no longer get renamed when an agent is created against an existing worktree from MCP.
|
|
||||||
- Creating an agent in a subdirectory of a registered workspace now runs in that subdirectory instead of jumping up to the parent. ([#551](https://github.com/getpaseo/paseo/issues/551))
|
|
||||||
- Non-GitHub project display names are derived from the remote owner/repo instead of the local path.
|
|
||||||
- Desktop IPC wrapped in shared mutation/query hooks, fixing stale state and intermittent failures. ([#761](https://github.com/getpaseo/paseo/issues/761))
|
|
||||||
- `paseo schedule create --host` now requires `--cwd` to avoid running schedules in the wrong directory.
|
|
||||||
- `paseo schedule create --every` runs once immediately by default, then on the configured interval.
|
|
||||||
- MCP `create_agent` validates the requested mode and refuses silent cross-provider inheritance.
|
|
||||||
|
|
||||||
## 0.1.69 - 2026-05-05
|
## 0.1.69 - 2026-05-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ There are two supported ways to ship from `main`:
|
|||||||
Before running any stable patch release command:
|
Before running any stable patch release command:
|
||||||
|
|
||||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
- Make sure local `npm run typecheck` passes on that commit.
|
||||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-Fo95v2pBAW1i0K7WPoEwtKbwjeDZ5ed4vJ5p7I7LIYw=";
|
npmDepsHash = "sha256-mGnJDX1LOORj7fDRPcJYIFG0D+rLDyom6LktWhwZasw=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# 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).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+781
-916
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.70",
|
"version": "0.1.69",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.70",
|
"version": "0.1.69",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -67,22 +67,6 @@ describe("combined model selector helpers", () => {
|
|||||||
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches across label, provider, and description with multi-token fuzzy search", () => {
|
|
||||||
const row = {
|
|
||||||
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
|
|
||||||
provider: "opencode",
|
|
||||||
providerLabel: "OpenCode",
|
|
||||||
modelId: "opencode-zen/kimi-k2.5",
|
|
||||||
modelLabel: "Kimi K2.5",
|
|
||||||
description: "OpenCode Zen - kimi",
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(matchesSearch(row, "kimi zen")).toBe(true);
|
|
||||||
expect(matchesSearch(row, "zen kimi")).toBe(true);
|
|
||||||
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
|
|
||||||
expect(matchesSearch(row, "kimi gemini")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the selected trigger label model-only", () => {
|
it("keeps the selected trigger label model-only", () => {
|
||||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||||
|
|||||||
@@ -48,10 +48,7 @@ export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): b
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]
|
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
|
||||||
.join(" ")
|
value.toLowerCase().includes(normalizedQuery),
|
||||||
.toLowerCase();
|
);
|
||||||
|
|
||||||
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
|
|
||||||
return tokens.every((token) => haystack.includes(token));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,34 +7,12 @@ import { settingsStyles } from "@/styles/settings";
|
|||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import {
|
import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
|
||||||
shouldUseDesktopDaemon,
|
|
||||||
type SkillOp,
|
|
||||||
type SkillsStatus,
|
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
|
||||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
|
||||||
|
|
||||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||||
const UNINSTALL_MESSAGE =
|
|
||||||
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
|
|
||||||
|
|
||||||
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
|
|
||||||
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
|
|
||||||
add: "Add skill",
|
|
||||||
update: "Update skill",
|
|
||||||
delete: "Delete skill",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatUpdateMessage(ops: readonly SkillOp[]): string {
|
|
||||||
const sorted = [...ops].sort((a, b) => {
|
|
||||||
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
|
|
||||||
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IntegrationsSection() {
|
export function IntegrationsSection() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -47,18 +25,16 @@ export function IntegrationsSection() {
|
|||||||
} = useCliInstall();
|
} = useCliInstall();
|
||||||
const {
|
const {
|
||||||
status: skillsStatus,
|
status: skillsStatus,
|
||||||
isWorking: isSkillsWorking,
|
isInstalling: isInstallingSkills,
|
||||||
install: installSkills,
|
install: installSkills,
|
||||||
update: updateSkills,
|
|
||||||
uninstall: uninstallSkills,
|
|
||||||
refresh: refreshSkillsStatus,
|
refresh: refreshSkillsStatus,
|
||||||
} = useSkillsStatus();
|
} = useSkillsInstall();
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
if (!showSection) return undefined;
|
if (!showSection) return undefined;
|
||||||
refreshCliStatus();
|
refreshCliStatus();
|
||||||
void refreshSkillsStatus();
|
refreshSkillsStatus();
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
|
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
|
||||||
);
|
);
|
||||||
@@ -69,33 +45,9 @@ export function IntegrationsSection() {
|
|||||||
}, [installCli, isInstallingCli]);
|
}, [installCli, isInstallingCli]);
|
||||||
|
|
||||||
const handleInstallSkills = useCallback(() => {
|
const handleInstallSkills = useCallback(() => {
|
||||||
if (isSkillsWorking) return;
|
if (isInstallingSkills) return;
|
||||||
void installSkills();
|
installSkills();
|
||||||
}, [installSkills, isSkillsWorking]);
|
}, [installSkills, isInstallingSkills]);
|
||||||
|
|
||||||
const handleUpdateSkills = useCallback(async () => {
|
|
||||||
if (isSkillsWorking) return;
|
|
||||||
const ops = skillsStatus?.ops ?? [];
|
|
||||||
const confirmed = await confirmDialog({
|
|
||||||
title: "Update Paseo skills?",
|
|
||||||
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
|
|
||||||
confirmLabel: "Update",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
await updateSkills();
|
|
||||||
}, [isSkillsWorking, skillsStatus, updateSkills]);
|
|
||||||
|
|
||||||
const handleUninstallSkills = useCallback(async () => {
|
|
||||||
if (isSkillsWorking) return;
|
|
||||||
const confirmed = await confirmDialog({
|
|
||||||
title: "Uninstall Paseo skills?",
|
|
||||||
message: UNINSTALL_MESSAGE,
|
|
||||||
confirmLabel: "Uninstall",
|
|
||||||
destructive: true,
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
await uninstallSkills();
|
|
||||||
}, [isSkillsWorking, uninstallSkills]);
|
|
||||||
|
|
||||||
const handleOpenCliDocs = useCallback(() => {
|
const handleOpenCliDocs = useCallback(() => {
|
||||||
void openExternalUrl(CLI_DOCS_URL);
|
void openExternalUrl(CLI_DOCS_URL);
|
||||||
@@ -144,8 +96,6 @@ export function IntegrationsSection() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillsState = skillsStatus?.state ?? null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Integrations" trailing={trailing}>
|
<SettingsSection title="Integrations" trailing={trailing}>
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
@@ -180,69 +130,30 @@ export function IntegrationsSection() {
|
|||||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={settingsStyles.rowHint}>
|
<Text style={settingsStyles.rowHint}>
|
||||||
{skillsState === "drift"
|
Teach your agents to orchestrate through the CLI
|
||||||
? "Update available"
|
|
||||||
: "Teach your agents to orchestrate through the CLI"}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<SkillsActions
|
{skillsStatus?.installed ? (
|
||||||
state={skillsState}
|
<View style={styles.installedLabel}>
|
||||||
isWorking={isSkillsWorking}
|
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||||
onInstall={handleInstallSkills}
|
<Text style={styles.mutedText}>Installed</Text>
|
||||||
onUpdate={handleUpdateSkills}
|
</View>
|
||||||
onUninstall={handleUninstallSkills}
|
) : (
|
||||||
/>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleInstallSkills}
|
||||||
|
disabled={isInstallingSkills}
|
||||||
|
>
|
||||||
|
{isInstallingSkills ? "Installing..." : "Install"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SkillsActionsProps {
|
|
||||||
state: SkillsStatus["state"] | null;
|
|
||||||
isWorking: boolean;
|
|
||||||
onInstall: () => void;
|
|
||||||
onUpdate: () => void;
|
|
||||||
onUninstall: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
|
|
||||||
if (state === "up-to-date") {
|
|
||||||
return (
|
|
||||||
<View style={styles.actionsRow}>
|
|
||||||
<View style={styles.installedLabel}>
|
|
||||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.mutedText}>Installed</Text>
|
|
||||||
</View>
|
|
||||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
|
||||||
Uninstall
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === "drift") {
|
|
||||||
return (
|
|
||||||
<View style={styles.actionsRow}>
|
|
||||||
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
|
|
||||||
{isWorking ? "Working..." : "Update"}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
|
||||||
Uninstall
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
|
|
||||||
{isWorking ? "Installing..." : "Install"}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
headerLinks: {
|
headerLinks: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -263,9 +174,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
actionsRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -220,67 +220,10 @@ export async function installCli(): Promise<InstallStatus> {
|
|||||||
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||||
|
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
|
||||||
export type SkillOp =
|
|
||||||
| { kind: "add"; name: string }
|
|
||||||
| { kind: "update"; name: string }
|
|
||||||
| { kind: "delete"; name: string };
|
|
||||||
|
|
||||||
export interface SkillsStatus {
|
|
||||||
state: SkillsState;
|
|
||||||
ops: SkillOp[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSkillsState(value: unknown): SkillsState {
|
export async function installSkills(): Promise<InstallStatus> {
|
||||||
switch (value) {
|
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
|
||||||
case "not-installed":
|
|
||||||
case "up-to-date":
|
|
||||||
case "drift":
|
|
||||||
return value;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unexpected skills status state: ${String(value)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSkillOp(raw: unknown): SkillOp {
|
|
||||||
if (!isRecord(raw)) {
|
|
||||||
throw new Error("Unexpected skill op response.");
|
|
||||||
}
|
|
||||||
const name = toStringOrNull(raw.name);
|
|
||||||
if (!name) throw new Error("Skill op missing name.");
|
|
||||||
switch (raw.kind) {
|
|
||||||
case "add":
|
|
||||||
return { kind: "add", name };
|
|
||||||
case "update":
|
|
||||||
return { kind: "update", name };
|
|
||||||
case "delete":
|
|
||||||
return { kind: "delete", name };
|
|
||||||
default:
|
|
||||||
throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSkillsStatus(raw: unknown): SkillsStatus {
|
|
||||||
if (!isRecord(raw)) {
|
|
||||||
throw new Error("Unexpected skills status response.");
|
|
||||||
}
|
|
||||||
const ops = Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [];
|
|
||||||
return { state: parseSkillsState(raw.state), ops };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSkillsStatus(): Promise<SkillsStatus> {
|
|
||||||
return parseSkillsStatus(await invokeDesktopCommand("get_skills_status"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installSkills(): Promise<SkillsStatus> {
|
|
||||||
return parseSkillsStatus(await invokeDesktopCommand("install_skills"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSkills(): Promise<SkillsStatus> {
|
|
||||||
return parseSkillsStatus(await invokeDesktopCommand("update_skills"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uninstallSkills(): Promise<SkillsStatus> {
|
|
||||||
return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills"));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import React from "react";
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useCliInstall, useSkillsStatus } from "./use-install-status";
|
import { useCliInstall, useSkillsInstall } from "./use-install-status";
|
||||||
|
|
||||||
const toast = vi.hoisted(() => ({
|
const toast = vi.hoisted(() => ({
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
@@ -15,11 +15,9 @@ const toast = vi.hoisted(() => ({
|
|||||||
|
|
||||||
const desktopDaemon = vi.hoisted(() => ({
|
const desktopDaemon = vi.hoisted(() => ({
|
||||||
getCliInstallStatus: vi.fn(),
|
getCliInstallStatus: vi.fn(),
|
||||||
|
getSkillsInstallStatus: vi.fn(),
|
||||||
installCli: vi.fn(),
|
installCli: vi.fn(),
|
||||||
getSkillsStatus: vi.fn(),
|
|
||||||
installSkills: vi.fn(),
|
installSkills: vi.fn(),
|
||||||
updateSkills: vi.fn(),
|
|
||||||
uninstallSkills: vi.fn(),
|
|
||||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
shouldUseDesktopDaemon: vi.fn(() => true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -91,9 +89,11 @@ describe("useCliInstall", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useSkillsStatus", () => {
|
describe("useSkillsInstall", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
|
||||||
|
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -101,155 +101,34 @@ describe("useSkillsStatus", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads the current skills status", async () => {
|
it("loads skills install status", async () => {
|
||||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||||
state: "up-to-date",
|
|
||||||
ops: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
expect(result.current.status).toEqual({ installed: true });
|
||||||
});
|
});
|
||||||
expect(result.current.isWorking).toBe(false);
|
|
||||||
expect(toast.error).not.toHaveBeenCalled();
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => {
|
it("toasts and exposes skills install errors", async () => {
|
||||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
|
||||||
state: "not-installed",
|
|
||||||
ops: [{ kind: "add", name: "paseo" }],
|
|
||||||
});
|
|
||||||
desktopDaemon.installSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
|
|
||||||
|
|
||||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.status?.state).toBe("not-installed");
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.install();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(desktopDaemon.installSkills).toHaveBeenCalledOnce();
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("update transitions drift to up-to-date", async () => {
|
|
||||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
|
||||||
state: "drift",
|
|
||||||
ops: [{ kind: "update", name: "paseo" }],
|
|
||||||
});
|
|
||||||
desktopDaemon.updateSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
|
|
||||||
|
|
||||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.status?.state).toBe("drift");
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.update();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(desktopDaemon.updateSkills).toHaveBeenCalledOnce();
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uninstall transitions up-to-date back to not-installed", async () => {
|
|
||||||
desktopDaemon.getSkillsStatus.mockResolvedValue({ state: "up-to-date", ops: [] });
|
|
||||||
desktopDaemon.uninstallSkills.mockResolvedValue({
|
|
||||||
state: "not-installed",
|
|
||||||
ops: [{ kind: "add", name: "paseo" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.status?.state).toBe("up-to-date");
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.uninstall();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(desktopDaemon.uninstallSkills).toHaveBeenCalledOnce();
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.status).toEqual({
|
|
||||||
state: "not-installed",
|
|
||||||
ops: [{ kind: "add", name: "paseo" }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isWorking flips while a mutation is in flight", async () => {
|
|
||||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
|
||||||
state: "not-installed",
|
|
||||||
ops: [{ kind: "add", name: "paseo" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
let resolveInstall: ((value: unknown) => void) | null = null;
|
|
||||||
desktopDaemon.installSkills.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
resolveInstall = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.status?.state).toBe("not-installed");
|
|
||||||
});
|
|
||||||
expect(result.current.isWorking).toBe(false);
|
|
||||||
|
|
||||||
let installPromise: Promise<void> = Promise.resolve();
|
|
||||||
act(() => {
|
|
||||||
installPromise = result.current.install();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isWorking).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
resolveInstall?.({ state: "up-to-date", ops: [] });
|
|
||||||
await installPromise;
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isWorking).toBe(false);
|
|
||||||
});
|
|
||||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toasts and exposes errors when install fails", async () => {
|
|
||||||
const error = new Error("Missing IPC handler");
|
const error = new Error("Missing IPC handler");
|
||||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
|
||||||
state: "not-installed",
|
|
||||||
ops: [{ kind: "add", name: "paseo" }],
|
|
||||||
});
|
|
||||||
desktopDaemon.installSkills.mockRejectedValue(error);
|
desktopDaemon.installSkills.mockRejectedValue(error);
|
||||||
|
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.status?.state).toBe("not-installed");
|
expect(result.current.status).toEqual({ installed: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
act(() => {
|
||||||
await result.current.install();
|
result.current.install();
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.error).toBe(error);
|
expect(result.current.error).toBe(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
|
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
|
||||||
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
|
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ import { useCallback } from "react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getCliInstallStatus,
|
getCliInstallStatus,
|
||||||
getSkillsStatus,
|
getSkillsInstallStatus,
|
||||||
installCli,
|
installCli,
|
||||||
installSkills,
|
installSkills,
|
||||||
shouldUseDesktopDaemon,
|
shouldUseDesktopDaemon,
|
||||||
type InstallStatus,
|
type InstallStatus,
|
||||||
type SkillsStatus,
|
|
||||||
uninstallSkills,
|
|
||||||
updateSkills,
|
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
import {
|
import {
|
||||||
useDesktopIpcErrorReporter,
|
useDesktopIpcErrorReporter,
|
||||||
@@ -17,7 +14,11 @@ import {
|
|||||||
} from "@/desktop/hooks/desktop-ipc-error";
|
} from "@/desktop/hooks/desktop-ipc-error";
|
||||||
|
|
||||||
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
|
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
|
||||||
const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const;
|
const SKILLS_INSTALL_STATUS_QUERY_KEY = [
|
||||||
|
"desktop",
|
||||||
|
"integrations",
|
||||||
|
"skills-install-status",
|
||||||
|
] as const;
|
||||||
|
|
||||||
interface DesktopInstallHookResult {
|
interface DesktopInstallHookResult {
|
||||||
status: InstallStatus | null;
|
status: InstallStatus | null;
|
||||||
@@ -76,43 +77,25 @@ export function useCliInstall(): DesktopInstallHookResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsStatusHookResult {
|
export function useSkillsInstall(): DesktopInstallHookResult {
|
||||||
status: SkillsStatus | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
isWorking: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
install: () => Promise<void>;
|
|
||||||
update: () => Promise<void>;
|
|
||||||
uninstall: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSkillsStatus(): SkillsStatusHookResult {
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const reportError = useDesktopIpcErrorReporter();
|
const reportError = useDesktopIpcErrorReporter();
|
||||||
const enabled = shouldUseDesktopDaemon();
|
const enabled = shouldUseDesktopDaemon();
|
||||||
|
|
||||||
const statusQuery = useQuery<SkillsStatus, Error>({
|
const statusQuery = useQuery<InstallStatus, Error>({
|
||||||
queryKey: SKILLS_STATUS_QUERY_KEY,
|
queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
|
||||||
queryFn: getSkillsStatus,
|
queryFn: getSkillsInstallStatus,
|
||||||
enabled,
|
enabled,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
|
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
|
||||||
useDesktopIpcQueryErrorToast({
|
useDesktopIpcQueryErrorToast({
|
||||||
error: statusQuery.error,
|
error: statusQuery.error,
|
||||||
message: "Unable to check orchestration skills status.",
|
message: "Unable to check orchestration skills install status.",
|
||||||
logLabel: "[Integrations] Failed to load skills status",
|
logLabel: "[Integrations] Failed to load skills status",
|
||||||
});
|
});
|
||||||
|
|
||||||
const setStatus = useCallback(
|
const installMutation = useMutation<InstallStatus, Error>({
|
||||||
(next: SkillsStatus) => {
|
|
||||||
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
const installMutation = useMutation<SkillsStatus, Error>({
|
|
||||||
mutationFn: installSkills,
|
mutationFn: installSkills,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
reportError({
|
reportError({
|
||||||
@@ -121,65 +104,23 @@ export function useSkillsStatus(): SkillsStatusHookResult {
|
|||||||
logLabel: "[Integrations] Failed to install skills",
|
logLabel: "[Integrations] Failed to install skills",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: setStatus,
|
onSuccess: (nextStatus) => {
|
||||||
});
|
queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
|
||||||
const updateMutation = useMutation<SkillsStatus, Error>({
|
|
||||||
mutationFn: updateSkills,
|
|
||||||
onError: (error) => {
|
|
||||||
reportError({
|
|
||||||
error,
|
|
||||||
message: "Unable to update orchestration skills.",
|
|
||||||
logLabel: "[Integrations] Failed to update skills",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onSuccess: setStatus,
|
|
||||||
});
|
});
|
||||||
|
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
|
||||||
|
|
||||||
const uninstallMutation = useMutation<SkillsStatus, Error>({
|
const refresh = useCallback(() => {
|
||||||
mutationFn: uninstallSkills,
|
void refetch();
|
||||||
onError: (error) => {
|
|
||||||
reportError({
|
|
||||||
error,
|
|
||||||
message: "Unable to uninstall orchestration skills.",
|
|
||||||
logLabel: "[Integrations] Failed to uninstall skills",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: setStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isWorking =
|
|
||||||
installMutation.isPending || updateMutation.isPending || uninstallMutation.isPending;
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
await refetch();
|
|
||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
const install = useCallback(async () => {
|
|
||||||
await installMutation.mutateAsync().catch(() => undefined);
|
|
||||||
}, [installMutation]);
|
|
||||||
|
|
||||||
const update = useCallback(async () => {
|
|
||||||
await updateMutation.mutateAsync().catch(() => undefined);
|
|
||||||
}, [updateMutation]);
|
|
||||||
|
|
||||||
const uninstall = useCallback(async () => {
|
|
||||||
await uninstallMutation.mutateAsync().catch(() => undefined);
|
|
||||||
}, [uninstallMutation]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: status ?? null,
|
status: installStatus ?? null,
|
||||||
isLoading,
|
isLoading,
|
||||||
isWorking,
|
isInstalling,
|
||||||
error:
|
error: statusError ?? installError ?? null,
|
||||||
statusError ??
|
|
||||||
installMutation.error ??
|
|
||||||
updateMutation.error ??
|
|
||||||
uninstallMutation.error ??
|
|
||||||
null,
|
|
||||||
refresh,
|
|
||||||
install,
|
install,
|
||||||
update,
|
refresh,
|
||||||
uninstall,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,21 @@ describe("UpdateCalloutSource", () => {
|
|||||||
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows only the changelog action once the update is installed", async () => {
|
||||||
|
updaterState.value = {
|
||||||
|
...updaterState.value,
|
||||||
|
status: "installed",
|
||||||
|
availableUpdate: null,
|
||||||
|
};
|
||||||
|
await renderHarness(root!);
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain("Update installed");
|
||||||
|
expect(
|
||||||
|
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
|
||||||
|
).toContain("What's new");
|
||||||
|
expect(container?.querySelector('[data-testid="update-callout-action-1"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("disables the install action and shows Installing... while installing", async () => {
|
it("disables the install action and shows Installing... while installing", async () => {
|
||||||
updaterState.value = {
|
updaterState.value = {
|
||||||
...updaterState.value,
|
...updaterState.value,
|
||||||
|
|||||||
@@ -13,18 +13,25 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
|||||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||||
|
|
||||||
function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string {
|
function resolveUpdateCalloutTitle(args: {
|
||||||
|
isInstalled: boolean;
|
||||||
|
isInstalling: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
}): string {
|
||||||
|
if (args.isInstalled) return "Update installed";
|
||||||
if (args.isInstalling) return "Installing update";
|
if (args.isInstalling) return "Installing update";
|
||||||
if (args.isError) return "Update failed";
|
if (args.isError) return "Update failed";
|
||||||
return "Update available";
|
return "Update available";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveUpdateCalloutDescription(args: {
|
function resolveUpdateCalloutDescription(args: {
|
||||||
|
isInstalled: boolean;
|
||||||
isInstalling: boolean;
|
isInstalling: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
latestVersion: string | undefined;
|
latestVersion: string | undefined;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
|
if (args.isInstalled) return "Restart to use the new version.";
|
||||||
if (args.isInstalling) return "Installing and restarting...";
|
if (args.isInstalling) return "Installing and restarting...";
|
||||||
if (args.isError) return args.errorMessage ?? "Something went wrong.";
|
if (args.isError) return args.errorMessage ?? "Something went wrong.";
|
||||||
if (args.latestVersion) {
|
if (args.latestVersion) {
|
||||||
@@ -36,6 +43,7 @@ function resolveUpdateCalloutDescription(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildUpdateCalloutActions(args: {
|
function buildUpdateCalloutActions(args: {
|
||||||
|
isInstalled: boolean;
|
||||||
isInstalling: boolean;
|
isInstalling: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
openChangelog: () => void;
|
openChangelog: () => void;
|
||||||
@@ -45,7 +53,7 @@ function buildUpdateCalloutActions(args: {
|
|||||||
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
|
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
|
||||||
if (args.isError) {
|
if (args.isError) {
|
||||||
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
|
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
|
||||||
} else {
|
} else if (!args.isInstalled) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: args.isInstalling ? "Installing..." : "Install & restart",
|
label: args.isInstalling ? "Installing..." : "Install & restart",
|
||||||
onPress: args.install,
|
onPress: args.install,
|
||||||
@@ -99,21 +107,29 @@ export function UpdateCalloutSource() {
|
|||||||
if (!isDesktopApp) {
|
if (!isDesktopApp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status !== "available" && status !== "installing" && status !== "error") {
|
if (
|
||||||
|
status !== "available" &&
|
||||||
|
status !== "installed" &&
|
||||||
|
status !== "installing" &&
|
||||||
|
status !== "error"
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isInstalled = status === "installed";
|
||||||
const isError = status === "error";
|
const isError = status === "error";
|
||||||
const isAvailable = !isInstalling && !isError;
|
const isAvailable = !isInstalled && !isInstalling && !isError;
|
||||||
|
|
||||||
const title = resolveUpdateCalloutTitle({ isInstalling, isError });
|
const title = resolveUpdateCalloutTitle({ isInstalled, isInstalling, isError });
|
||||||
const description = resolveUpdateCalloutDescription({
|
const description = resolveUpdateCalloutDescription({
|
||||||
|
isInstalled,
|
||||||
isInstalling,
|
isInstalling,
|
||||||
isError,
|
isError,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
latestVersion: availableUpdate?.latestVersion ?? undefined,
|
latestVersion: availableUpdate?.latestVersion ?? undefined,
|
||||||
});
|
});
|
||||||
const actions = buildUpdateCalloutActions({
|
const actions = buildUpdateCalloutActions({
|
||||||
|
isInstalled,
|
||||||
isInstalling,
|
isInstalling,
|
||||||
isError,
|
isError,
|
||||||
openChangelog,
|
openChangelog,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import type { ComponentType, ReactNode } from "react";
|
import type { ComponentType, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -745,19 +745,16 @@ function SettingsSidebar({
|
|||||||
) : null}
|
) : null}
|
||||||
<View style={sidebarStyles.list}>
|
<View style={sidebarStyles.list}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Fragment key={item.id}>
|
<SidebarSectionButton
|
||||||
<SidebarSectionButton
|
key={item.id}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
isSelected={selectedSectionId === item.id}
|
isSelected={selectedSectionId === item.id}
|
||||||
onSelect={onSelectSection}
|
onSelect={onSelectSection}
|
||||||
/>
|
/>
|
||||||
{item.id === "general" ? (
|
|
||||||
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
|
||||||
) : null}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
))}
|
||||||
|
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
||||||
</View>
|
</View>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<View style={sidebarStyles.list}>
|
<View style={sidebarStyles.list}>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||||
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
|
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
|
||||||
@@ -28,7 +27,6 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
|||||||
import { isAbsolutePath } from "@/utils/path";
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
|
|
||||||
|
|
||||||
interface WorkspaceOpenInEditorButtonProps {
|
interface WorkspaceOpenInEditorButtonProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -40,7 +38,6 @@ interface OpenTarget {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: ReactElement;
|
icon: ReactElement;
|
||||||
requiresLocalDaemon: boolean;
|
|
||||||
onOpen: () => Promise<void> | void;
|
onOpen: () => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,16 +82,14 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
|
||||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||||
|
|
||||||
const shouldQueryWorkspace =
|
const shouldLoadTargets =
|
||||||
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||||
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
|
|
||||||
|
|
||||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||||
queryKey: ["available-editors", serverId],
|
queryKey: ["available-editors", serverId],
|
||||||
enabled: shouldLoadEditorTargets,
|
enabled: shouldLoadTargets,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -117,7 +112,7 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
|
|
||||||
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd: shouldQueryWorkspace ? cwd : "",
|
cwd: shouldLoadTargets ? cwd : "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorTargets = useMemo<OpenTarget[]>(
|
const editorTargets = useMemo<OpenTarget[]>(
|
||||||
@@ -126,7 +121,6 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
id: editor.id,
|
id: editor.id,
|
||||||
label: editor.label,
|
label: editor.label,
|
||||||
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
||||||
requiresLocalDaemon: true,
|
|
||||||
onOpen: async () => {
|
onOpen: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
@@ -155,20 +149,13 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
id: "github",
|
id: "github",
|
||||||
label: "GitHub",
|
label: "GitHub",
|
||||||
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
||||||
requiresLocalDaemon: false,
|
|
||||||
onOpen: () => openExternalUrl(url),
|
onOpen: () => openExternalUrl(url),
|
||||||
};
|
};
|
||||||
}, [checkoutStatus]);
|
}, [checkoutStatus]);
|
||||||
|
|
||||||
const targets = useMemo(
|
const targets = useMemo(
|
||||||
() =>
|
() => (githubTarget ? [...editorTargets, githubTarget] : editorTargets),
|
||||||
filterTargetsForDaemonLocation(
|
[editorTargets, githubTarget],
|
||||||
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
|
|
||||||
{
|
|
||||||
isLocalDaemon,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
[editorTargets, githubTarget, isLocalDaemon],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
||||||
@@ -223,7 +210,7 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
}
|
}
|
||||||
}, [primaryOption, handleOpenTarget]);
|
}, [primaryOption, handleOpenTarget]);
|
||||||
|
|
||||||
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) {
|
if (!shouldLoadTargets || !primaryOption || targets.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
|
|
||||||
|
|
||||||
describe("filterTargetsForDaemonLocation", () => {
|
|
||||||
const targets = [
|
|
||||||
{ id: "cursor", requiresLocalDaemon: true },
|
|
||||||
{ id: "vscode", requiresLocalDaemon: true },
|
|
||||||
{ id: "github", requiresLocalDaemon: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
it("keeps local app targets and URL targets for the local daemon", () => {
|
|
||||||
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides local app targets for a remote daemon", () => {
|
|
||||||
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([
|
|
||||||
{ id: "github", requiresLocalDaemon: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves target order after filtering", () => {
|
|
||||||
expect(
|
|
||||||
filterTargetsForDaemonLocation(
|
|
||||||
[
|
|
||||||
{ id: "github", requiresLocalDaemon: false },
|
|
||||||
{ id: "finder", requiresLocalDaemon: true },
|
|
||||||
{ id: "docs", requiresLocalDaemon: false },
|
|
||||||
],
|
|
||||||
{ isLocalDaemon: false },
|
|
||||||
),
|
|
||||||
).toEqual([
|
|
||||||
{ id: "github", requiresLocalDaemon: false },
|
|
||||||
{ id: "docs", requiresLocalDaemon: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export interface WorkspaceOpenTargetAvailability {
|
|
||||||
requiresLocalDaemon: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterTargetsForDaemonLocation<Target extends WorkspaceOpenTargetAvailability>(
|
|
||||||
targets: readonly Target[],
|
|
||||||
input: { isLocalDaemon: boolean },
|
|
||||||
): Target[] {
|
|
||||||
if (input.isLocalDaemon) {
|
|
||||||
return [...targets];
|
|
||||||
}
|
|
||||||
return targets.filter((target) => !target.requiresLocalDaemon);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.70",
|
"version": "0.1.69",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/server": "0.1.70",
|
"@getpaseo/server": "0.1.69",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -60,11 +60,6 @@ const EXPECTED_CLAUDE_MODELS = [
|
|||||||
model: "Opus 4.6 1M",
|
model: "Opus 4.6 1M",
|
||||||
descriptionFragment: "1M context window",
|
descriptionFragment: "1M context window",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "claude-sonnet-4-6[1m]",
|
|
||||||
model: "Sonnet 4.6 1M",
|
|
||||||
descriptionFragment: "1M context window",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "claude-sonnet-4-6",
|
id: "claude-sonnet-4-6",
|
||||||
model: "Sonnet 4.6",
|
model: "Sonnet 4.6",
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ setlocal
|
|||||||
set "SCRIPT_DIR=%~dp0"
|
set "SCRIPT_DIR=%~dp0"
|
||||||
set "RESOURCES_DIR=%SCRIPT_DIR%.."
|
set "RESOURCES_DIR=%SCRIPT_DIR%.."
|
||||||
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
|
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
|
||||||
|
set "CLI_EXECUTABLE=%RESOURCES_DIR%\..\PaseoCli.exe"
|
||||||
if not exist "%APP_EXECUTABLE%" (
|
if not exist "%APP_EXECUTABLE%" (
|
||||||
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
|
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
if not exist "%CLI_EXECUTABLE%" (
|
||||||
|
set "CLI_EXECUTABLE=%APP_EXECUTABLE%"
|
||||||
|
)
|
||||||
|
|
||||||
set "ELECTRON_RUN_AS_NODE=1"
|
set "ELECTRON_RUN_AS_NODE=1"
|
||||||
set "PASEO_NODE_ENV=production"
|
set "PASEO_NODE_ENV=production"
|
||||||
"%APP_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
"%CLI_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||||
exit /b %errorlevel%
|
exit /b %errorlevel%
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.70",
|
"version": "0.1.69",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"homepage": "https://paseo.sh",
|
"homepage": "https://paseo.sh",
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ const path = require("path");
|
|||||||
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
|
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
|
||||||
|
|
||||||
const EXECUTABLE_NAME = "Paseo";
|
const EXECUTABLE_NAME = "Paseo";
|
||||||
|
const WINDOWS_CLI_EXECUTABLE_NAME = "PaseoCli";
|
||||||
|
const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
|
||||||
|
const PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER = 0x5c;
|
||||||
|
|
||||||
// electron-builder arch enum → Node.js arch string
|
// electron-builder arch enum → Node.js arch string
|
||||||
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
|
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
|
||||||
@@ -53,22 +56,10 @@ function pruneOnnxRuntime(nodeModules, platform, arch) {
|
|||||||
function pruneClaudeAgentSdk(nodeModules, platform, arch) {
|
function pruneClaudeAgentSdk(nodeModules, platform, arch) {
|
||||||
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
|
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
|
||||||
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
|
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
|
||||||
if (keepName) {
|
if (!keepName) return;
|
||||||
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
|
|
||||||
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// SDK ≥0.2.113 ships per-platform Claude Code binaries via optionalDependencies
|
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
|
||||||
// (~210 MB each). Paseo requires user-installed `claude` on PATH, matching how
|
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
|
||||||
// Codex/OpenCode are integrated, so drop every bundled copy.
|
|
||||||
const anthropicDir = path.join(nodeModules, "@anthropic-ai");
|
|
||||||
if (fs.existsSync(anthropicDir)) {
|
|
||||||
for (const entry of fs.readdirSync(anthropicDir)) {
|
|
||||||
if (entry.startsWith("claude-agent-sdk-")) {
|
|
||||||
rmSafe(path.join(anthropicDir, entry));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneNodePty(nodeModules, platform, arch) {
|
function pruneNodePty(nodeModules, platform, arch) {
|
||||||
@@ -133,12 +124,50 @@ function fmtMB(bytes) {
|
|||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWindowsExecutableSubsystem(filePath, subsystem) {
|
||||||
|
const fd = fs.openSync(filePath, "r+");
|
||||||
|
try {
|
||||||
|
const dosHeader = Buffer.alloc(64);
|
||||||
|
fs.readSync(fd, dosHeader, 0, dosHeader.length, 0);
|
||||||
|
if (dosHeader.toString("ascii", 0, 2) !== "MZ") {
|
||||||
|
throw new Error(`Invalid Windows executable DOS header: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const peHeaderOffset = dosHeader.readUInt32LE(0x3c);
|
||||||
|
const peSignature = Buffer.alloc(4);
|
||||||
|
fs.readSync(fd, peSignature, 0, peSignature.length, peHeaderOffset);
|
||||||
|
if (peSignature.toString("ascii") !== "PE\u0000\u0000") {
|
||||||
|
throw new Error(`Invalid Windows executable PE header: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subsystemOffset = peHeaderOffset + PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER;
|
||||||
|
const subsystemBuffer = Buffer.alloc(2);
|
||||||
|
subsystemBuffer.writeUInt16LE(subsystem);
|
||||||
|
fs.writeSync(fd, subsystemBuffer, 0, subsystemBuffer.length, subsystemOffset);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindowsCliExecutable(appOutDir) {
|
||||||
|
const appExecutable = path.join(appOutDir, `${EXECUTABLE_NAME}.exe`);
|
||||||
|
const cliExecutable = path.join(appOutDir, `${WINDOWS_CLI_EXECUTABLE_NAME}.exe`);
|
||||||
|
|
||||||
|
fs.copyFileSync(appExecutable, cliExecutable);
|
||||||
|
setWindowsExecutableSubsystem(cliExecutable, IMAGE_SUBSYSTEM_WINDOWS_CUI);
|
||||||
|
console.log(`Created Windows CLI executable: ${cliExecutable}`);
|
||||||
|
}
|
||||||
|
|
||||||
exports.default = async function afterPack(context) {
|
exports.default = async function afterPack(context) {
|
||||||
const platform = context.electronPlatformName;
|
const platform = context.electronPlatformName;
|
||||||
const arch = ARCH_MAP[context.arch] || process.arch;
|
const arch = ARCH_MAP[context.arch] || process.arch;
|
||||||
|
|
||||||
pruneNativeModules(context.appOutDir, platform, arch);
|
pruneNativeModules(context.appOutDir, platform, arch);
|
||||||
|
|
||||||
|
if (platform === "win32") {
|
||||||
|
createWindowsCliExecutable(context.appOutDir);
|
||||||
|
}
|
||||||
|
|
||||||
if (platform === "linux" || platform === "win32") {
|
if (platform === "linux" || platform === "win32") {
|
||||||
if (arch !== process.arch) {
|
if (arch !== process.arch) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -159,3 +188,6 @@ async function smokeUnpackedAppIfRequested(appOutDir) {
|
|||||||
appPath: appOutDir,
|
appPath: appOutDir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.createWindowsCliExecutable = createWindowsCliExecutable;
|
||||||
|
exports.setWindowsExecutableSubsystem = setWindowsExecutableSubsystem;
|
||||||
|
|||||||
@@ -17,13 +17,12 @@ import {
|
|||||||
downloadAndInstallUpdate,
|
downloadAndInstallUpdate,
|
||||||
type AppReleaseChannel,
|
type AppReleaseChannel,
|
||||||
} from "../features/auto-updater.js";
|
} from "../features/auto-updater.js";
|
||||||
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
|
|
||||||
import {
|
import {
|
||||||
getSkillsStatus,
|
installCli,
|
||||||
|
getCliInstallStatus,
|
||||||
installSkills,
|
installSkills,
|
||||||
uninstallSkills,
|
getSkillsInstallStatus,
|
||||||
updateSkills,
|
} from "../integrations/integrations-manager.js";
|
||||||
} from "../integrations/skills/index.js";
|
|
||||||
import {
|
import {
|
||||||
openLocalTransportSession,
|
openLocalTransportSession,
|
||||||
sendLocalTransportMessage,
|
sendLocalTransportMessage,
|
||||||
@@ -532,10 +531,8 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
|||||||
get_local_daemon_version: () => getLocalDaemonVersion(),
|
get_local_daemon_version: () => getLocalDaemonVersion(),
|
||||||
install_cli: () => installCli(),
|
install_cli: () => installCli(),
|
||||||
get_cli_install_status: () => getCliInstallStatus(),
|
get_cli_install_status: () => getCliInstallStatus(),
|
||||||
get_skills_status: () => getSkillsStatus(),
|
|
||||||
install_skills: () => installSkills(),
|
install_skills: () => installSkills(),
|
||||||
update_skills: () => updateSkills(),
|
get_skills_install_status: () => getSkillsInstallStatus(),
|
||||||
uninstall_skills: () => uninstallSkills(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
describe("desktop packaging", () => {
|
describe("desktop packaging", () => {
|
||||||
it("unpacks server zsh shell integration files for external shells", () => {
|
it("unpacks server zsh shell integration files for external shells", () => {
|
||||||
@@ -33,4 +36,38 @@ describe("desktop packaging", () => {
|
|||||||
expect(deps[required], `${required} must be declared in dependencies`).toBe("*");
|
expect(deps[required], `${required} must be declared in dependencies`).toBe("*");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses a console-subsystem executable for the bundled Windows CLI shim", () => {
|
||||||
|
const cmd = readFileSync(join(packageRoot, "bin", "paseo.cmd"), "utf8");
|
||||||
|
|
||||||
|
expect(cmd).toContain("PaseoCli.exe");
|
||||||
|
expect(cmd).toContain('"%CLI_EXECUTABLE%"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can mark the Windows CLI executable as console subsystem", () => {
|
||||||
|
const { setWindowsExecutableSubsystem } = require(
|
||||||
|
join(packageRoot, "scripts", "after-pack.js"),
|
||||||
|
) as {
|
||||||
|
setWindowsExecutableSubsystem: (filePath: string, subsystem: number) => void;
|
||||||
|
};
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "paseo-pe-subsystem-"));
|
||||||
|
const exePath = join(tempDir, "probe.exe");
|
||||||
|
const peHeaderOffset = 0x80;
|
||||||
|
const subsystemOffset = peHeaderOffset + 0x5c;
|
||||||
|
const bytes = Buffer.alloc(subsystemOffset + 2);
|
||||||
|
bytes.write("MZ", 0, "ascii");
|
||||||
|
bytes.writeUInt32LE(peHeaderOffset, 0x3c);
|
||||||
|
bytes.write("PE\u0000\u0000", peHeaderOffset, "ascii");
|
||||||
|
bytes.writeUInt16LE(2, subsystemOffset);
|
||||||
|
writeFileSync(exePath, bytes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setWindowsExecutableSubsystem(exePath, 3);
|
||||||
|
|
||||||
|
const patched = readFileSync(exePath);
|
||||||
|
expect(patched.readUInt16LE(subsystemOffset)).toBe(3);
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -196,9 +196,16 @@ function buildCheckResult(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> {
|
function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
|
||||||
if (onBeforeQuit) await onBeforeQuit();
|
// Use a short delay to allow the renderer to receive the response.
|
||||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
if (onBeforeQuit) await onBeforeQuit();
|
||||||
|
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[auto-updater] quitAndInstall failed:", error);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -307,7 +314,7 @@ export async function downloadAndInstallUpdate(
|
|||||||
|
|
||||||
const readyVersion = cachedUpdateInfo.version;
|
const readyVersion = cachedUpdateInfo.version;
|
||||||
if (isReadyToInstallVersion(readyVersion)) {
|
if (isReadyToInstallVersion(readyVersion)) {
|
||||||
await performQuitAndInstall(onBeforeQuit);
|
scheduleQuitAndInstall(onBeforeQuit);
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
version: readyVersion,
|
version: readyVersion,
|
||||||
@@ -329,7 +336,7 @@ export async function downloadAndInstallUpdate(
|
|||||||
await autoUpdater.downloadUpdate();
|
await autoUpdater.downloadUpdate();
|
||||||
downloadedUpdateVersion = readyVersion;
|
downloadedUpdateVersion = readyVersion;
|
||||||
downloading = false;
|
downloading = false;
|
||||||
await performQuitAndInstall(onBeforeQuit);
|
scheduleQuitAndInstall(onBeforeQuit);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveCliInstallSourcePath } from "./path";
|
import { resolveCliInstallSourcePath } from "./cli-install-path";
|
||||||
|
|
||||||
describe("cli-install-path", () => {
|
describe("cli-install-path", () => {
|
||||||
it("uses the bundled shim for packaged macOS installs", () => {
|
it("uses the bundled shim for packaged macOS installs", () => {
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { getCliInstallStatus, installCli } from "./install.js";
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { promises as fs } from "node:fs";
|
|
||||||
import { app } from "electron";
|
|
||||||
import log from "electron-log/main";
|
|
||||||
import { resolveCliInstallSourcePath } from "./path.js";
|
|
||||||
import { getBundledCliShimPath, getCliTargetPath, getLocalBinDir } from "./paths.js";
|
|
||||||
import { ensurePathInShellRc } from "./shell-rc.js";
|
|
||||||
|
|
||||||
interface InstallStatus {
|
|
||||||
installed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await fs.lstat(p);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installCli(): Promise<InstallStatus> {
|
|
||||||
const targetPath = getCliTargetPath();
|
|
||||||
const shimPath = getBundledCliShimPath();
|
|
||||||
const installSourcePath = resolveCliInstallSourcePath({
|
|
||||||
platform: process.platform,
|
|
||||||
isPackaged: app.isPackaged,
|
|
||||||
executablePath: app.getPath("exe"),
|
|
||||||
shimPath,
|
|
||||||
appImagePath: process.env.APPIMAGE,
|
|
||||||
});
|
|
||||||
const binDir = getLocalBinDir();
|
|
||||||
|
|
||||||
await fs.mkdir(binDir, { recursive: true });
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
if (await pathOrSymlinkExists(targetPath)) {
|
|
||||||
await fs.unlink(targetPath);
|
|
||||||
}
|
|
||||||
// Generate a thin .cmd trampoline that delegates to the bundled shim.
|
|
||||||
// Only the app install path is baked in — internal details (asar layout,
|
|
||||||
// entrypoint scripts) live in the bundled shim and update with the app.
|
|
||||||
const cmdContent = [
|
|
||||||
"@echo off",
|
|
||||||
`set "BUNDLED_CLI=${shimPath}"`,
|
|
||||||
`if not exist "%BUNDLED_CLI%" (`,
|
|
||||||
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
|
|
||||||
` exit /b 1`,
|
|
||||||
`)`,
|
|
||||||
`call "%BUNDLED_CLI%" %*`,
|
|
||||||
`exit /b %errorlevel%`,
|
|
||||||
].join("\r\n");
|
|
||||||
await fs.writeFile(targetPath, cmdContent, "utf-8");
|
|
||||||
} else {
|
|
||||||
if (await pathOrSymlinkExists(targetPath)) {
|
|
||||||
await fs.unlink(targetPath);
|
|
||||||
}
|
|
||||||
await fs.symlink(installSourcePath, targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { shellUpdated } = await ensurePathInShellRc();
|
|
||||||
if (shellUpdated) {
|
|
||||||
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
|
|
||||||
}
|
|
||||||
|
|
||||||
return getCliInstallStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCliInstallStatus(): Promise<InstallStatus> {
|
|
||||||
const targetPath = getCliTargetPath();
|
|
||||||
return { installed: await pathOrSymlinkExists(targetPath) };
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import os from "node:os";
|
|
||||||
import { app } from "electron";
|
|
||||||
|
|
||||||
export function getLocalBinDir(): string {
|
|
||||||
return path.join(os.homedir(), ".local", "bin");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCliTargetPath(): string {
|
|
||||||
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
|
||||||
return path.join(getLocalBinDir(), filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBundledCliShimPath(): string {
|
|
||||||
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
|
||||||
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
const electronExePath = app.getPath("exe");
|
|
||||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
|
||||||
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
const electronExePath = app.getPath("exe");
|
|
||||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Linux
|
|
||||||
const electronExePath = app.getPath("exe");
|
|
||||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { promises as fs } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import os from "node:os";
|
|
||||||
import log from "electron-log/main";
|
|
||||||
|
|
||||||
export interface ShellRcInfo {
|
|
||||||
shell: string;
|
|
||||||
rcFile: string;
|
|
||||||
pathCheckPattern: RegExp;
|
|
||||||
exportLine: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await fs.lstat(p);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectShellRcInfo(): ShellRcInfo | null {
|
|
||||||
if (process.platform === "win32") return null;
|
|
||||||
|
|
||||||
const shell = process.env.SHELL;
|
|
||||||
if (!shell) return null;
|
|
||||||
|
|
||||||
const shellName = path.basename(shell);
|
|
||||||
|
|
||||||
if (shellName === "zsh") {
|
|
||||||
return {
|
|
||||||
shell: "zsh",
|
|
||||||
rcFile: path.join(os.homedir(), ".zshrc"),
|
|
||||||
pathCheckPattern: /\.local\/bin/,
|
|
||||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shellName === "bash") {
|
|
||||||
const rcFile =
|
|
||||||
process.platform === "darwin"
|
|
||||||
? path.join(os.homedir(), ".bash_profile")
|
|
||||||
: path.join(os.homedir(), ".bashrc");
|
|
||||||
return {
|
|
||||||
shell: "bash",
|
|
||||||
rcFile,
|
|
||||||
pathCheckPattern: /\.local\/bin/,
|
|
||||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shellName === "fish") {
|
|
||||||
return {
|
|
||||||
shell: "fish",
|
|
||||||
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
|
|
||||||
pathCheckPattern: /\.local\/bin/,
|
|
||||||
exportLine: "fish_add_path $HOME/.local/bin",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pathAlreadyContainsLocalBin(): boolean {
|
|
||||||
const pathEnv = process.env.PATH ?? "";
|
|
||||||
const localBin = path.join(os.homedir(), ".local", "bin");
|
|
||||||
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
|
|
||||||
if (pathAlreadyContainsLocalBin()) {
|
|
||||||
return { shellUpdated: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = detectShellRcInfo();
|
|
||||||
if (!info) {
|
|
||||||
return { shellUpdated: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const exists = await pathOrSymlinkExists(info.rcFile);
|
|
||||||
if (exists) {
|
|
||||||
const content = await fs.readFile(info.rcFile, "utf-8");
|
|
||||||
if (info.pathCheckPattern.test(content)) {
|
|
||||||
return { shellUpdated: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
|
|
||||||
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
|
|
||||||
|
|
||||||
return { shellUpdated: true };
|
|
||||||
} catch (err) {
|
|
||||||
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
|
|
||||||
return { shellUpdated: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { app } from "electron";
|
||||||
|
import log from "electron-log/main";
|
||||||
|
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
|
||||||
|
import { syncSkills } from "./skill-sync.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface InstallStatus {
|
||||||
|
installed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SKILL_NAMES = [
|
||||||
|
"paseo",
|
||||||
|
"paseo-advisor",
|
||||||
|
"paseo-committee",
|
||||||
|
"paseo-epic",
|
||||||
|
"paseo-handoff",
|
||||||
|
"paseo-loop",
|
||||||
|
"paseo-orchestrate",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filesystem helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.lstat(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getLocalBinDir(): string {
|
||||||
|
return path.join(os.homedir(), ".local", "bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCliTargetPath(): string {
|
||||||
|
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||||
|
return path.join(getLocalBinDir(), filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBundledCliShimPath(): string {
|
||||||
|
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||||
|
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
const electronExePath = app.getPath("exe");
|
||||||
|
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||||
|
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const electronExePath = app.getPath("exe");
|
||||||
|
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux
|
||||||
|
const electronExePath = app.getPath("exe");
|
||||||
|
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBundledSkillsDir(): string {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return path.join(process.resourcesPath, "skills");
|
||||||
|
}
|
||||||
|
return path.join(__dirname, "..", "..", "..", "..", "skills");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentsSkillsDir(): string {
|
||||||
|
return path.join(os.homedir(), ".agents", "skills");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClaudeSkillsDir(): string {
|
||||||
|
return path.join(os.homedir(), ".claude", "skills");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCodexSkillsDir(): string {
|
||||||
|
return path.join(os.homedir(), ".codex", "skills");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shell PATH helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ShellRcInfo {
|
||||||
|
shell: string;
|
||||||
|
rcFile: string;
|
||||||
|
pathCheckPattern: RegExp;
|
||||||
|
exportLine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectShellRcInfo(): ShellRcInfo | null {
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
|
const shell = process.env.SHELL;
|
||||||
|
if (!shell) return null;
|
||||||
|
|
||||||
|
const shellName = path.basename(shell);
|
||||||
|
|
||||||
|
if (shellName === "zsh") {
|
||||||
|
return {
|
||||||
|
shell: "zsh",
|
||||||
|
rcFile: path.join(os.homedir(), ".zshrc"),
|
||||||
|
pathCheckPattern: /\.local\/bin/,
|
||||||
|
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shellName === "bash") {
|
||||||
|
const rcFile =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? path.join(os.homedir(), ".bash_profile")
|
||||||
|
: path.join(os.homedir(), ".bashrc");
|
||||||
|
return {
|
||||||
|
shell: "bash",
|
||||||
|
rcFile,
|
||||||
|
pathCheckPattern: /\.local\/bin/,
|
||||||
|
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shellName === "fish") {
|
||||||
|
return {
|
||||||
|
shell: "fish",
|
||||||
|
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
|
||||||
|
pathCheckPattern: /\.local\/bin/,
|
||||||
|
exportLine: "fish_add_path $HOME/.local/bin",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathAlreadyContainsLocalBin(): boolean {
|
||||||
|
const pathEnv = process.env.PATH ?? "";
|
||||||
|
const localBin = path.join(os.homedir(), ".local", "bin");
|
||||||
|
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
|
||||||
|
if (pathAlreadyContainsLocalBin()) {
|
||||||
|
return { shellUpdated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = detectShellRcInfo();
|
||||||
|
if (!info) {
|
||||||
|
return { shellUpdated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exists = await pathOrSymlinkExists(info.rcFile);
|
||||||
|
if (exists) {
|
||||||
|
const content = await fs.readFile(info.rcFile, "utf-8");
|
||||||
|
if (info.pathCheckPattern.test(content)) {
|
||||||
|
return { shellUpdated: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
|
||||||
|
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
|
||||||
|
|
||||||
|
return { shellUpdated: true };
|
||||||
|
} catch (err) {
|
||||||
|
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
|
||||||
|
return { shellUpdated: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CLI Installation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function installCli(): Promise<InstallStatus> {
|
||||||
|
const targetPath = getCliTargetPath();
|
||||||
|
const shimPath = getBundledCliShimPath();
|
||||||
|
const installSourcePath = resolveCliInstallSourcePath({
|
||||||
|
platform: process.platform,
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
executablePath: app.getPath("exe"),
|
||||||
|
shimPath,
|
||||||
|
appImagePath: process.env.APPIMAGE,
|
||||||
|
});
|
||||||
|
const binDir = getLocalBinDir();
|
||||||
|
|
||||||
|
await fs.mkdir(binDir, { recursive: true });
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
if (await pathOrSymlinkExists(targetPath)) {
|
||||||
|
await fs.unlink(targetPath);
|
||||||
|
}
|
||||||
|
// Generate a thin .cmd trampoline that delegates to the bundled shim.
|
||||||
|
// Only the app install path is baked in — internal details (asar layout,
|
||||||
|
// entrypoint scripts) live in the bundled shim and update with the app.
|
||||||
|
const cmdContent = [
|
||||||
|
"@echo off",
|
||||||
|
`set "BUNDLED_CLI=${shimPath}"`,
|
||||||
|
`if not exist "%BUNDLED_CLI%" (`,
|
||||||
|
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
|
||||||
|
` exit /b 1`,
|
||||||
|
`)`,
|
||||||
|
`call "%BUNDLED_CLI%" %*`,
|
||||||
|
`exit /b %errorlevel%`,
|
||||||
|
].join("\r\n");
|
||||||
|
await fs.writeFile(targetPath, cmdContent, "utf-8");
|
||||||
|
} else {
|
||||||
|
if (await pathOrSymlinkExists(targetPath)) {
|
||||||
|
await fs.unlink(targetPath);
|
||||||
|
}
|
||||||
|
await fs.symlink(installSourcePath, targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { shellUpdated } = await ensurePathInShellRc();
|
||||||
|
if (shellUpdated) {
|
||||||
|
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCliInstallStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCliInstallStatus(): Promise<InstallStatus> {
|
||||||
|
const targetPath = getCliTargetPath();
|
||||||
|
return { installed: await pathOrSymlinkExists(targetPath) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skills Installation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getSkillSyncTargets(): {
|
||||||
|
sourceDir: string;
|
||||||
|
agentsDir: string;
|
||||||
|
claudeDir: string;
|
||||||
|
codexDir: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
sourceDir: getBundledSkillsDir(),
|
||||||
|
agentsDir: getAgentsSkillsDir(),
|
||||||
|
claudeDir: getClaudeSkillsDir(),
|
||||||
|
codexDir: getCodexSkillsDir(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installSkills(): Promise<InstallStatus> {
|
||||||
|
const targets = getSkillSyncTargets();
|
||||||
|
log.info("[integrations] installSkills", targets);
|
||||||
|
|
||||||
|
const result = await syncSkills({
|
||||||
|
...targets,
|
||||||
|
skillNames: SKILL_NAMES,
|
||||||
|
onSkillError: (skillName, error) => {
|
||||||
|
log.warn("[integrations] skill install failed", { skillName, error });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("[integrations] installSkills done", result);
|
||||||
|
return getSkillsInstallStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoUpdateSkillsIfInstalled(): Promise<{
|
||||||
|
ran: boolean;
|
||||||
|
changedFiles: number;
|
||||||
|
processedSkills: number;
|
||||||
|
}> {
|
||||||
|
const targets = getSkillSyncTargets();
|
||||||
|
const installedMarker = path.join(targets.agentsDir, "paseo", "SKILL.md");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(installedMarker);
|
||||||
|
} catch {
|
||||||
|
return { ran: false, changedFiles: 0, processedSkills: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncSkills({
|
||||||
|
...targets,
|
||||||
|
skillNames: SKILL_NAMES,
|
||||||
|
onSkillError: (skillName, error) => {
|
||||||
|
log.warn("[integrations] skill auto-update failed", { skillName, error });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.changedFiles > 0) {
|
||||||
|
log.info("[integrations] auto-updated paseo skills", result);
|
||||||
|
} else {
|
||||||
|
log.info("[integrations] paseo skills already up to date", result);
|
||||||
|
}
|
||||||
|
return { ran: true, ...result };
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("[integrations] auto-update skills aborted", { error });
|
||||||
|
return { ran: false, changedFiles: 0, processedSkills: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||||
|
const claudeDir = getClaudeSkillsDir();
|
||||||
|
const accessResults = await Promise.all(
|
||||||
|
SKILL_NAMES.map((skillName) =>
|
||||||
|
fs
|
||||||
|
.access(path.join(claudeDir, skillName, "SKILL.md"))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { installed: accessResults.every(Boolean) };
|
||||||
|
}
|
||||||
+5
-53
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { removeSkill, syncSkills } from "./sync";
|
import { syncSkills } from "./skill-sync";
|
||||||
|
|
||||||
interface Sandbox {
|
interface Sandbox {
|
||||||
root: string;
|
root: string;
|
||||||
@@ -70,11 +70,6 @@ describe("syncSkills", () => {
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
expect(agentsContent).toBe("new paseo content");
|
expect(agentsContent).toBe("new paseo content");
|
||||||
const claudeContent = await fs.readFile(
|
|
||||||
path.join(sandbox.claudeDir, "paseo", "SKILL.md"),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
expect(claudeContent).toBe("new paseo content");
|
|
||||||
const codexContent = await fs.readFile(
|
const codexContent = await fs.readFile(
|
||||||
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
|
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -112,10 +107,10 @@ describe("syncSkills", () => {
|
|||||||
),
|
),
|
||||||
).toBe("roles content");
|
).toBe("roles content");
|
||||||
|
|
||||||
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic");
|
const claudeLink = path.join(sandbox.claudeDir, "paseo-epic");
|
||||||
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true);
|
const lstat = await fs.lstat(claudeLink);
|
||||||
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content");
|
expect(lstat.isSymbolicLink()).toBe(true);
|
||||||
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe(
|
expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe(
|
||||||
"roles content",
|
"roles content",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -259,46 +254,3 @@ describe("syncSkills", () => {
|
|||||||
expect(result.processedSkills).toBe(0);
|
expect(result.processedSkills).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("removeSkill", () => {
|
|
||||||
let sandbox: Sandbox;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
sandbox = await makeSandbox();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes the skill from all three targets when present", async () => {
|
|
||||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
|
|
||||||
await syncSkills({
|
|
||||||
sourceDir: sandbox.sourceDir,
|
|
||||||
agentsDir: sandbox.agentsDir,
|
|
||||||
claudeDir: sandbox.claudeDir,
|
|
||||||
codexDir: sandbox.codexDir,
|
|
||||||
skillNames: ["paseo"],
|
|
||||||
});
|
|
||||||
|
|
||||||
await removeSkill("paseo", {
|
|
||||||
agentsDir: sandbox.agentsDir,
|
|
||||||
claudeDir: sandbox.claudeDir,
|
|
||||||
codexDir: sandbox.codexDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(fs.access(path.join(sandbox.agentsDir, "paseo"))).rejects.toThrow();
|
|
||||||
await expect(fs.access(path.join(sandbox.claudeDir, "paseo"))).rejects.toThrow();
|
|
||||||
await expect(fs.access(path.join(sandbox.codexDir, "paseo"))).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not throw when targets are missing", async () => {
|
|
||||||
await expect(
|
|
||||||
removeSkill("does-not-exist", {
|
|
||||||
agentsDir: sandbox.agentsDir,
|
|
||||||
claudeDir: sandbox.claudeDir,
|
|
||||||
codexDir: sandbox.codexDir,
|
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+35
-19
@@ -7,6 +7,7 @@ export interface SkillSyncOptions {
|
|||||||
claudeDir: string;
|
claudeDir: string;
|
||||||
codexDir: string;
|
codexDir: string;
|
||||||
skillNames: readonly string[];
|
skillNames: readonly string[];
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
onSkillError?: (skillName: string, error: unknown) => void;
|
onSkillError?: (skillName: string, error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boo
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
async function walk(dir: string): Promise<void> {
|
async function walk(dir: string): Promise<void> {
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
@@ -52,24 +53,38 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<numbe
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoveSkillTargets {
|
async function ensureClaudeSkillLink(
|
||||||
agentsDir: string;
|
skillName: string,
|
||||||
claudeDir: string;
|
agentsDir: string,
|
||||||
codexDir: string;
|
claudeDir: string,
|
||||||
}
|
platform: NodeJS.Platform,
|
||||||
|
): Promise<number> {
|
||||||
|
await fs.mkdir(claudeDir, { recursive: true });
|
||||||
|
const target = path.join(agentsDir, skillName);
|
||||||
|
const linkPath = path.join(claudeDir, skillName);
|
||||||
|
|
||||||
export async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise<void> {
|
// Always rebuild the link rather than diffing it. fs.rm with force: true is
|
||||||
const paths = [
|
// a no-op when nothing is there, and matches existing install behavior.
|
||||||
path.join(targets.agentsDir, skillName),
|
// On Windows, `fs.rm` does not follow junctions, so the agents-side content
|
||||||
path.join(targets.claudeDir, skillName),
|
// is preserved.
|
||||||
path.join(targets.codexDir, skillName),
|
await fs.rm(linkPath, { recursive: true, force: true });
|
||||||
];
|
|
||||||
for (const p of paths) {
|
if (platform === "win32") {
|
||||||
await fs.rm(p, { recursive: true, force: true });
|
try {
|
||||||
|
// Junctions don't require Developer Mode / admin like regular symlinks do.
|
||||||
|
await fs.symlink(target, linkPath, "junction");
|
||||||
|
return 0;
|
||||||
|
} catch {
|
||||||
|
return await syncDirectoryFiles(target, linkPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await fs.symlink(target, linkPath);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
|
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
let changedFiles = 0;
|
let changedFiles = 0;
|
||||||
let processedSkills = 0;
|
let processedSkills = 0;
|
||||||
|
|
||||||
@@ -85,9 +100,11 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
|
|||||||
path.join(options.agentsDir, skillName),
|
path.join(options.agentsDir, skillName),
|
||||||
);
|
);
|
||||||
|
|
||||||
changedFiles += await syncDirectoryFiles(
|
changedFiles += await ensureClaudeSkillLink(
|
||||||
bundleSkillDir,
|
skillName,
|
||||||
path.join(options.claudeDir, skillName),
|
options.agentsDir,
|
||||||
|
options.claudeDir,
|
||||||
|
platform,
|
||||||
);
|
);
|
||||||
|
|
||||||
changedFiles += await syncDirectoryFiles(
|
changedFiles += await syncDirectoryFiles(
|
||||||
@@ -97,8 +114,7 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
|
|||||||
|
|
||||||
processedSkills++;
|
processedSkills++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!options.onSkillError) throw error;
|
options.onSkillError?.(skillName, error);
|
||||||
options.onSkillError(skillName, error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export {
|
|
||||||
getSkillsStatus,
|
|
||||||
installSkills,
|
|
||||||
uninstallSkills,
|
|
||||||
updateSkills,
|
|
||||||
type SkillOp,
|
|
||||||
type SkillsState,
|
|
||||||
type SkillsStatus,
|
|
||||||
type SkillTargets,
|
|
||||||
} from "./operations.js";
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
import { promises as fs } from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("electron", () => ({
|
|
||||||
app: {
|
|
||||||
getPath: vi.fn(() => "/tmp/paseo-user-data"),
|
|
||||||
isPackaged: false,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import {
|
|
||||||
getSkillsStatus,
|
|
||||||
installSkills,
|
|
||||||
PASEO_SKILL_NAMES,
|
|
||||||
type SkillTargets,
|
|
||||||
uninstallSkills,
|
|
||||||
updateSkills,
|
|
||||||
} from "./operations";
|
|
||||||
|
|
||||||
interface Sandbox {
|
|
||||||
root: string;
|
|
||||||
targets: SkillTargets;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeSandbox(): Promise<Sandbox> {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skills-"));
|
|
||||||
const targets: SkillTargets = {
|
|
||||||
sourceDir: path.join(root, "bundle"),
|
|
||||||
agentsDir: path.join(root, "home", ".agents", "skills"),
|
|
||||||
claudeDir: path.join(root, "home", ".claude", "skills"),
|
|
||||||
codexDir: path.join(root, "home", ".codex", "skills"),
|
|
||||||
};
|
|
||||||
await fs.mkdir(targets.sourceDir, { recursive: true });
|
|
||||||
return { root, targets };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeFiles(rootDir: string, files: Record<string, string>): Promise<void> {
|
|
||||||
for (const [rel, content] of Object.entries(files)) {
|
|
||||||
const full = path.join(rootDir, rel);
|
|
||||||
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
||||||
await fs.writeFile(full, content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeBundleSkill(
|
|
||||||
sourceDir: string,
|
|
||||||
name: string,
|
|
||||||
files: Record<string, string>,
|
|
||||||
): Promise<void> {
|
|
||||||
await writeFiles(path.join(sourceDir, name), files);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeOnDiskSkill(
|
|
||||||
agentsDir: string,
|
|
||||||
name: string,
|
|
||||||
files: Record<string, string>,
|
|
||||||
): Promise<void> {
|
|
||||||
await writeFiles(path.join(agentsDir, name), files);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeCurrentBundle(sourceDir: string): Promise<void> {
|
|
||||||
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
|
|
||||||
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pathExists(p: string): Promise<boolean> {
|
|
||||||
return fs
|
|
||||||
.access(p)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("getSkillsStatus", () => {
|
|
||||||
let sandbox: Sandbox;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
sandbox = await makeSandbox();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns not-installed with add ops for every bundled skill when nothing is on disk", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status.state).toBe("not-installed");
|
|
||||||
expect(status.ops).toEqual([
|
|
||||||
{ kind: "add", name: "paseo" },
|
|
||||||
{ kind: "add", name: "paseo-loop" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns not-installed when only user-personal skill dirs exist (the live bug)", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status.state).toBe("not-installed");
|
|
||||||
expect(status.ops).toEqual([
|
|
||||||
{ kind: "add", name: "paseo" },
|
|
||||||
{ kind: "add", name: "paseo-loop" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns up-to-date when every bundled skill matches on disk", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns drift with a single update op when one bundled file diverges", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status.state).toBe("drift");
|
|
||||||
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns drift with add ops for the bundled skills missing from disk", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status.state).toBe("drift");
|
|
||||||
expect(status.ops).toEqual([{ kind: "add", name: "paseo-loop" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status.state).toBe("drift");
|
|
||||||
expect(status.ops).toEqual([{ kind: "delete", name: "paseo-chat" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status.state).toBe("drift");
|
|
||||||
expect(status.ops).toEqual([
|
|
||||||
{ kind: "update", name: "paseo" },
|
|
||||||
{ kind: "delete", name: "paseo-chat" },
|
|
||||||
{ kind: "add", name: "paseo-loop" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("installSkills / updateSkills", () => {
|
|
||||||
let sandbox: Sandbox;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
sandbox = await makeSandbox();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" });
|
|
||||||
|
|
||||||
const status = await installSkills(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
|
||||||
for (const name of ["paseo", "paseo-loop"]) {
|
|
||||||
expect(
|
|
||||||
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
|
|
||||||
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
|
|
||||||
expect(
|
|
||||||
await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"),
|
|
||||||
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
|
|
||||||
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true);
|
|
||||||
}
|
|
||||||
expect(
|
|
||||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"),
|
|
||||||
).toBe("user-unslop");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converges to up-to-date when state has missing + edited + legacy skills", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
|
||||||
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
|
||||||
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
|
||||||
|
|
||||||
const status = await updateSkills(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
|
||||||
expect(
|
|
||||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
|
|
||||||
).toBe("paseo-v1");
|
|
||||||
expect(
|
|
||||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo-loop", "SKILL.md"), "utf-8"),
|
|
||||||
).toBe("loop-v1");
|
|
||||||
for (const dir of [
|
|
||||||
sandbox.targets.agentsDir,
|
|
||||||
sandbox.targets.claudeDir,
|
|
||||||
sandbox.targets.codexDir,
|
|
||||||
]) {
|
|
||||||
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent — running install twice keeps state at up-to-date", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
|
|
||||||
const first = await installSkills(sandbox.targets);
|
|
||||||
const second = await installSkills(sandbox.targets);
|
|
||||||
|
|
||||||
expect(first).toEqual({ state: "up-to-date", ops: [] });
|
|
||||||
expect(second).toEqual({ state: "up-to-date", ops: [] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("uninstallSkills", () => {
|
|
||||||
let sandbox: Sandbox;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
sandbox = await makeSandbox();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes every Paseo skill from all three targets and preserves user dirs", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
await installSkills(sandbox.targets);
|
|
||||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await uninstallSkills(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status.state).toBe("not-installed");
|
|
||||||
for (const name of PASEO_SKILL_NAMES) {
|
|
||||||
expect(await pathExists(path.join(sandbox.targets.agentsDir, name))).toBe(false);
|
|
||||||
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(false);
|
|
||||||
expect(await pathExists(path.join(sandbox.targets.codexDir, name))).toBe(false);
|
|
||||||
}
|
|
||||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
|
||||||
expect(
|
|
||||||
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
|
|
||||||
).toBe(`user-${name}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op when nothing is installed", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
|
|
||||||
const status = await uninstallSkills(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status.state).toBe("not-installed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cleans up legacy skill names that linger in agents, claude, and codex", async () => {
|
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
|
||||||
for (const dir of [
|
|
||||||
sandbox.targets.agentsDir,
|
|
||||||
sandbox.targets.claudeDir,
|
|
||||||
sandbox.targets.codexDir,
|
|
||||||
]) {
|
|
||||||
await writeOnDiskSkill(dir, "paseo-chat", { "SKILL.md": "chat-old" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await uninstallSkills(sandbox.targets);
|
|
||||||
|
|
||||||
expect(status.state).toBe("not-installed");
|
|
||||||
for (const dir of [
|
|
||||||
sandbox.targets.agentsDir,
|
|
||||||
sandbox.targets.claudeDir,
|
|
||||||
sandbox.targets.codexDir,
|
|
||||||
]) {
|
|
||||||
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { promises as fs } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import {
|
|
||||||
getAgentsSkillsDir,
|
|
||||||
getBundledSkillsDir,
|
|
||||||
getClaudeSkillsDir,
|
|
||||||
getCodexSkillsDir,
|
|
||||||
} from "./paths.js";
|
|
||||||
import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js";
|
|
||||||
|
|
||||||
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
|
||||||
|
|
||||||
export type SkillOp =
|
|
||||||
| { kind: "add"; name: string }
|
|
||||||
| { kind: "update"; name: string }
|
|
||||||
| { kind: "delete"; name: string };
|
|
||||||
|
|
||||||
export interface SkillsStatus {
|
|
||||||
state: SkillsState;
|
|
||||||
ops: SkillOp[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SkillTargets {
|
|
||||||
sourceDir: string;
|
|
||||||
agentsDir: string;
|
|
||||||
claudeDir: string;
|
|
||||||
codexDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PASEO_SKILL_NAMES = [
|
|
||||||
"paseo",
|
|
||||||
"paseo-advisor",
|
|
||||||
"paseo-chat",
|
|
||||||
"paseo-committee",
|
|
||||||
"paseo-epic",
|
|
||||||
"paseo-handoff",
|
|
||||||
"paseo-loop",
|
|
||||||
"paseo-orchestrate",
|
|
||||||
"paseo-orchestrator",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type SkillFiles = Map<string, string>;
|
|
||||||
|
|
||||||
function resolveSkillTargets(): SkillTargets {
|
|
||||||
return {
|
|
||||||
sourceDir: getBundledSkillsDir(),
|
|
||||||
agentsDir: getAgentsSkillsDir(),
|
|
||||||
claudeDir: getClaudeSkillsDir(),
|
|
||||||
codexDir: getCodexSkillsDir(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hashSkillDir(skillDir: string): Promise<SkillFiles | null> {
|
|
||||||
const stat = await fs.stat(skillDir).catch(() => null);
|
|
||||||
if (!stat?.isDirectory()) return null;
|
|
||||||
|
|
||||||
const rels = await listFilesRecursive(skillDir);
|
|
||||||
const files: SkillFiles = new Map();
|
|
||||||
for (const rel of rels) {
|
|
||||||
const buf = await fs.readFile(path.join(skillDir, rel));
|
|
||||||
const sha = createHash("sha256").update(buf).digest("hex");
|
|
||||||
files.set(toPosix(rel), sha);
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
|
|
||||||
const out = new Map<string, SkillFiles>();
|
|
||||||
for (const name of PASEO_SKILL_NAMES) {
|
|
||||||
const files = await hashSkillDir(path.join(rootDir, name));
|
|
||||||
if (files !== null) out.set(name, files);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] {
|
|
||||||
const ops: SkillOp[] = [];
|
|
||||||
for (const name of PASEO_SKILL_NAMES) {
|
|
||||||
const b = bundle.get(name);
|
|
||||||
const d = disk.get(name);
|
|
||||||
if (b && !d) ops.push({ kind: "add", name });
|
|
||||||
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name });
|
|
||||||
else if (!b && d) ops.push({ kind: "delete", name });
|
|
||||||
}
|
|
||||||
ops.sort((a, b) => compareStrings(a.name, b.name));
|
|
||||||
return ops;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filesEqual(a: SkillFiles, b: SkillFiles): boolean {
|
|
||||||
if (a.size !== b.size) return false;
|
|
||||||
for (const [rel, sha] of a) {
|
|
||||||
if (b.get(rel) !== sha) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPosix(p: string): string {
|
|
||||||
return p.split(path.sep).join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareStrings(a: string, b: string): number {
|
|
||||||
if (a < b) return -1;
|
|
||||||
if (a > b) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
|
|
||||||
const t = targets ?? resolveSkillTargets();
|
|
||||||
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]);
|
|
||||||
const ops = diff(bundle, disk);
|
|
||||||
|
|
||||||
if (disk.size === 0) return { state: "not-installed", ops };
|
|
||||||
if (ops.length === 0) return { state: "up-to-date", ops };
|
|
||||||
return { state: "drift", ops };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> {
|
|
||||||
const status = await getSkillsStatus(targets);
|
|
||||||
|
|
||||||
const writes = status.ops
|
|
||||||
.filter((op) => op.kind === "add" || op.kind === "update")
|
|
||||||
.map((op) => op.name);
|
|
||||||
if (writes.length > 0) {
|
|
||||||
await syncSkills({
|
|
||||||
sourceDir: targets.sourceDir,
|
|
||||||
agentsDir: targets.agentsDir,
|
|
||||||
claudeDir: targets.claudeDir,
|
|
||||||
codexDir: targets.codexDir,
|
|
||||||
skillNames: writes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const op of status.ops) {
|
|
||||||
if (op.kind !== "delete") continue;
|
|
||||||
await removeSkill(op.name, {
|
|
||||||
agentsDir: targets.agentsDir,
|
|
||||||
claudeDir: targets.claudeDir,
|
|
||||||
codexDir: targets.codexDir,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return getSkillsStatus(targets);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
|
||||||
return applySkills(targets ?? resolveSkillTargets());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
|
||||||
return applySkills(targets ?? resolveSkillTargets());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
|
||||||
const t = targets ?? resolveSkillTargets();
|
|
||||||
for (const name of PASEO_SKILL_NAMES) {
|
|
||||||
await removeSkill(name, {
|
|
||||||
agentsDir: t.agentsDir,
|
|
||||||
claudeDir: t.claudeDir,
|
|
||||||
codexDir: t.codexDir,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return getSkillsStatus(t);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import os from "node:os";
|
|
||||||
import { app } from "electron";
|
|
||||||
|
|
||||||
export function getBundledSkillsDir(): string {
|
|
||||||
if (app.isPackaged) {
|
|
||||||
return path.join(process.resourcesPath, "skills");
|
|
||||||
}
|
|
||||||
return path.join(__dirname, "..", "..", "..", "..", "..", "skills");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAgentsSkillsDir(): string {
|
|
||||||
return path.join(os.homedir(), ".agents", "skills");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getClaudeSkillsDir(): string {
|
|
||||||
return path.join(os.homedir(), ".claude", "skills");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCodexSkillsDir(): string {
|
|
||||||
return path.join(os.homedir(), ".codex", "skills");
|
|
||||||
}
|
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
createBeforeQuitHandler,
|
createBeforeQuitHandler,
|
||||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||||
} from "./daemon/quit-lifecycle.js";
|
} from "./daemon/quit-lifecycle.js";
|
||||||
|
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
|
||||||
import { runDesktopStartup } from "./desktop-startup.js";
|
import { runDesktopStartup } from "./desktop-startup.js";
|
||||||
|
|
||||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||||
@@ -660,6 +661,10 @@ async function bootstrap(): Promise<void> {
|
|||||||
registerNotificationHandlers();
|
registerNotificationHandlers();
|
||||||
registerOpenerHandlers();
|
registerOpenerHandlers();
|
||||||
|
|
||||||
|
void autoUpdateSkillsIfInstalled().catch((error) => {
|
||||||
|
log.warn("[integrations] auto-update skills failed", error);
|
||||||
|
});
|
||||||
|
|
||||||
await createMainWindow();
|
await createMainWindow();
|
||||||
|
|
||||||
app.on("activate", async () => {
|
app.on("activate", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.70",
|
"version": "0.1.69",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ExpoTwoWayAudio",
|
"ExpoTwoWayAudio",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.70",
|
"version": "0.1.69",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.70",
|
"version": "0.1.69",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.70",
|
"version": "0.1.69",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/server",
|
"dist/server",
|
||||||
@@ -57,9 +57,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@getpaseo/highlight": "0.1.70",
|
"@getpaseo/highlight": "0.1.69",
|
||||||
"@getpaseo/relay": "0.1.70",
|
"@getpaseo/relay": "0.1.69",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||||
"@mariozechner/pi-ai": "^0.70.2",
|
"@mariozechner/pi-ai": "^0.70.2",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import path from "node:path";
|
|||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { isPlatform } from "../src/test-utils/platform.js";
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||||
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
||||||
@@ -117,21 +116,17 @@ describe("supervisor durable logging", () => {
|
|||||||
expect(result.log).toContain("raw stderr line\n");
|
expect(result.log).toContain("raw stderr line\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
// POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL.
|
test("logs worker signal exits even when the worker cannot log", async () => {
|
||||||
test.skipIf(isPlatform("win32"))(
|
const result = await runSupervisorFixture({
|
||||||
"logs worker signal exits even when the worker cannot log",
|
workerSource: `
|
||||||
async () => {
|
|
||||||
const result = await runSupervisorFixture({
|
|
||||||
workerSource: `
|
|
||||||
process.kill(process.pid, "SIGKILL");
|
process.kill(process.pid, "SIGKILL");
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.code).toBe(1);
|
expect(result.code).toBe(1);
|
||||||
expect(result.signal).toBeNull();
|
expect(result.signal).toBeNull();
|
||||||
expect(result.log).toContain('"msg":"Worker exited"');
|
expect(result.log).toContain('"msg":"Worker exited"');
|
||||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
expect(result.log).toContain('"signal":"SIGKILL"');
|
||||||
expect(result.log).toContain("Supervisor exiting");
|
expect(result.log).toContain("Supervisor exiting");
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ import { AgentManager } from "./agent-manager.js";
|
|||||||
import { AgentStorage } from "./agent-storage.js";
|
import { AgentStorage } from "./agent-storage.js";
|
||||||
import { createAgentMcpServer } from "./mcp-server.js";
|
import { createAgentMcpServer } from "./mcp-server.js";
|
||||||
import { createAllClients, shutdownProviders } from "./provider-registry.js";
|
import { createAllClients, shutdownProviders } from "./provider-registry.js";
|
||||||
import { isProviderAvailable } from "../daemon-e2e/agent-configs.js";
|
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
const CODEX_TEST_MODEL = "gpt-5.4-mini";
|
const CODEX_TEST_MODEL = "gpt-5.4-mini";
|
||||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||||
|
|
||||||
const hasOpenAICredentials = !!process.env.OPENAI_API_KEY;
|
const hasOpenAICredentials = !!process.env.OPENAI_API_KEY;
|
||||||
|
const hasClaudeCredentials =
|
||||||
|
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
const shouldRun = !process.env.CI && (hasOpenAICredentials || hasClaudeCredentials);
|
||||||
|
|
||||||
interface AgentMcpServerHandle {
|
interface AgentMcpServerHandle {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -145,20 +147,13 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("getStructuredAgentResponse (e2e)", () => {
|
(shouldRun ? describe : describe.skip)("getStructuredAgentResponse (e2e)", () => {
|
||||||
let manager: AgentManager;
|
let manager: AgentManager;
|
||||||
let cwd: string;
|
let cwd: string;
|
||||||
let agentMcpServer: AgentMcpServerHandle;
|
let agentMcpServer: AgentMcpServerHandle;
|
||||||
let canRunCodex = false;
|
|
||||||
let canRunClaude = false;
|
|
||||||
const logger = pino({ level: "silent" });
|
const logger = pino({ level: "silent" });
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
canRunCodex = !process.env.CI && hasOpenAICredentials;
|
|
||||||
canRunClaude = await isProviderAvailable("claude");
|
|
||||||
if (!canRunCodex && !canRunClaude) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
agentMcpServer = await startAgentMcpServer(logger);
|
agentMcpServer = await startAgentMcpServer(logger);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,70 +174,72 @@ describe("getStructuredAgentResponse (e2e)", () => {
|
|||||||
await shutdownProviders(logger);
|
await shutdownProviders(logger);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
test("returns schema-valid JSON from a real Codex agent", async (context) => {
|
test.runIf(hasOpenAICredentials)(
|
||||||
if (!canRunCodex) {
|
"returns schema-valid JSON from a real Codex agent",
|
||||||
context.skip();
|
async () => {
|
||||||
}
|
const schema = z.object({
|
||||||
const schema = z.object({
|
title: z.string(),
|
||||||
title: z.string(),
|
count: z.number(),
|
||||||
count: z.number(),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const result = await generateStructuredAgentResponse({
|
const result = await generateStructuredAgentResponse({
|
||||||
manager,
|
manager,
|
||||||
agentConfig: {
|
agentConfig: {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: CODEX_TEST_MODEL,
|
model: CODEX_TEST_MODEL,
|
||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
cwd,
|
cwd,
|
||||||
title: "Structured Response Test",
|
title: "Structured Response Test",
|
||||||
},
|
},
|
||||||
prompt: "Return JSON with a short title and count 2.",
|
prompt: "Return JSON with a short title and count 2.",
|
||||||
schema,
|
schema,
|
||||||
maxRetries: 1,
|
maxRetries: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.title.length).toBeGreaterThan(0);
|
expect(result.title.length).toBeGreaterThan(0);
|
||||||
expect(typeof result.count).toBe("number");
|
expect(typeof result.count).toBe("number");
|
||||||
}, 180000);
|
},
|
||||||
|
180000,
|
||||||
|
);
|
||||||
|
|
||||||
test("returns schema-valid JSON from Claude Haiku", async (context) => {
|
test.runIf(hasClaudeCredentials)(
|
||||||
if (!canRunClaude) {
|
"returns schema-valid JSON from Claude Haiku",
|
||||||
context.skip();
|
async () => {
|
||||||
}
|
const schema = z.object({
|
||||||
const schema = z.object({
|
message: z.string(),
|
||||||
message: z.string(),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let result: { message: string } | null = null;
|
let result: { message: string } | null = null;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
result = await generateStructuredAgentResponse({
|
result = await generateStructuredAgentResponse({
|
||||||
manager,
|
manager,
|
||||||
agentConfig: {
|
agentConfig: {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
model: "haiku",
|
model: "haiku",
|
||||||
thinkingOptionId: "on",
|
thinkingOptionId: "on",
|
||||||
cwd,
|
cwd,
|
||||||
title: "Claude Haiku Structured Test",
|
title: "Claude Haiku Structured Test",
|
||||||
internal: true,
|
internal: true,
|
||||||
},
|
},
|
||||||
prompt:
|
prompt:
|
||||||
'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}',
|
'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}',
|
||||||
schema,
|
schema,
|
||||||
maxRetries: 6,
|
maxRetries: 6,
|
||||||
});
|
});
|
||||||
lastError = null;
|
lastError = null;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result) {
|
||||||
|
throw lastError;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!result) {
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(result.message.trim().toLowerCase()).toBe("hello");
|
expect(result.message.trim().toLowerCase()).toBe("hello");
|
||||||
}, 180000);
|
},
|
||||||
|
180000,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { realpathSync } from "node:fs";
|
|
||||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
import { join, resolve as resolvePath } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -25,9 +24,6 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
|||||||
import type { GitHubService } from "../../services/github-service.js";
|
import type { GitHubService } from "../../services/github-service.js";
|
||||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||||
|
|
||||||
const REPO_CWD = resolvePath("/tmp/repo");
|
|
||||||
const TARGET_CWD = resolvePath("/tmp/target");
|
|
||||||
|
|
||||||
interface LooseSafeParseResult {
|
interface LooseSafeParseResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
@@ -46,7 +42,7 @@ interface LooseStructuredContent {
|
|||||||
|
|
||||||
interface RegisteredMcpTool {
|
interface RegisteredMcpTool {
|
||||||
inputSchema: LooseInputSchema;
|
inputSchema: LooseInputSchema;
|
||||||
handler: (input: unknown) => Promise<{
|
callback: (input: unknown) => Promise<{
|
||||||
structuredContent: LooseStructuredContent;
|
structuredContent: LooseStructuredContent;
|
||||||
content?: Array<{ type: string; text?: string }>;
|
content?: Array<{ type: string; text?: string }>;
|
||||||
}>;
|
}>;
|
||||||
@@ -387,7 +383,7 @@ describe("terminal MCP tools", () => {
|
|||||||
});
|
});
|
||||||
const tool = registeredTool(server, "capture_terminal");
|
const tool = registeredTool(server, "capture_terminal");
|
||||||
|
|
||||||
const response = await tool.handler({
|
const response = await tool.callback({
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
scrollback: true,
|
scrollback: true,
|
||||||
stripAnsi: false,
|
stripAnsi: false,
|
||||||
@@ -511,7 +507,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect(providerWithEmptyProvider.success).toBe(false);
|
expect(providerWithEmptyProvider.success).toBe(false);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool.handler({
|
tool.callback({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
mode: "default",
|
mode: "default",
|
||||||
title: "Short title",
|
title: "Short title",
|
||||||
@@ -574,7 +570,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool.handler({
|
tool.callback({
|
||||||
cwd: "/path/that/does/not/exist",
|
cwd: "/path/that/does/not/exist",
|
||||||
title: "Short title",
|
title: "Short title",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
@@ -587,7 +583,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.createAgent.mockResolvedValue({
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
id: "agent-123",
|
id: "agent-123",
|
||||||
cwd: REPO_CWD,
|
cwd: "/tmp/repo",
|
||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -596,7 +592,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
title: " Fix auth bug ",
|
title: " Fix auth bug ",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
@@ -617,7 +613,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.createAgent.mockResolvedValue({
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
id: "agent-456",
|
id: "agent-456",
|
||||||
cwd: REPO_CWD,
|
cwd: "/tmp/repo",
|
||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -626,7 +622,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
title: " Fix auth ",
|
title: " Fix auth ",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
@@ -646,7 +642,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.createAgent.mockResolvedValue({
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
id: "agent-789",
|
id: "agent-789",
|
||||||
cwd: REPO_CWD,
|
cwd: "/tmp/repo",
|
||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -655,7 +651,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
title: "Config test",
|
title: "Config test",
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
@@ -689,20 +685,14 @@ describe("create_agent MCP tool", () => {
|
|||||||
const startedAgentSetupIds: string[] = [];
|
const startedAgentSetupIds: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||||
stdio: "pipe",
|
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
|
||||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||||
id: "agent-with-worktree",
|
id: "agent-with-worktree",
|
||||||
@@ -727,7 +717,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
title: "Worktree agent",
|
title: "Worktree agent",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
@@ -767,20 +757,14 @@ describe("create_agent MCP tool", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||||
stdio: "pipe",
|
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
|
||||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||||
id: "agent-auto-named-worktree",
|
id: "agent-auto-named-worktree",
|
||||||
@@ -803,7 +787,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
title: "Worktree agent",
|
title: "Worktree agent",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
@@ -814,10 +798,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||||
const initialBranch = execFileSync("git", ["branch", "--show-current"], {
|
const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
|
||||||
cwd: agentCwd,
|
|
||||||
stdio: "pipe",
|
|
||||||
})
|
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
expect(initialBranch).not.toBe("");
|
expect(initialBranch).not.toBe("");
|
||||||
@@ -843,28 +824,19 @@ describe("create_agent MCP tool", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||||
stdio: "pipe",
|
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
|
||||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["checkout", "-b", "existing-feature"], {
|
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
await writeFile(join(repoDir, "feature.txt"), "feature\n");
|
await writeFile(join(repoDir, "feature.txt"), "feature\n");
|
||||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||||
id: "agent-checkout-worktree",
|
id: "agent-checkout-worktree",
|
||||||
@@ -887,7 +859,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
title: "Checkout agent",
|
title: "Checkout agent",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
@@ -899,9 +871,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||||
expect(
|
expect(
|
||||||
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
|
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
|
||||||
.toString()
|
|
||||||
.trim(),
|
|
||||||
).toBe("existing-feature");
|
).toBe("existing-feature");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
@@ -931,7 +901,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
workspaceId: "/tmp/worktrees/pr-123",
|
workspaceId: "/tmp/worktrees/pr-123",
|
||||||
projectId: REPO_CWD,
|
projectId: "/tmp/repo",
|
||||||
cwd: "/tmp/worktrees/pr-123",
|
cwd: "/tmp/worktrees/pr-123",
|
||||||
kind: "worktree" as const,
|
kind: "worktree" as const,
|
||||||
displayName: "pr-123",
|
displayName: "pr-123",
|
||||||
@@ -939,7 +909,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
},
|
},
|
||||||
repoRoot: REPO_CWD,
|
repoRoot: "/tmp/repo",
|
||||||
created: true,
|
created: true,
|
||||||
...(options?.setupContinuation?.kind === "agent"
|
...(options?.setupContinuation?.kind === "agent"
|
||||||
? {
|
? {
|
||||||
@@ -978,8 +948,8 @@ describe("create_agent MCP tool", () => {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
cwd: REPO_CWD,
|
cwd: "/tmp/repo",
|
||||||
title: "PR agent",
|
title: "PR agent",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
initialPrompt: "Rename this PR branch from prompt",
|
initialPrompt: "Rename this PR branch from prompt",
|
||||||
@@ -1015,20 +985,14 @@ describe("create_agent MCP tool", () => {
|
|||||||
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||||
stdio: "pipe",
|
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
|
||||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||||
const workspaceGitService = {
|
const workspaceGitService = {
|
||||||
getSnapshot: vi.fn(async () => null),
|
getSnapshot: vi.fn(async () => null),
|
||||||
};
|
};
|
||||||
@@ -1049,7 +1013,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_worktree");
|
const tool = registeredTool(server, "create_worktree");
|
||||||
const response = await tool.handler({
|
const response = await tool.callback({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" },
|
target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" },
|
||||||
});
|
});
|
||||||
@@ -1067,27 +1031,19 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
|
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const tempDir = realpathSync.native(
|
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
|
||||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
|
|
||||||
);
|
|
||||||
const repoDir = join(tempDir, "repo");
|
const repoDir = join(tempDir, "repo");
|
||||||
const paseoHome = join(tempDir, ".paseo");
|
const paseoHome = join(tempDir, ".paseo");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||||
stdio: "pipe",
|
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
|
||||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
const workspaceGitService = {
|
const workspaceGitService = {
|
||||||
getSnapshot: vi.fn(async () => null),
|
getSnapshot: vi.fn(async () => null),
|
||||||
@@ -1116,13 +1072,13 @@ describe("create_agent MCP tool", () => {
|
|||||||
});
|
});
|
||||||
const createTool = registeredTool(server, "create_worktree");
|
const createTool = registeredTool(server, "create_worktree");
|
||||||
const archiveTool = registeredTool(server, "archive_worktree");
|
const archiveTool = registeredTool(server, "archive_worktree");
|
||||||
const created = await createTool.handler({
|
const created = await createTool.callback({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" },
|
target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" },
|
||||||
});
|
});
|
||||||
workspaceGitService.getSnapshot.mockClear();
|
workspaceGitService.getSnapshot.mockClear();
|
||||||
|
|
||||||
await archiveTool.handler({
|
await archiveTool.callback({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
worktreePath: created.structuredContent.worktreePath,
|
worktreePath: created.structuredContent.worktreePath,
|
||||||
});
|
});
|
||||||
@@ -1170,9 +1126,9 @@ describe("create_agent MCP tool", () => {
|
|||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_worktrees");
|
const tool = registeredTool(server, "list_worktrees");
|
||||||
|
|
||||||
const response = await tool.handler({ cwd: REPO_CWD });
|
const response = await tool.callback({ cwd: "/tmp/repo" });
|
||||||
|
|
||||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
|
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", {
|
||||||
reason: "mcp:list-worktrees",
|
reason: "mcp:list-worktrees",
|
||||||
});
|
});
|
||||||
expect(response.structuredContent.worktrees).toEqual([
|
expect(response.structuredContent.worktrees).toEqual([
|
||||||
@@ -1232,7 +1188,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
cwd: "subdir",
|
cwd: "subdir",
|
||||||
title: "Child",
|
title: "Child",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
@@ -1258,7 +1214,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.createAgent.mockResolvedValue({
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
id: "agent-injected-123",
|
id: "agent-injected-123",
|
||||||
cwd: REPO_CWD,
|
cwd: "/tmp/repo",
|
||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -1271,7 +1227,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
title: "Injected config test",
|
title: "Injected config test",
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
@@ -1295,7 +1251,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool.handler({
|
tool.callback({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
title: "Bad mode",
|
title: "Bad mode",
|
||||||
provider: "opencode/gpt-5.4",
|
provider: "opencode/gpt-5.4",
|
||||||
@@ -1332,7 +1288,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
title: "Child",
|
title: "Child",
|
||||||
provider: "claude/claude-sonnet-4-20250514",
|
provider: "claude/claude-sonnet-4-20250514",
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
@@ -1363,7 +1319,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool.handler({
|
tool.callback({
|
||||||
title: "Child",
|
title: "Child",
|
||||||
provider: "opencode/gpt-5.4",
|
provider: "opencode/gpt-5.4",
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
@@ -1398,7 +1354,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
title: "Child",
|
title: "Child",
|
||||||
provider: "opencode/gpt-5.4",
|
provider: "opencode/gpt-5.4",
|
||||||
mode: "build",
|
mode: "build",
|
||||||
@@ -1428,7 +1384,7 @@ describe("create_schedule MCP tool", () => {
|
|||||||
const tool = registeredTool(server, "create_schedule");
|
const tool = registeredTool(server, "create_schedule");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool.handler({
|
tool.callback({
|
||||||
prompt: "say hello",
|
prompt: "say hello",
|
||||||
every: "5m",
|
every: "5m",
|
||||||
name: "Default schedule",
|
name: "Default schedule",
|
||||||
@@ -1448,12 +1404,12 @@ describe("create_schedule MCP tool", () => {
|
|||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_schedule");
|
const tool = registeredTool(server, "create_schedule");
|
||||||
|
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
prompt: "say hello",
|
prompt: "say hello",
|
||||||
every: "5m",
|
every: "5m",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
});
|
});
|
||||||
await tool.handler({
|
await tool.callback({
|
||||||
prompt: "say hello again",
|
prompt: "say hello again",
|
||||||
every: "10m",
|
every: "10m",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
@@ -1514,7 +1470,7 @@ describe("provider listing MCP tool", () => {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_providers");
|
const tool = registeredTool(server, "list_providers");
|
||||||
const response = await tool.handler({});
|
const response = await tool.callback({});
|
||||||
|
|
||||||
expect(response.structuredContent).toEqual({
|
expect(response.structuredContent).toEqual({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -1562,7 +1518,7 @@ describe("provider listing MCP tool", () => {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_providers");
|
const tool = registeredTool(server, "list_providers");
|
||||||
const response = await tool.handler({});
|
const response = await tool.callback({});
|
||||||
|
|
||||||
expect(response.structuredContent).toEqual({
|
expect(response.structuredContent).toEqual({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -1599,7 +1555,7 @@ describe("provider listing MCP tool", () => {
|
|||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_providers");
|
const tool = registeredTool(server, "list_providers");
|
||||||
|
|
||||||
await tool.handler({});
|
await tool.callback({});
|
||||||
|
|
||||||
expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1);
|
expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1);
|
||||||
expect(isAvailable).toHaveBeenCalledTimes(1);
|
expect(isAvailable).toHaveBeenCalledTimes(1);
|
||||||
@@ -1635,7 +1591,7 @@ describe("model listing MCP tool", () => {
|
|||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_models");
|
const tool = registeredTool(server, "list_models");
|
||||||
|
|
||||||
await expect(tool.handler({ provider: "codex" })).rejects.toThrow(
|
await expect(tool.callback({ provider: "codex" })).rejects.toThrow(
|
||||||
"Provider 'codex' is disabled",
|
"Provider 'codex' is disabled",
|
||||||
);
|
);
|
||||||
expect(fetchModels).not.toHaveBeenCalled();
|
expect(fetchModels).not.toHaveBeenCalled();
|
||||||
@@ -1659,7 +1615,7 @@ describe("speak MCP tool", () => {
|
|||||||
const tool = registeredTool(server, "speak");
|
const tool = registeredTool(server, "speak");
|
||||||
expect(tool).toBeDefined();
|
expect(tool).toBeDefined();
|
||||||
|
|
||||||
await tool.handler({ text: "Hello from voice agent." });
|
await tool.callback({ text: "Hello from voice agent." });
|
||||||
expect(speak).toHaveBeenCalledWith(
|
expect(speak).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
text: "Hello from voice agent.",
|
text: "Hello from voice agent.",
|
||||||
@@ -1679,7 +1635,7 @@ describe("speak MCP tool", () => {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "speak");
|
const tool = registeredTool(server, "speak");
|
||||||
await expect(tool.handler({ text: "Hello." })).rejects.toThrow(
|
await expect(tool.callback({ text: "Hello." })).rejects.toThrow(
|
||||||
"No speak handler registered for caller agent",
|
"No speak handler registered for caller agent",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1706,7 +1662,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
createManagedAgent({
|
createManagedAgent({
|
||||||
id: "agent-compact",
|
id: "agent-compact",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: REPO_CWD,
|
cwd: "/tmp/repo",
|
||||||
config: { model: "gpt-5.4", thinkingOptionId: "high" },
|
config: { model: "gpt-5.4", thinkingOptionId: "high" },
|
||||||
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
|
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
|
||||||
labels: { role: "researcher" },
|
labels: { role: "researcher" },
|
||||||
@@ -1715,7 +1671,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
|
|
||||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
const tool = registeredTool(server, "list_agents");
|
const tool = registeredTool(server, "list_agents");
|
||||||
const response = await tool.handler({});
|
const response = await tool.callback({});
|
||||||
const structured = z
|
const structured = z
|
||||||
.object({ agents: z.array(z.record(z.unknown())) })
|
.object({ agents: z.array(z.record(z.unknown())) })
|
||||||
.parse(response.structuredContent);
|
.parse(response.structuredContent);
|
||||||
@@ -1731,7 +1687,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
effectiveThinkingOptionId: "high",
|
effectiveThinkingOptionId: "high",
|
||||||
status: "idle",
|
status: "idle",
|
||||||
cwd: REPO_CWD,
|
cwd: "/tmp/repo",
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
updatedAt: expect.any(String),
|
updatedAt: expect.any(String),
|
||||||
lastUserMessageAt: null,
|
lastUserMessageAt: null,
|
||||||
@@ -1769,7 +1725,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "get_agent_status");
|
const tool = registeredTool(server, "get_agent_status");
|
||||||
const response = await tool.handler({ agentId: "archived-agent" });
|
const response = await tool.callback({ agentId: "archived-agent" });
|
||||||
|
|
||||||
expect(response.structuredContent).toEqual({
|
expect(response.structuredContent).toEqual({
|
||||||
status: "closed",
|
status: "closed",
|
||||||
@@ -1825,7 +1781,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
|
|
||||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
const tool = registeredTool(server, "get_agent_status");
|
const tool = registeredTool(server, "get_agent_status");
|
||||||
const response = await tool.handler({ agentId: "full-detail-agent" });
|
const response = await tool.callback({ agentId: "full-detail-agent" });
|
||||||
const snapshot = z.record(z.unknown()).parse(response.structuredContent.snapshot);
|
const snapshot = z.record(z.unknown()).parse(response.structuredContent.snapshot);
|
||||||
|
|
||||||
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
|
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
|
||||||
@@ -1901,7 +1857,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
});
|
});
|
||||||
const tool = registeredTool(server, "get_agent_status");
|
const tool = registeredTool(server, "get_agent_status");
|
||||||
|
|
||||||
await expect(tool.handler({ agentId: "internal-agent" })).rejects.toThrow(
|
await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow(
|
||||||
"Agent internal-agent not found",
|
"Agent internal-agent not found",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1945,7 +1901,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
callerAgentId: "caller-agent",
|
callerAgentId: "caller-agent",
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_agents");
|
const tool = registeredTool(server, "list_agents");
|
||||||
const response = await tool.handler({});
|
const response = await tool.callback({});
|
||||||
|
|
||||||
const agentIds = agentsOf(response).map((agent) => agent.id);
|
const agentIds = agentsOf(response).map((agent) => agent.id);
|
||||||
expect(agentIds).toHaveLength(3);
|
expect(agentIds).toHaveLength(3);
|
||||||
@@ -1960,32 +1916,28 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
spies.agentManager.listAgents.mockReturnValue([
|
spies.agentManager.listAgents.mockReturnValue([
|
||||||
createManagedAgent({
|
createManagedAgent({
|
||||||
id: "running-target",
|
id: "running-target",
|
||||||
cwd: TARGET_CWD,
|
cwd: "/tmp/target",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
updatedAt: new Date(recent),
|
updatedAt: new Date(recent),
|
||||||
}),
|
}),
|
||||||
createManagedAgent({
|
createManagedAgent({
|
||||||
id: "idle-target",
|
id: "idle-target",
|
||||||
cwd: TARGET_CWD,
|
cwd: "/tmp/target",
|
||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
updatedAt: new Date(recent),
|
updatedAt: new Date(recent),
|
||||||
}),
|
}),
|
||||||
createManagedAgent({
|
createManagedAgent({
|
||||||
id: "old-running-target",
|
id: "old-running-target",
|
||||||
cwd: TARGET_CWD,
|
cwd: "/tmp/target",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
createdAt: new Date(old),
|
createdAt: new Date(old),
|
||||||
updatedAt: new Date(old),
|
updatedAt: new Date(old),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
spies.agentStorage.list.mockResolvedValue([
|
spies.agentStorage.list.mockResolvedValue([
|
||||||
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
|
createStoredRecord({ id: "recent-archived", cwd: "/tmp/target", archivedAt: recent }),
|
||||||
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
|
createStoredRecord({ id: "old-archived", cwd: "/tmp/target", archivedAt: old }),
|
||||||
createStoredRecord({
|
createStoredRecord({ id: "recent-other-cwd", cwd: "/tmp/other", archivedAt: recent }),
|
||||||
id: "recent-other-cwd",
|
|
||||||
cwd: resolvePath("/tmp/other"),
|
|
||||||
archivedAt: recent,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
@@ -1997,8 +1949,8 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_agents");
|
const tool = registeredTool(server, "list_agents");
|
||||||
const response = await tool.handler({
|
const response = await tool.callback({
|
||||||
cwd: TARGET_CWD,
|
cwd: "/tmp/target",
|
||||||
includeArchived: true,
|
includeArchived: true,
|
||||||
sinceHours: 48,
|
sinceHours: 48,
|
||||||
statuses: ["running", "closed"],
|
statuses: ["running", "closed"],
|
||||||
@@ -2038,7 +1990,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_agents");
|
const tool = registeredTool(server, "list_agents");
|
||||||
const response = await tool.handler({ includeArchived: true });
|
const response = await tool.callback({ includeArchived: true });
|
||||||
const agentIds = agentsOf(response).map((agent) => agent.id);
|
const agentIds = agentsOf(response).map((agent) => agent.id);
|
||||||
|
|
||||||
expect(agentIds).toHaveLength(50);
|
expect(agentIds).toHaveLength(50);
|
||||||
@@ -2057,7 +2009,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
spies.agentStorage.list.mockResolvedValue([
|
spies.agentStorage.list.mockResolvedValue([
|
||||||
createStoredRecord({
|
createStoredRecord({
|
||||||
id: "stored-archived-compact",
|
id: "stored-archived-compact",
|
||||||
cwd: REPO_CWD,
|
cwd: "/tmp/repo",
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lastActivityAt: now,
|
lastActivityAt: now,
|
||||||
archivedAt: now,
|
archivedAt: now,
|
||||||
@@ -2081,7 +2033,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_agents");
|
const tool = registeredTool(server, "list_agents");
|
||||||
const response = await tool.handler({ cwd: REPO_CWD, includeArchived: true });
|
const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true });
|
||||||
const item = agentsOf(response)[0];
|
const item = agentsOf(response)[0];
|
||||||
|
|
||||||
expect(item).toEqual({
|
expect(item).toEqual({
|
||||||
@@ -2093,7 +2045,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
thinkingOptionId: null,
|
thinkingOptionId: null,
|
||||||
effectiveThinkingOptionId: null,
|
effectiveThinkingOptionId: null,
|
||||||
status: "closed",
|
status: "closed",
|
||||||
cwd: REPO_CWD,
|
cwd: "/tmp/repo",
|
||||||
createdAt: "2026-04-11T00:00:00.000Z",
|
createdAt: "2026-04-11T00:00:00.000Z",
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lastUserMessageAt: null,
|
lastUserMessageAt: null,
|
||||||
@@ -2154,7 +2106,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
|
|
||||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
const tool = registeredTool(server, "list_agents");
|
const tool = registeredTool(server, "list_agents");
|
||||||
const response = await tool.handler({});
|
const response = await tool.callback({});
|
||||||
|
|
||||||
expect(agentsOf(response).map((agent) => agent.id)).toEqual([
|
expect(agentsOf(response).map((agent) => agent.id)).toEqual([
|
||||||
"idle-attention-oldest",
|
"idle-attention-oldest",
|
||||||
@@ -2189,7 +2141,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_agents");
|
const tool = registeredTool(server, "list_agents");
|
||||||
const response = await tool.handler({ includeArchived: true });
|
const response = await tool.callback({ includeArchived: true });
|
||||||
|
|
||||||
const parsed = z.array(AgentListItemPayloadSchema).safeParse(response.structuredContent.agents);
|
const parsed = z.array(AgentListItemPayloadSchema).safeParse(response.structuredContent.agents);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -2229,7 +2181,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "get_agent_activity");
|
const tool = registeredTool(server, "get_agent_activity");
|
||||||
const response = await tool.handler({ agentId: "archived-activity-agent" });
|
const response = await tool.callback({ agentId: "archived-activity-agent" });
|
||||||
|
|
||||||
expect(response.structuredContent).toEqual(
|
expect(response.structuredContent).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -2267,7 +2219,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "get_agent_activity");
|
const tool = registeredTool(server, "get_agent_activity");
|
||||||
const response = await tool.handler({ agentId: "live-activity-agent", limit: 1 });
|
const response = await tool.callback({ agentId: "live-activity-agent", limit: 1 });
|
||||||
|
|
||||||
const content = String(response.structuredContent.content);
|
const content = String(response.structuredContent.content);
|
||||||
expect(content).toContain("Hello world. How are you?");
|
expect(content).toContain("Hello world. How are you?");
|
||||||
@@ -2298,7 +2250,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "get_agent_activity");
|
const tool = registeredTool(server, "get_agent_activity");
|
||||||
const response = await tool.handler({ agentId: "live-activity-agent-2", limit: 2 });
|
const response = await tool.callback({ agentId: "live-activity-agent-2", limit: 2 });
|
||||||
|
|
||||||
const content = String(response.structuredContent.content);
|
const content = String(response.structuredContent.content);
|
||||||
expect(content).toContain("[User] u3");
|
expect(content).toContain("[User] u3");
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ vi.mock("../../utils/executable.js", () => ({
|
|||||||
isCommandAvailable: mockState.isCommandAvailable,
|
isCommandAvailable: mockState.isCommandAvailable,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./providers/claude/agent.js", () => ({
|
vi.mock("./providers/claude-agent.js", () => ({
|
||||||
ClaudeAgentClient: class ClaudeAgentClient {
|
ClaudeAgentClient: class ClaudeAgentClient {
|
||||||
readonly capabilities = {
|
readonly capabilities = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type {
|
|||||||
ProviderProfileModel,
|
ProviderProfileModel,
|
||||||
ProviderRuntimeSettings,
|
ProviderRuntimeSettings,
|
||||||
} from "./provider-launch-config.js";
|
} from "./provider-launch-config.js";
|
||||||
import { ClaudeAgentClient } from "./providers/claude/agent.js";
|
import { ClaudeAgentClient } from "./providers/claude-agent.js";
|
||||||
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
||||||
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
||||||
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||||
|
|||||||
+9
-12
@@ -6,7 +6,9 @@
|
|||||||
*
|
*
|
||||||
* All tests use REAL Claude SDK sessions — no mocks.
|
* All tests use REAL Claude SDK sessions — no mocks.
|
||||||
*
|
*
|
||||||
* These tests run when the shared Claude provider availability gate passes.
|
* CREDENTIALS: These tests require a running `claude` CLI and either
|
||||||
|
* CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment.
|
||||||
|
* They are skipped automatically when credentials are unavailable.
|
||||||
*/
|
*/
|
||||||
import { beforeAll, beforeEach, expect, test } from "vitest";
|
import { beforeAll, beforeEach, expect, test } from "vitest";
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
@@ -15,8 +17,8 @@ import path from "node:path";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
import { isCommandAvailable } from "../../../../utils/executable.js";
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
import { ClaudeAgentClient } from "../claude-agent.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -24,6 +26,8 @@ import { ClaudeAgentClient } from "./agent.js";
|
|||||||
|
|
||||||
const logger = pino({ level: "silent" });
|
const logger = pino({ level: "silent" });
|
||||||
const client = new ClaudeAgentClient({ logger });
|
const client = new ClaudeAgentClient({ logger });
|
||||||
|
const hasClaudeCredentials =
|
||||||
|
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
function tmpCwd(prefix: string): string {
|
function tmpCwd(prefix: string): string {
|
||||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||||
@@ -70,14 +74,7 @@ async function createSession(params?: {
|
|||||||
|
|
||||||
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
|
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
|
||||||
await handle.session.close().catch(() => undefined);
|
await handle.session.close().catch(() => undefined);
|
||||||
try {
|
rmSync(handle.cwd, { recursive: true, force: true });
|
||||||
rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
||||||
} catch (error) {
|
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
|
||||||
if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startTurnAndCollectEvents(
|
async function startTurnAndCollectEvents(
|
||||||
@@ -199,7 +196,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
|
|||||||
let canRun = false;
|
let canRun = false;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
canRun = await isProviderAvailable("claude");
|
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach((context) => {
|
beforeEach((context) => {
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { type ChildProcess } from "node:child_process";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { EventEmitter } from "node:events";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import type {
|
import type {
|
||||||
PermissionOption,
|
PermissionOption,
|
||||||
PromptResponse,
|
PromptResponse,
|
||||||
@@ -25,7 +23,6 @@ import { transformPiModels } from "./pi-direct-agent.js";
|
|||||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||||
import * as spawnUtils from "../../../utils/spawn.js";
|
|
||||||
|
|
||||||
interface ACPSessionInternals {
|
interface ACPSessionInternals {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -117,14 +114,6 @@ function createSessionWithConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTerminalChildStub(): ChildProcess {
|
|
||||||
const child = new EventEmitter() as ChildProcess;
|
|
||||||
child.stdout = new EventEmitter() as ChildProcess["stdout"];
|
|
||||||
child.stderr = new EventEmitter() as ChildProcess["stderr"];
|
|
||||||
child.kill = vi.fn(() => true) as ChildProcess["kill"];
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectConfigOption(
|
function selectConfigOption(
|
||||||
category: "mode" | "model" | "thought_level",
|
category: "mode" | "model" | "thought_level",
|
||||||
values: string[],
|
values: string[],
|
||||||
@@ -314,78 +303,6 @@ describe("createLoggedNdJsonStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ACPAgentSession terminal tools", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("runs single-string terminal commands through the platform shell", async () => {
|
|
||||||
const child = createTerminalChildStub();
|
|
||||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
|
||||||
const session = createSession();
|
|
||||||
const shell = spawnUtils.platformShell();
|
|
||||||
|
|
||||||
await session.createTerminal({
|
|
||||||
sessionId: "session-1",
|
|
||||||
command: "git -C /repo status --short",
|
|
||||||
cwd: "/repo",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(spawn).toHaveBeenCalledWith(
|
|
||||||
shell.command,
|
|
||||||
[...shell.flag, "git -C /repo status --short"],
|
|
||||||
expect.objectContaining({ cwd: "/repo" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves explicit terminal argv", async () => {
|
|
||||||
const child = createTerminalChildStub();
|
|
||||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
|
||||||
const session = createSession();
|
|
||||||
|
|
||||||
await session.createTerminal({
|
|
||||||
sessionId: "session-1",
|
|
||||||
command: "git",
|
|
||||||
args: ["status", "--short"],
|
|
||||||
cwd: "/repo",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(spawn).toHaveBeenCalledWith(
|
|
||||||
"git",
|
|
||||||
["status", "--short"],
|
|
||||||
expect.objectContaining({ cwd: "/repo" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("surfaces spawn errors through terminal output and waitForTerminalExit", async () => {
|
|
||||||
const child = createTerminalChildStub();
|
|
||||||
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
|
||||||
const session = createSession();
|
|
||||||
|
|
||||||
const terminal = await session.createTerminal({
|
|
||||||
sessionId: "session-1",
|
|
||||||
command: "missing-command",
|
|
||||||
});
|
|
||||||
child.emit("error", new Error("spawn missing-command ENOENT"));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
session.waitForTerminalExit({
|
|
||||||
sessionId: "session-1",
|
|
||||||
terminalId: terminal.terminalId,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("spawn missing-command ENOENT");
|
|
||||||
await expect(
|
|
||||||
session.terminalOutput({
|
|
||||||
sessionId: "session-1",
|
|
||||||
terminalId: terminal.terminalId,
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
output: "spawn missing-command ENOENT\n",
|
|
||||||
truncated: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mapACPUsage", () => {
|
describe("mapACPUsage", () => {
|
||||||
test("maps ACP usage fields into Paseo usage", () => {
|
test("maps ACP usage fields into Paseo usage", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ import {
|
|||||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||||
import { findExecutable } from "../../../utils/executable.js";
|
import { findExecutable } from "../../../utils/executable.js";
|
||||||
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
|
import { spawnProcess } from "../../../utils/spawn.js";
|
||||||
|
|
||||||
function assertChildWithPipes(
|
function assertChildWithPipes(
|
||||||
child: ChildProcess,
|
child: ChildProcess,
|
||||||
@@ -106,22 +106,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTerminalCommand(
|
|
||||||
command: string,
|
|
||||||
args?: string[],
|
|
||||||
): { command: string; args: string[] } {
|
|
||||||
if (args && args.length > 0) {
|
|
||||||
return { command, args };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/\s/.test(command.trim())) {
|
|
||||||
return { command, args: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const shell = platformShell();
|
|
||||||
return { command: shell.command, args: [...shell.flag, command] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
supportsSessionPersistence: true,
|
supportsSessionPersistence: true,
|
||||||
@@ -1480,8 +1464,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
const env = Object.fromEntries(
|
const env = Object.fromEntries(
|
||||||
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
|
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
|
||||||
);
|
);
|
||||||
const terminalCommand = resolveTerminalCommand(params.command, params.args);
|
const child = spawnProcess(params.command, params.args ?? [], {
|
||||||
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
|
|
||||||
cwd: params.cwd ?? this.config.cwd,
|
cwd: params.cwd ?? this.config.cwd,
|
||||||
...createProviderEnvSpec({
|
...createProviderEnvSpec({
|
||||||
runtimeSettings: this.runtimeSettings,
|
runtimeSettings: this.runtimeSettings,
|
||||||
@@ -1496,7 +1479,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
resolveExit = resolve;
|
resolveExit = resolve;
|
||||||
rejectExit = reject;
|
rejectExit = reject;
|
||||||
});
|
});
|
||||||
waitForExit.catch(() => undefined);
|
|
||||||
|
|
||||||
const entry: TerminalEntry = {
|
const entry: TerminalEntry = {
|
||||||
id: terminalId,
|
id: terminalId,
|
||||||
@@ -1516,11 +1498,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
child.stderr!.on("data", (chunk: Buffer | string) =>
|
child.stderr!.on("data", (chunk: Buffer | string) =>
|
||||||
appendTerminalOutput(entry, chunk.toString()),
|
appendTerminalOutput(entry, chunk.toString()),
|
||||||
);
|
);
|
||||||
child.once("error", (error) => {
|
child.once("error", (error) =>
|
||||||
const spawnError = error instanceof Error ? error : new Error(String(error));
|
rejectExit(error instanceof Error ? error : new Error(String(error))),
|
||||||
appendTerminalOutput(entry, `${spawnError.message}\n`);
|
);
|
||||||
rejectExit(spawnError);
|
|
||||||
});
|
|
||||||
child.once("exit", (code, signal) => {
|
child.once("exit", (code, signal) => {
|
||||||
const exit = { exitCode: code, signal };
|
const exit = { exitCode: code, signal };
|
||||||
entry.exit = exit;
|
entry.exit = exit;
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { createDaemonTestContext, type DaemonTestContext } from "../../../test-utils/index.js";
|
import { createDaemonTestContext, type DaemonTestContext } from "../../test-utils/index.js";
|
||||||
|
|
||||||
// Fake-daemon plumbing coverage: validates manager/client command wiring without a real Claude binary.
|
// Fake-daemon plumbing coverage: validates manager/client command wiring without a real Claude binary.
|
||||||
describe("claude agent commands E2E", () => {
|
describe("claude agent commands E2E", () => {
|
||||||
+5
-3
@@ -1,15 +1,15 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
import { isCommandAvailable } from "../../../utils/executable.js";
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
|
|
||||||
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
|
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
|
||||||
describe("claude agent commands contract (real)", () => {
|
describe("claude agent commands contract (real)", () => {
|
||||||
let canRun = false;
|
let canRun = false;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
canRun = await isProviderAvailable("claude");
|
canRun = await isCommandAvailable("claude");
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach((context) => {
|
beforeEach((context) => {
|
||||||
@@ -19,6 +19,8 @@ describe("claude agent commands contract (real)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("lists slash commands with the expected contract", async () => {
|
test("lists slash commands with the expected contract", async () => {
|
||||||
|
expect(await isCommandAvailable("claude")).toBe(true);
|
||||||
|
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
logger: pino({ level: "silent" }),
|
logger: pino({ level: "silent" }),
|
||||||
});
|
});
|
||||||
+6
-9
@@ -1,10 +1,9 @@
|
|||||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
import { query, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import type { AgentLaunchContext } from "../../agent-sdk-types.js";
|
import type { AgentLaunchContext } from "../agent-sdk-types.js";
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
import type { ClaudeQueryInput } from "./query.js";
|
|
||||||
|
|
||||||
function createQueryMock(events: unknown[]): Query {
|
function createQueryMock(events: unknown[]): Query {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -37,7 +36,7 @@ describe("Claude SDK env", () => {
|
|||||||
PASEO_TEST_FLAG: "launch-value",
|
PASEO_TEST_FLAG: "launch-value",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
|
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
||||||
capturedEnv = options.env;
|
capturedEnv = options.env;
|
||||||
return createQueryMock([
|
return createQueryMock([
|
||||||
{
|
{
|
||||||
@@ -67,7 +66,6 @@ describe("Claude SDK env", () => {
|
|||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
queryFactory,
|
queryFactory,
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
});
|
||||||
const session = await client.createSession(
|
const session = await client.createSession(
|
||||||
{
|
{
|
||||||
@@ -95,7 +93,7 @@ describe("Claude SDK env", () => {
|
|||||||
PASEO_TEST_FLAG: "resume-launch-value",
|
PASEO_TEST_FLAG: "resume-launch-value",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
|
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
||||||
capturedEnv = options.env;
|
capturedEnv = options.env;
|
||||||
return createQueryMock([
|
return createQueryMock([
|
||||||
{
|
{
|
||||||
@@ -125,7 +123,6 @@ describe("Claude SDK env", () => {
|
|||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
queryFactory,
|
queryFactory,
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
});
|
||||||
const session = await client.resumeSession(
|
const session = await client.resumeSession(
|
||||||
{
|
{
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { extractUserMessageText } from "./agent.js";
|
import { extractUserMessageText } from "./claude-agent.js";
|
||||||
|
|
||||||
describe("extractUserMessageText", () => {
|
describe("extractUserMessageText", () => {
|
||||||
test("returns trimmed string content", () => {
|
test("returns trimmed string content", () => {
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import { describe, expect, test, beforeAll, beforeEach } from "vitest";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import pino from "pino";
|
||||||
|
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
|
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
|
||||||
|
import { isCommandAvailable } from "../../../utils/executable.js";
|
||||||
|
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||||
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
|
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||||
|
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
|
const client = new ClaudeAgentClient({ logger });
|
||||||
|
const hasClaudeCredentials =
|
||||||
|
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
|
function tmpCwd(prefix: string): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||||
|
return (async function* empty() {})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactText(value: string): string {
|
||||||
|
return value.replace(/\s+/g, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||||
|
return (
|
||||||
|
event.type === "turn_completed" ||
|
||||||
|
event.type === "turn_failed" ||
|
||||||
|
event.type === "turn_canceled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextStreamEvent(
|
||||||
|
stream: AsyncGenerator<AgentStreamEvent>,
|
||||||
|
timeoutMs: number,
|
||||||
|
label: string,
|
||||||
|
): Promise<IteratorResult<AgentStreamEvent>> {
|
||||||
|
return await withTimeout(stream.next(), timeoutMs, `Timed out waiting for ${label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectUntilTerminal(
|
||||||
|
stream: AsyncGenerator<AgentStreamEvent>,
|
||||||
|
options?: {
|
||||||
|
timeoutMs?: number;
|
||||||
|
onEvent?: (event: AgentStreamEvent) => Promise<void> | void;
|
||||||
|
},
|
||||||
|
): Promise<AgentStreamEvent[]> {
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
while (true) {
|
||||||
|
const next = await nextStreamEvent(stream, options?.timeoutMs ?? 45_000, "stream event");
|
||||||
|
if (next.done || !next.value) {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
const event = next.value;
|
||||||
|
events.push(event);
|
||||||
|
await options?.onEvent?.(event);
|
||||||
|
if (isTerminalEvent(event)) {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectUntil(
|
||||||
|
stream: AsyncGenerator<AgentStreamEvent>,
|
||||||
|
predicate: (event: AgentStreamEvent) => boolean,
|
||||||
|
timeoutMs = 45_000,
|
||||||
|
): Promise<AgentStreamEvent[]> {
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
while (true) {
|
||||||
|
const next = await nextStreamEvent(stream, timeoutMs, "matching stream event");
|
||||||
|
if (next.done || !next.value) {
|
||||||
|
throw new Error("Stream ended before the expected event arrived");
|
||||||
|
}
|
||||||
|
const event = next.value;
|
||||||
|
events.push(event);
|
||||||
|
if (predicate(event) || isTerminalEvent(event)) {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSubscribedUntil(
|
||||||
|
session: AgentSession,
|
||||||
|
predicate: (event: AgentStreamEvent) => boolean,
|
||||||
|
timeoutMs = 45_000,
|
||||||
|
): Promise<AgentStreamEvent[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
unsubscribe();
|
||||||
|
reject(new Error(`Timed out after ${timeoutMs}ms waiting for subscribed event`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const unsubscribe = session.subscribe((event) => {
|
||||||
|
events.push(event);
|
||||||
|
if (!predicate(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timeout);
|
||||||
|
unsubscribe();
|
||||||
|
resolve(events);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssistantText(events: AgentStreamEvent[]): string {
|
||||||
|
return events
|
||||||
|
.flatMap((event) => {
|
||||||
|
if (event.type !== "timeline" || event.item.type !== "assistant_message") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [event.item.text];
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolCalls(events: AgentStreamEvent[]): ToolCallTimelineItem[] {
|
||||||
|
return events.flatMap((event) => {
|
||||||
|
if (event.type !== "timeline" || event.item.type !== "tool_call") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [event.item];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelineItem | undefined {
|
||||||
|
return [...getToolCalls(events)]
|
||||||
|
.toReversed()
|
||||||
|
.find((item) => item.status === "completed" && item.name.toLowerCase() === "bash");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInternalQuery(session: AgentSession): unknown {
|
||||||
|
return (session as AgentSession & { query?: unknown }).query ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession(params?: {
|
||||||
|
cwdPrefix?: string;
|
||||||
|
modeId?: string;
|
||||||
|
title?: string;
|
||||||
|
}): Promise<{ cwd: string; session: AgentSession }> {
|
||||||
|
const cwd = tmpCwd(params?.cwdPrefix ?? "claude-agent-integration-");
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: params?.title ?? "ClaudeAgentSession integration",
|
||||||
|
modeId: params?.modeId ?? "acceptEdits",
|
||||||
|
model: "haiku",
|
||||||
|
});
|
||||||
|
return { cwd, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
|
||||||
|
await handle.session.close().catch(() => undefined);
|
||||||
|
rmSync(handle.cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ClaudeAgentSession integration", () => {
|
||||||
|
let canRunClaudeIntegration = false;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
||||||
|
if (canRunClaudeIntegration) {
|
||||||
|
expect(await isCommandAvailable("claude")).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach((context) => {
|
||||||
|
if (!canRunClaudeIntegration) {
|
||||||
|
context.skip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streams a basic response turn end-to-end", async () => {
|
||||||
|
const handle = await createSession({
|
||||||
|
cwdPrefix: "claude-agent-basic-response-",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await collectUntilTerminal(
|
||||||
|
streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "turn_started",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
events.some(
|
||||||
|
(event) =>
|
||||||
|
event.type === "timeline" &&
|
||||||
|
event.item.type === "assistant_message" &&
|
||||||
|
compactText(event.item.text).includes("hello_world"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(events.at(-1)).toMatchObject({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await cleanupSession(handle);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test("keeps bypassPermissions available after a thinking-option restart", async () => {
|
||||||
|
const handle = await createSession({
|
||||||
|
cwdPrefix: "claude-agent-bypass-restart-",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handle.session.setMode("acceptEdits");
|
||||||
|
await handle.session.setThinkingOption("high");
|
||||||
|
await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await cleanupSession(handle);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test("supportedModels returns the current abstract Claude SDK model shape", async () => {
|
||||||
|
const claudeQuery = query({
|
||||||
|
prompt: createEmptyPrompt(),
|
||||||
|
options: {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
permissionMode: "plan",
|
||||||
|
includePartialMessages: false,
|
||||||
|
settingSources: ["user", "project"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await claudeQuery.supportedModels();
|
||||||
|
|
||||||
|
expect(models.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(models).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "default",
|
||||||
|
displayName: "Default (recommended)",
|
||||||
|
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(models).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "haiku",
|
||||||
|
displayName: "Haiku",
|
||||||
|
description: expect.stringContaining("Haiku 4.5"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
models.some(
|
||||||
|
(model) =>
|
||||||
|
model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await claudeQuery.return?.();
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test.runIf(canRunClaudeIntegration)(
|
||||||
|
"runs a real Bash tool call and completes it",
|
||||||
|
async () => {
|
||||||
|
const handle = await createSession({
|
||||||
|
cwdPrefix: "claude-agent-basic-tool-",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await collectUntilTerminal(
|
||||||
|
streamSession(
|
||||||
|
handle.session,
|
||||||
|
[
|
||||||
|
"Use the Bash tool.",
|
||||||
|
"Run exactly: echo TOOL_TEST_OUTPUT",
|
||||||
|
"After the command completes, reply with exactly: TOOL_DONE",
|
||||||
|
].join(" "),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
|
||||||
|
const completedBashCall = getLatestCompletedBashCall(events);
|
||||||
|
|
||||||
|
expect(bashCalls.length).toBeGreaterThan(0);
|
||||||
|
expect(completedBashCall).toBeDefined();
|
||||||
|
expect(completedBashCall?.detail.type).toBe("shell");
|
||||||
|
expect(
|
||||||
|
completedBashCall?.detail.type === "shell" &&
|
||||||
|
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(compactText(getAssistantText(events))).toContain("tool_done");
|
||||||
|
expect(events.at(-1)).toMatchObject({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await cleanupSession(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
test.runIf(canRunClaudeIntegration)(
|
||||||
|
"interrupts a running Bash turn and continues on the same query",
|
||||||
|
async () => {
|
||||||
|
const handle = await createSession({
|
||||||
|
cwdPrefix: "claude-agent-interrupt-continue-",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstStream = streamSession(
|
||||||
|
handle.session,
|
||||||
|
[
|
||||||
|
"Use the Bash tool.",
|
||||||
|
"Run exactly: sleep 10",
|
||||||
|
"Do not use a background task.",
|
||||||
|
"Do not do anything after starting the command.",
|
||||||
|
].join(" "),
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialEvents = await collectUntil(
|
||||||
|
firstStream,
|
||||||
|
(event) =>
|
||||||
|
event.type === "timeline" &&
|
||||||
|
event.item.type === "tool_call" &&
|
||||||
|
event.item.name.toLowerCase() === "bash",
|
||||||
|
45_000,
|
||||||
|
);
|
||||||
|
const firstQuery = getInternalQuery(handle.session);
|
||||||
|
|
||||||
|
expect(firstQuery).toBeTruthy();
|
||||||
|
|
||||||
|
await handle.session.interrupt();
|
||||||
|
|
||||||
|
const canceledEvents = await collectUntilTerminal(firstStream, {
|
||||||
|
timeoutMs: 20_000,
|
||||||
|
});
|
||||||
|
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
allFirstTurnEvents.some(
|
||||||
|
(event) => event.type === "turn_canceled" && event.provider === "claude",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const followUpEvents = await collectUntilTerminal(
|
||||||
|
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
|
||||||
|
);
|
||||||
|
const secondQuery = getInternalQuery(handle.session);
|
||||||
|
|
||||||
|
expect(secondQuery).toBe(firstQuery);
|
||||||
|
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
|
||||||
|
expect(followUpEvents.at(-1)).toMatchObject({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await cleanupSession(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
test.runIf(canRunClaudeIntegration)(
|
||||||
|
"creates an autonomous live turn when a background task completes",
|
||||||
|
async () => {
|
||||||
|
const handle = await createSession({
|
||||||
|
cwdPrefix: "claude-agent-autonomous-",
|
||||||
|
});
|
||||||
|
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const foregroundEvents = await collectUntilTerminal(
|
||||||
|
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.",
|
||||||
|
"Reply immediately with exactly: SPAWNED",
|
||||||
|
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
|
||||||
|
].join(" "),
|
||||||
|
),
|
||||||
|
{ timeoutMs: 45_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
|
||||||
|
|
||||||
|
const liveEvents = await collectSubscribedUntil(
|
||||||
|
handle.session,
|
||||||
|
(event) => isTerminalEvent(event),
|
||||||
|
45_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(compactText(getAssistantText(liveEvents))).toContain(
|
||||||
|
autonomousWakeToken.toLowerCase(),
|
||||||
|
);
|
||||||
|
expect(liveEvents.at(-1)).toMatchObject({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await cleanupSession(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
test.runIf(canRunClaudeIntegration)(
|
||||||
|
"surfaces permission requests and resumes after approval",
|
||||||
|
async () => {
|
||||||
|
const handle = await createSession({
|
||||||
|
cwdPrefix: "claude-agent-permission-",
|
||||||
|
modeId: "default",
|
||||||
|
});
|
||||||
|
const permissionFile = path.join(handle.cwd, "permission.txt");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await collectUntilTerminal(
|
||||||
|
streamSession(
|
||||||
|
handle.session,
|
||||||
|
[
|
||||||
|
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
|
||||||
|
"If approval is required, wait for approval.",
|
||||||
|
"After the command succeeds, reply with exactly: PERM_DONE",
|
||||||
|
].join(" "),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
timeoutMs: 45_000,
|
||||||
|
onEvent: async (event) => {
|
||||||
|
if (event.type !== "permission_requested") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handle.session.respondToPermission(event.request.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissionRequest = events.find(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
|
event.type === "permission_requested",
|
||||||
|
);
|
||||||
|
const permissionResolved = events.find(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
|
||||||
|
event.type === "permission_resolved",
|
||||||
|
);
|
||||||
|
const completedBashCall = getLatestCompletedBashCall(events);
|
||||||
|
|
||||||
|
expect(permissionRequest?.request.kind).toBe("tool");
|
||||||
|
expect(permissionResolved).toMatchObject({
|
||||||
|
type: "permission_resolved",
|
||||||
|
provider: "claude",
|
||||||
|
resolution: { behavior: "allow" },
|
||||||
|
});
|
||||||
|
expect(completedBashCall).toBeDefined();
|
||||||
|
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
|
||||||
|
expect(compactText(getAssistantText(events))).toContain("perm_done");
|
||||||
|
expect(events.at(-1)).toMatchObject({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await cleanupSession(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
});
|
||||||
+30
-52
@@ -1,9 +1,9 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
|
|
||||||
interface QueryMock {
|
interface QueryMock {
|
||||||
next: ReturnType<typeof vi.fn>;
|
next: ReturnType<typeof vi.fn>;
|
||||||
@@ -41,7 +41,13 @@ type PromptHandler = (input: {
|
|||||||
query: ScriptedQuery;
|
query: ScriptedQuery;
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
|
|
||||||
const queryFactory = vi.fn();
|
const sdkMocks = vi.hoisted(() => ({
|
||||||
|
query: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||||
|
query: sdkMocks.query,
|
||||||
|
}));
|
||||||
|
|
||||||
function createAsyncQueue<T>(): AsyncQueue<T> {
|
function createAsyncQueue<T>(): AsyncQueue<T> {
|
||||||
const items: T[] = [];
|
const items: T[] = [];
|
||||||
@@ -239,14 +245,14 @@ async function waitFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
queryFactory.mockReset();
|
sdkMocks.query.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("interrupt only calls query.interrupt and leaves the query open", async () => {
|
test("interrupt only calls query.interrupt and leaves the query open", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const queries: ScriptedQuery[] = [];
|
const queries: ScriptedQuery[] = [];
|
||||||
|
|
||||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
const scriptedQuery = createScriptedQuery({
|
const scriptedQuery = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "interrupt-keep-query-session",
|
sessionId: "interrupt-keep-query-session",
|
||||||
@@ -255,11 +261,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
|
|||||||
return scriptedQuery;
|
return scriptedQuery;
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -272,7 +274,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
|
|||||||
await session.interrupt();
|
await session.interrupt();
|
||||||
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
|
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
|
||||||
|
|
||||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||||
expect(queries[0]?.return).not.toHaveBeenCalled();
|
expect(queries[0]?.return).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const firstTurnEvents = await collectUntilTerminal(firstTurn);
|
const firstTurnEvents = await collectUntilTerminal(firstTurn);
|
||||||
@@ -289,7 +291,7 @@ test("reuses the existing query after interrupt before starting the next prompt"
|
|||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const queries: ScriptedQuery[] = [];
|
const queries: ScriptedQuery[] = [];
|
||||||
|
|
||||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
const scriptedQuery = createScriptedQuery({
|
const scriptedQuery = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "interrupt-reuse-query-session",
|
sessionId: "interrupt-reuse-query-session",
|
||||||
@@ -309,11 +311,7 @@ test("reuses the existing query after interrupt before starting the next prompt"
|
|||||||
return scriptedQuery;
|
return scriptedQuery;
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -328,7 +326,7 @@ test("reuses the existing query after interrupt before starting the next prompt"
|
|||||||
|
|
||||||
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
||||||
|
|
||||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||||
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
|
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||||
"first prompt",
|
"first prompt",
|
||||||
"second prompt",
|
"second prompt",
|
||||||
@@ -344,7 +342,7 @@ test("emits an assistant system notice when Claude changes session id mid-turn",
|
|||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
let queryRef: ScriptedQuery | null = null;
|
let queryRef: ScriptedQuery | null = null;
|
||||||
|
|
||||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
queryRef = createScriptedQuery({
|
queryRef = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "claude-original-session",
|
sessionId: "claude-original-session",
|
||||||
@@ -363,11 +361,7 @@ test("emits an assistant system notice when Claude changes session id mid-turn",
|
|||||||
return queryRef;
|
return queryRef;
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -396,7 +390,7 @@ test("recovers when the query pump sees a single interrupt abort before the next
|
|||||||
const prompts: PromptRecord[] = [];
|
const prompts: PromptRecord[] = [];
|
||||||
let throwAbortOnNext = false;
|
let throwAbortOnNext = false;
|
||||||
|
|
||||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
const scriptedQuery = {
|
const scriptedQuery = {
|
||||||
next: vi.fn(async () => {
|
next: vi.fn(async () => {
|
||||||
if (throwAbortOnNext) {
|
if (throwAbortOnNext) {
|
||||||
@@ -461,11 +455,7 @@ test("recovers when the query pump sees a single interrupt abort before the next
|
|||||||
return scriptedQuery;
|
return scriptedQuery;
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -478,7 +468,7 @@ test("recovers when the query pump sees a single interrupt abort before the next
|
|||||||
|
|
||||||
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
||||||
|
|
||||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||||
expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
|
expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
|
||||||
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
||||||
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||||
@@ -490,7 +480,7 @@ test("stale abort result after replacement start does not poison the new foregro
|
|||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
let queryRef: ScriptedQuery | null = null;
|
let queryRef: ScriptedQuery | null = null;
|
||||||
|
|
||||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
queryRef = createScriptedQuery({
|
queryRef = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "interrupt-stale-result-session",
|
sessionId: "interrupt-stale-result-session",
|
||||||
@@ -498,11 +488,7 @@ test("stale abort result after replacement start does not poison the new foregro
|
|||||||
return queryRef;
|
return queryRef;
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -559,7 +545,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo
|
|||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
let queryRef: ScriptedQuery | null = null;
|
let queryRef: ScriptedQuery | null = null;
|
||||||
|
|
||||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
queryRef = createScriptedQuery({
|
queryRef = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "autonomous-live-session",
|
sessionId: "autonomous-live-session",
|
||||||
@@ -578,11 +564,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo
|
|||||||
return queryRef;
|
return queryRef;
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -624,7 +606,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
|
|||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
let queryRef: ScriptedQuery | null = null;
|
let queryRef: ScriptedQuery | null = null;
|
||||||
|
|
||||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
queryRef = createScriptedQuery({
|
queryRef = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "autonomous-handoff-session",
|
sessionId: "autonomous-handoff-session",
|
||||||
@@ -652,11 +634,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
|
|||||||
return queryRef;
|
return queryRef;
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -699,7 +677,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
|
|||||||
(event) => event?.type === "turn_canceled",
|
(event) => event?.type === "turn_canceled",
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||||
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
|
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||||
"seed prompt",
|
"seed prompt",
|
||||||
"foreground prompt",
|
"foreground prompt",
|
||||||
+8
-5
@@ -1,10 +1,13 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import type { AgentStreamEvent, AgentSession } from "../../agent-sdk-types.js";
|
import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js";
|
||||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
import { isCommandAvailable } from "../../../utils/executable.js";
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||||
|
|
||||||
|
const hasClaudeCredentials =
|
||||||
|
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +32,7 @@ describe("Claude max effort availability (real)", () => {
|
|||||||
let canRun = false;
|
let canRun = false;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
canRun = await isProviderAvailable("claude");
|
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach((context) => {
|
beforeEach((context) => {
|
||||||
+6
-10
@@ -1,11 +1,11 @@
|
|||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { asInternals } from "../../../test-utils/class-mocks.js";
|
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||||
import { ClaudeAgentClient, readEventIdentifiers } from "./agent.js";
|
import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js";
|
||||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||||
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
|
||||||
|
|
||||||
interface QueryMock {
|
interface QueryMock {
|
||||||
next: ReturnType<typeof vi.fn>;
|
next: ReturnType<typeof vi.fn>;
|
||||||
@@ -66,7 +66,6 @@ async function createSession() {
|
|||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
queryFactory: sdkQueryFactory,
|
queryFactory: sdkQueryFactory,
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
});
|
||||||
return client.createSession({
|
return client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
@@ -78,7 +77,6 @@ function createSessionWithLogger(logger: Logger) {
|
|||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
logger,
|
logger,
|
||||||
queryFactory: sdkQueryFactory,
|
queryFactory: sdkQueryFactory,
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
});
|
||||||
return client.createSession({
|
return client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
@@ -225,7 +223,6 @@ test("logs redacted query summary and never leaks sentinel secrets", async () =>
|
|||||||
PASEO_RUNTIME_SENTINEL_SECRET: runtimeSecret,
|
PASEO_RUNTIME_SENTINEL_SECRET: runtimeSecret,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
});
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
@@ -660,7 +657,7 @@ test("Grep tool_result string content flows to a search detail with content", as
|
|||||||
grepEntry,
|
grepEntry,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mapClaudeCompletedToolCall } = await import("./tool-call-mapper.js");
|
const { mapClaudeCompletedToolCall } = await import("./claude/tool-call-mapper.js");
|
||||||
const item = mapClaudeCompletedToolCall({
|
const item = mapClaudeCompletedToolCall({
|
||||||
callId: "tool-grep-1",
|
callId: "tool-grep-1",
|
||||||
name: "Grep",
|
name: "Grep",
|
||||||
@@ -800,7 +797,6 @@ test("captures Claude stderr in the turn failure diagnostic when stderr arrives
|
|||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
logger: loggerSpy.logger,
|
logger: loggerSpy.logger,
|
||||||
queryFactory: sdkQueryFactory,
|
queryFactory: sdkQueryFactory,
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
});
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
+9
-10
@@ -1,16 +1,16 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import type {
|
import {
|
||||||
Options,
|
query,
|
||||||
Query,
|
type Options,
|
||||||
SpawnOptions as ClaudeSpawnOptions,
|
type Query,
|
||||||
|
type SpawnOptions as ClaudeSpawnOptions,
|
||||||
} from "@anthropic-ai/claude-agent-sdk";
|
} from "@anthropic-ai/claude-agent-sdk";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import * as spawnUtils from "../../../../utils/spawn.js";
|
import * as spawnUtils from "../../../utils/spawn.js";
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
import type { ClaudeQueryInput } from "./query.js";
|
|
||||||
|
|
||||||
function createQueryMock(events: unknown[]): Query {
|
function createQueryMock(events: unknown[]): Query {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -47,7 +47,7 @@ describe("Claude spawn override", () => {
|
|||||||
|
|
||||||
test("bypasses the shell when spawning Claude Code", async () => {
|
test("bypasses the shell when spawning Claude Code", async () => {
|
||||||
let capturedOptions: Options | undefined;
|
let capturedOptions: Options | undefined;
|
||||||
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
|
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
||||||
capturedOptions = options;
|
capturedOptions = options;
|
||||||
return createQueryMock([
|
return createQueryMock([
|
||||||
{
|
{
|
||||||
@@ -77,7 +77,6 @@ describe("Claude spawn override", () => {
|
|||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
queryFactory,
|
queryFactory,
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
});
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
+18
-20
@@ -1,13 +1,19 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
import type { AgentTimelineRow } from "../../agent-manager.js";
|
import type { AgentTimelineRow } from "../agent-manager.js";
|
||||||
import { projectTimelineRows } from "../../timeline-projection.js";
|
import { projectTimelineRows } from "../timeline-projection.js";
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||||
|
|
||||||
const queryFactory = vi.fn();
|
const sdkMocks = vi.hoisted(() => ({
|
||||||
|
query: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||||
|
query: sdkMocks.query,
|
||||||
|
}));
|
||||||
|
|
||||||
interface QueryMock {
|
interface QueryMock {
|
||||||
next: ReturnType<typeof vi.fn>;
|
next: ReturnType<typeof vi.fn>;
|
||||||
@@ -144,7 +150,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const largeOldText = "VERY_LARGE_OLD_STRING".repeat(50);
|
const largeOldText = "VERY_LARGE_OLD_STRING".repeat(50);
|
||||||
queryFactory.mockImplementation(() =>
|
sdkMocks.query.mockImplementation(() =>
|
||||||
buildQueryMock([
|
buildQueryMock([
|
||||||
{
|
{
|
||||||
type: "system",
|
type: "system",
|
||||||
@@ -241,15 +247,11 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
queryFactory.mockReset();
|
sdkMocks.query.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
|
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
|
||||||
const session = await new ClaudeAgentClient({
|
const session = await new ClaudeAgentClient({ logger }).createSession({
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
}).createSession({
|
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
});
|
});
|
||||||
@@ -296,13 +298,9 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
|
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
|
||||||
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
sdkMocks.query.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
||||||
|
|
||||||
const session = await new ClaudeAgentClient({
|
const session = await new ClaudeAgentClient({ logger }).createSession({
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
}).createSession({
|
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
});
|
});
|
||||||
+8
-76
@@ -1,24 +1,19 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import * as executableUtils from "../../../../utils/executable.js";
|
|
||||||
import {
|
import {
|
||||||
ClaudeAgentClient,
|
ClaudeAgentClient,
|
||||||
convertClaudeHistoryEntry,
|
convertClaudeHistoryEntry,
|
||||||
normalizeClaudeAskUserQuestionUpdatedInput,
|
normalizeClaudeAskUserQuestionUpdatedInput,
|
||||||
} from "./agent.js";
|
} from "./claude-agent.js";
|
||||||
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../../agent-sdk-types.js";
|
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
|
|
||||||
interface TestClaudeSession {
|
interface TestClaudeSession {
|
||||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||||
convertUsage(message: SDKMessage): AgentUsage | undefined;
|
convertUsage(message: SDKMessage): AgentUsage | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("convertClaudeHistoryEntry", () => {
|
describe("convertClaudeHistoryEntry", () => {
|
||||||
test("maps user tool results to timeline items", () => {
|
test("maps user tool results to timeline items", () => {
|
||||||
const toolUseId = "toolu_test";
|
const toolUseId = "toolu_test";
|
||||||
@@ -351,7 +346,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
|||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
test("returns hardcoded claude models", async () => {
|
test("returns hardcoded claude models", async () => {
|
||||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
const client = new ClaudeAgentClient({ logger });
|
||||||
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
|
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
|
||||||
|
|
||||||
expect(models.map((m) => m.id)).toEqual([
|
expect(models.map((m) => m.id)).toEqual([
|
||||||
@@ -359,7 +354,6 @@ describe("ClaudeAgentClient.listModels", () => {
|
|||||||
"claude-opus-4-7",
|
"claude-opus-4-7",
|
||||||
"claude-opus-4-6[1m]",
|
"claude-opus-4-6[1m]",
|
||||||
"claude-opus-4-6",
|
"claude-opus-4-6",
|
||||||
"claude-sonnet-4-6[1m]",
|
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
"claude-haiku-4-5",
|
"claude-haiku-4-5",
|
||||||
]);
|
]);
|
||||||
@@ -374,59 +368,6 @@ describe("ClaudeAgentClient.listModels", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ClaudeAgentClient binary resolution", () => {
|
|
||||||
const logger = createTestLogger();
|
|
||||||
|
|
||||||
test("uses the replace-command override binary when claude is not on PATH", async () => {
|
|
||||||
const customClaudePath = "/path/to/custom-claude";
|
|
||||||
vi.spyOn(executableUtils, "findExecutable").mockImplementation(async (name: string) => {
|
|
||||||
if (name === "claude") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (name === customClaudePath) {
|
|
||||||
return customClaudePath;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryReturn = vi.fn();
|
|
||||||
queryReturn.mockResolvedValue(undefined);
|
|
||||||
const queryFactory = vi.fn(() => ({
|
|
||||||
close: vi.fn(),
|
|
||||||
return: queryReturn,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const client = new ClaudeAgentClient({
|
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
runtimeSettings: {
|
|
||||||
command: {
|
|
||||||
mode: "replace",
|
|
||||||
argv: [customClaudePath],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const session = await client.createSession({
|
|
||||||
provider: "claude",
|
|
||||||
cwd: process.cwd(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
(
|
|
||||||
session as unknown as {
|
|
||||||
ensureQuery(): Promise<unknown>;
|
|
||||||
}
|
|
||||||
).ensureQuery(),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
|
|
||||||
expect(queryFactory.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable).toBe(
|
|
||||||
customClaudePath,
|
|
||||||
);
|
|
||||||
|
|
||||||
await session.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
||||||
test("maps frontend header-keyed answers to Claude question text keys", () => {
|
test("maps frontend header-keyed answers to Claude question text keys", () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -488,10 +429,7 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("respondToPermission preserves full question input when UI returns answers-only payload", async () => {
|
test("respondToPermission preserves full question input when UI returns answers-only payload", async () => {
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||||
logger: createTestLogger(),
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -566,7 +504,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
async function createSessionForTest(): Promise<TestClaudeSession> {
|
async function createSessionForTest(): Promise<TestClaudeSession> {
|
||||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
const client = new ClaudeAgentClient({ logger });
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -666,7 +604,6 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
const nonPersistedClient = new ClaudeAgentClient({
|
const nonPersistedClient = new ClaudeAgentClient({
|
||||||
logger,
|
logger,
|
||||||
queryFactory: nonPersistedQueryFactory,
|
queryFactory: nonPersistedQueryFactory,
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
});
|
||||||
const nonPersistedSession = await nonPersistedClient.createSession(
|
const nonPersistedSession = await nonPersistedClient.createSession(
|
||||||
{
|
{
|
||||||
@@ -685,7 +622,6 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
const persistedClient = new ClaudeAgentClient({
|
const persistedClient = new ClaudeAgentClient({
|
||||||
logger,
|
logger,
|
||||||
queryFactory: persistedQueryFactory,
|
queryFactory: persistedQueryFactory,
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
});
|
||||||
const persistedSession = await persistedClient.createSession(
|
const persistedSession = await persistedClient.createSession(
|
||||||
{
|
{
|
||||||
@@ -1043,11 +979,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger, queryFactory });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
+129
-60
@@ -1,16 +1,20 @@
|
|||||||
|
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { promises } from "node:fs";
|
import { promises } from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
|
query,
|
||||||
type AgentDefinition,
|
type AgentDefinition,
|
||||||
type CanUseTool,
|
type CanUseTool,
|
||||||
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
||||||
|
type Options,
|
||||||
type PermissionMode,
|
type PermissionMode,
|
||||||
type PermissionResult,
|
type PermissionResult,
|
||||||
type PermissionUpdate,
|
type PermissionUpdate,
|
||||||
type Query,
|
type Query,
|
||||||
|
type SpawnOptions,
|
||||||
type SDKMessage,
|
type SDKMessage,
|
||||||
type SDKPartialAssistantMessage,
|
type SDKPartialAssistantMessage,
|
||||||
type SDKTaskProgressMessage,
|
type SDKTaskProgressMessage,
|
||||||
@@ -24,23 +28,22 @@ import {
|
|||||||
mapClaudeCompletedToolCall,
|
mapClaudeCompletedToolCall,
|
||||||
mapClaudeFailedToolCall,
|
mapClaudeFailedToolCall,
|
||||||
mapClaudeRunningToolCall,
|
mapClaudeRunningToolCall,
|
||||||
} from "./tool-call-mapper.js";
|
} from "./claude/tool-call-mapper.js";
|
||||||
import {
|
import {
|
||||||
mapTaskNotificationSystemRecordToToolCall,
|
mapTaskNotificationSystemRecordToToolCall,
|
||||||
mapTaskNotificationUserContentToToolCall,
|
mapTaskNotificationUserContentToToolCall,
|
||||||
} from "./task-notification-tool-call.js";
|
} from "./claude/task-notification-tool-call.js";
|
||||||
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude/claude-models.js";
|
||||||
import { parsePartialJsonObject } from "./partial-json.js";
|
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
||||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
||||||
import {
|
import {
|
||||||
formatDiagnosticStatus,
|
formatDiagnosticStatus,
|
||||||
formatProviderDiagnostic,
|
formatProviderDiagnostic,
|
||||||
formatProviderDiagnosticError,
|
formatProviderDiagnosticError,
|
||||||
toDiagnosticErrorMessage,
|
toDiagnosticErrorMessage,
|
||||||
} from "../diagnostic-utils.js";
|
} from "./diagnostic-utils.js";
|
||||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
|
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentPermissionAction,
|
AgentPermissionAction,
|
||||||
@@ -70,19 +73,20 @@ import type {
|
|||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
} from "../../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
createProviderEnv,
|
createProviderEnv,
|
||||||
createProviderEnvSpec,
|
createProviderEnvSpec,
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js";
|
import { buildSelfNodeCommand } from "../../paseo-env.js";
|
||||||
import { withTimeout } from "../../../../utils/promise-timeout.js";
|
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||||
import { execCommand } from "../../../../utils/spawn.js";
|
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||||
import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js";
|
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
||||||
|
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||||
|
|
||||||
const fsPromises = promises;
|
const fsPromises = promises;
|
||||||
const CLAUDE_SETTING_SOURCES: NonNullable<ClaudeOptions["settingSources"]> = ["user", "project"];
|
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
|
||||||
|
|
||||||
function readNonEmptyString(value: unknown): string | null {
|
function readNonEmptyString(value: unknown): string | null {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||||
@@ -151,6 +155,10 @@ function isUnknownArray(value: unknown): value is readonly unknown[] {
|
|||||||
return Array.isArray(value);
|
return Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams {
|
||||||
|
return child.stdin !== null && child.stdout !== null && child.stderr !== null;
|
||||||
|
}
|
||||||
|
|
||||||
function isImageMimeType(
|
function isImageMimeType(
|
||||||
value: string,
|
value: string,
|
||||||
): value is "image/jpeg" | "image/png" | "image/gif" | "image/webp" {
|
): value is "image/jpeg" | "image/png" | "image/gif" | "image/webp" {
|
||||||
@@ -231,6 +239,7 @@ interface SlashCommandInvocation {
|
|||||||
rawInput: string;
|
rawInput: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Orchestrator instructions moved to shared module.
|
||||||
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
||||||
|
|
||||||
export interface ClaudeContentChunk {
|
export interface ClaudeContentChunk {
|
||||||
@@ -238,12 +247,13 @@ export interface ClaudeContentChunk {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClaudeOptions = Options;
|
||||||
|
|
||||||
interface ClaudeAgentClientOptions {
|
interface ClaudeAgentClientOptions {
|
||||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
runtimeSettings?: ProviderRuntimeSettings;
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
queryFactory?: ClaudeQueryFactory;
|
queryFactory?: typeof query;
|
||||||
resolveBinary?: () => Promise<string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaudeAgentSessionOptions {
|
interface ClaudeAgentSessionOptions {
|
||||||
@@ -253,8 +263,7 @@ interface ClaudeAgentSessionOptions {
|
|||||||
launchEnv?: Record<string, string>;
|
launchEnv?: Record<string, string>;
|
||||||
persistSession?: boolean;
|
persistSession?: boolean;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
queryFactory?: ClaudeQueryFactory;
|
queryFactory?: typeof query;
|
||||||
resolveBinary: () => Promise<string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
|
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||||
@@ -294,6 +303,86 @@ function extractSessionIdRaw(msg: {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveClaudeSpawnCommand(
|
||||||
|
spawnOptions: SpawnOptions,
|
||||||
|
runtimeSettings?: ProviderRuntimeSettings,
|
||||||
|
): { command: string; args: string[] } {
|
||||||
|
const commandConfig = runtimeSettings?.command;
|
||||||
|
if (!commandConfig || commandConfig.mode === "default") {
|
||||||
|
return {
|
||||||
|
command: spawnOptions.command,
|
||||||
|
args: [...spawnOptions.args],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandConfig.mode === "append") {
|
||||||
|
return {
|
||||||
|
command: spawnOptions.command,
|
||||||
|
args: [...spawnOptions.args, ...(commandConfig.args ?? [])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: commandConfig.argv[0],
|
||||||
|
args: [...commandConfig.argv.slice(1), ...spawnOptions.args],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRuntimeSettingsToClaudeOptions(
|
||||||
|
options: ClaudeOptions,
|
||||||
|
runtimeSettings?: ProviderRuntimeSettings,
|
||||||
|
launchEnv?: Record<string, string>,
|
||||||
|
): ClaudeOptions {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
spawnClaudeCodeProcess: (spawnOptions) => {
|
||||||
|
const resolved = resolveClaudeSpawnCommand(spawnOptions, runtimeSettings);
|
||||||
|
// When the SDK passes a default JS runtime ("node"/"bun"), replace it with
|
||||||
|
// process.execPath — the actual node binary running the daemon. This avoids
|
||||||
|
// PATH lookup failures in the managed runtime bundle.
|
||||||
|
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
|
||||||
|
// or the user overrides the command via runtime settings, use that directly.
|
||||||
|
const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun";
|
||||||
|
const providerEnvSpec = createProviderEnvSpec({
|
||||||
|
baseEnv: spawnOptions.env,
|
||||||
|
runtimeSettings,
|
||||||
|
overlays: [launchEnv],
|
||||||
|
});
|
||||||
|
const providerEnv = createProviderEnv({
|
||||||
|
baseEnv: spawnOptions.env,
|
||||||
|
runtimeSettings,
|
||||||
|
overlays: [launchEnv],
|
||||||
|
});
|
||||||
|
const selfNodeCommand = isDefaultRuntime
|
||||||
|
? buildSelfNodeCommand(resolved.args, providerEnv)
|
||||||
|
: null;
|
||||||
|
const command = selfNodeCommand?.command ?? resolved.command;
|
||||||
|
const args = selfNodeCommand?.args ?? resolved.args;
|
||||||
|
const child = spawnProcess(command, args, {
|
||||||
|
cwd: spawnOptions.cwd,
|
||||||
|
...(selfNodeCommand
|
||||||
|
? { env: selfNodeCommand.env, envMode: "internal" as const }
|
||||||
|
: providerEnvSpec),
|
||||||
|
signal: spawnOptions.signal,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
// Bypass cmd.exe on Windows: the SDK passes --mcp-config with inline JSON
|
||||||
|
// containing double quotes, which cmd.exe mangles (strips quotes, breaks parsing).
|
||||||
|
// The command is always a resolved binary path, so shell routing is unnecessary.
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
if (typeof options.stderr === "function") {
|
||||||
|
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||||
|
options.stderr?.(chunk.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isChildProcessWithStreams(child)) {
|
||||||
|
throw new Error("Claude process was spawned without stdio streams");
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
|
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
|
||||||
return (
|
return (
|
||||||
value === "low" ||
|
value === "low" ||
|
||||||
@@ -1161,15 +1250,13 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
private readonly queryFactory?: ClaudeQueryFactory;
|
private readonly queryFactory: typeof query;
|
||||||
private readonly resolveBinary: () => Promise<string>;
|
|
||||||
|
|
||||||
constructor(options: ClaudeAgentClientOptions) {
|
constructor(options: ClaudeAgentClientOptions) {
|
||||||
this.defaults = options.defaults;
|
this.defaults = options.defaults;
|
||||||
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
||||||
this.runtimeSettings = options.runtimeSettings;
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
this.queryFactory = options.queryFactory;
|
this.queryFactory = options.queryFactory ?? query;
|
||||||
this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(
|
async createSession(
|
||||||
@@ -1185,7 +1272,6 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
persistSession: options?.persistSession,
|
persistSession: options?.persistSession,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
queryFactory: this.queryFactory,
|
queryFactory: this.queryFactory,
|
||||||
resolveBinary: this.resolveBinary,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,7 +1298,6 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
launchEnv: launchContext?.env,
|
launchEnv: launchContext?.env,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
queryFactory: this.queryFactory,
|
queryFactory: this.queryFactory,
|
||||||
resolveBinary: this.resolveBinary,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1244,7 +1329,9 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
if (command?.mode === "replace") {
|
if (command?.mode === "replace") {
|
||||||
return await isCommandAvailable(command.argv[0]);
|
return await isCommandAvailable(command.argv[0]);
|
||||||
}
|
}
|
||||||
return await isCommandAvailable("claude");
|
// Default mode uses @anthropic-ai/claude-agent-sdk's bundled cli.js run
|
||||||
|
// via process.execPath. No external `claude` binary is required.
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||||
@@ -1296,24 +1383,6 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): Promise<string> {
|
|
||||||
const command = runtimeSettings?.command;
|
|
||||||
if (command?.mode === "replace") {
|
|
||||||
const foundOverride = await findExecutable(command.argv[0]);
|
|
||||||
if (foundOverride) {
|
|
||||||
return foundOverride;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const found = await findExecutable("claude");
|
|
||||||
if (found) {
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
"Claude binary not found. Install Claude Code (https://github.com/anthropics/claude-code) and ensure it is available in your shell PATH.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveClaudeVersion(
|
async function resolveClaudeVersion(
|
||||||
runtimeSettings?: ProviderRuntimeSettings,
|
runtimeSettings?: ProviderRuntimeSettings,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
@@ -1481,8 +1550,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
private readonly persistSession?: boolean;
|
private readonly persistSession?: boolean;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly queryFactory?: ClaudeQueryFactory;
|
private readonly queryFactory: typeof query;
|
||||||
private readonly resolveBinary: () => Promise<string>;
|
|
||||||
private query: Query | null = null;
|
private query: Query | null = null;
|
||||||
private input: AsyncMessageInput<SDKUserMessage> | null = null;
|
private input: AsyncMessageInput<SDKUserMessage> | null = null;
|
||||||
private claudeSessionId: string | null;
|
private claudeSessionId: string | null;
|
||||||
@@ -1530,8 +1598,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.runtimeSettings = options.runtimeSettings;
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
this.persistSession = options.persistSession;
|
this.persistSession = options.persistSession;
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
this.queryFactory = options.queryFactory;
|
this.queryFactory = options.queryFactory ?? query;
|
||||||
this.resolveBinary = options.resolveBinary;
|
|
||||||
const handle = options.handle;
|
const handle = options.handle;
|
||||||
|
|
||||||
if (handle) {
|
if (handle) {
|
||||||
@@ -2151,14 +2218,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const options = await this.buildOptions();
|
const options = await this.buildOptions();
|
||||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||||
this.input = input;
|
this.input = input;
|
||||||
this.query = claudeQuery(
|
this.query = this.queryFactory({ prompt: input.iterable, options });
|
||||||
{ prompt: input.iterable, options },
|
|
||||||
{
|
|
||||||
runtimeSettings: this.runtimeSettings,
|
|
||||||
launchEnv: this.launchEnv,
|
|
||||||
queryFactory: this.queryFactory,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Do not kick off background control-plane queries here. Methods like
|
// Do not kick off background control-plane queries here. Methods like
|
||||||
// supportedCommands()/setPermissionMode() may execute immediately after
|
// supportedCommands()/setPermissionMode() may execute immediately after
|
||||||
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
|
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
|
||||||
@@ -2196,6 +2256,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
? this.config.thinkingOptionId
|
? this.config.thinkingOptionId
|
||||||
: undefined;
|
: undefined;
|
||||||
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
|
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
|
||||||
|
if (thinkingOptionId === "xhigh") {
|
||||||
|
// "xhigh" is accepted by Claude Opus 4.7 but not yet in the SDK type definitions
|
||||||
|
// @ts-expect-error -- SDK 0.2.71 effort type doesn't include "xhigh" yet
|
||||||
|
return { thinking: { type: "adaptive" }, effort: thinkingOptionId };
|
||||||
|
}
|
||||||
return { thinking: { type: "adaptive" }, effort: thinkingOptionId };
|
return { thinking: { type: "adaptive" }, effort: thinkingOptionId };
|
||||||
}
|
}
|
||||||
return { thinking: undefined, effort: undefined };
|
return { thinking: undefined, effort: undefined };
|
||||||
@@ -2225,7 +2290,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const claudeBinary = await this.resolveBinary();
|
const claudeBinary = await findExecutable("claude");
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
{
|
{
|
||||||
claudeBinary,
|
claudeBinary,
|
||||||
@@ -2246,7 +2311,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
allowDangerouslySkipPermissions: true,
|
allowDangerouslySkipPermissions: true,
|
||||||
agents: this.defaults?.agents,
|
agents: this.defaults?.agents,
|
||||||
canUseTool: this.handlePermissionRequest,
|
canUseTool: this.handlePermissionRequest,
|
||||||
pathToClaudeCodeExecutable: claudeBinary,
|
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
|
||||||
// Use Claude Code preset system prompt and load CLAUDE.md files
|
// Use Claude Code preset system prompt and load CLAUDE.md files
|
||||||
// Append provider-agnostic system prompt and orchestrator instructions for agents.
|
// Append provider-agnostic system prompt and orchestrator instructions for agents.
|
||||||
systemPrompt: {
|
systemPrompt: {
|
||||||
@@ -2288,7 +2353,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
...this.runtimeSettings.disallowedTools,
|
...this.runtimeSettings.disallowedTools,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return base;
|
return this.applyRuntimeSettings(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
|
||||||
|
return applyRuntimeSettingsToClaudeOptions(options, this.runtimeSettings, this.launchEnv);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMcpServers(
|
private normalizeMcpServers(
|
||||||
+23
-33
@@ -3,13 +3,19 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||||
import type { AgentPersistenceHandle, AgentStreamEvent } from "../../agent-sdk-types.js";
|
import type { AgentPersistenceHandle, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
|
|
||||||
const queryFactory = vi.fn();
|
const sdkMocks = vi.hoisted(() => ({
|
||||||
let lastQuery: ReturnType<typeof buildSdkQueryMock> | null = null;
|
query: vi.fn(),
|
||||||
|
lastQuery: null as ReturnType<typeof buildSdkQueryMock> | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||||
|
query: sdkMocks.query,
|
||||||
|
}));
|
||||||
|
|
||||||
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
|
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
|
||||||
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
||||||
@@ -93,9 +99,9 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
let previousClaudeConfigDir: string | undefined;
|
let previousClaudeConfigDir: string | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
queryFactory.mockImplementation(() => {
|
sdkMocks.query.mockImplementation(() => {
|
||||||
const mock = buildSdkQueryMock();
|
const mock = buildSdkQueryMock();
|
||||||
lastQuery = mock;
|
sdkMocks.lastQuery = mock;
|
||||||
return mock;
|
return mock;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,8 +145,8 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
queryFactory.mockReset();
|
sdkMocks.query.mockReset();
|
||||||
lastQuery = null;
|
sdkMocks.lastQuery = null;
|
||||||
if (previousClaudeConfigDir === undefined) {
|
if (previousClaudeConfigDir === undefined) {
|
||||||
delete process.env.CLAUDE_CONFIG_DIR;
|
delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
} else {
|
} else {
|
||||||
@@ -151,11 +157,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
|
|
||||||
test("does not replay persisted history during the first live stream turn", async () => {
|
test("does not replay persisted history during the first live stream turn", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const handle: AgentPersistenceHandle = {
|
const handle: AgentPersistenceHandle = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: "history-session",
|
sessionId: "history-session",
|
||||||
@@ -192,11 +194,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
|
|
||||||
test("still exposes persisted history through streamHistory", async () => {
|
test("still exposes persisted history through streamHistory", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const handle: AgentPersistenceHandle = {
|
const handle: AgentPersistenceHandle = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: "history-session",
|
sessionId: "history-session",
|
||||||
@@ -225,11 +223,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
|
|
||||||
test("listCommands includes rewind command", async () => {
|
test("listCommands includes rewind command", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const handle: AgentPersistenceHandle = {
|
const handle: AgentPersistenceHandle = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: "history-session",
|
sessionId: "history-session",
|
||||||
@@ -251,11 +245,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
|
|
||||||
test("slash /rewind uses latest user message id from persisted history", async () => {
|
test("slash /rewind uses latest user message id from persisted history", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({ logger });
|
||||||
logger,
|
|
||||||
queryFactory,
|
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
|
||||||
});
|
|
||||||
const handle: AgentPersistenceHandle = {
|
const handle: AgentPersistenceHandle = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: "history-session",
|
sessionId: "history-session",
|
||||||
@@ -286,9 +276,9 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
|
|
||||||
expect(events.some((event) => event.type === "turn_started")).toBe(true);
|
expect(events.some((event) => event.type === "turn_started")).toBe(true);
|
||||||
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
|
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
|
||||||
expect(lastQuery).toBeTruthy();
|
expect(sdkMocks.lastQuery).toBeTruthy();
|
||||||
expect(lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
|
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
|
||||||
expect(lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", {
|
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+15
-22
@@ -1,14 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Direct SDK behavior tests - uses same setup as the Claude provider
|
* Direct SDK behavior tests - uses same setup as claude-agent.ts
|
||||||
*/
|
*/
|
||||||
import { mkdtempSync, rmSync, realpathSync } from "node:fs";
|
import { mkdtempSync, rmSync, realpathSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||||
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||||
import { findExecutable } from "../../../../utils/executable.js";
|
|
||||||
import { claudeQuery } from "./query.js";
|
|
||||||
|
|
||||||
class Pushable<T> implements AsyncIterable<T> {
|
class Pushable<T> implements AsyncIterable<T> {
|
||||||
private queue: T[] = [];
|
private queue: T[] = [];
|
||||||
@@ -55,17 +53,6 @@ function tmpCwd(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rmCwd(cwd: string): void {
|
|
||||||
try {
|
|
||||||
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
||||||
} catch (error) {
|
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
|
||||||
if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTextFromEvents(events: SDKMessage[]): string {
|
function extractTextFromEvents(events: SDKMessage[]): string {
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
@@ -85,15 +72,21 @@ function extractTextFromEvents(events: SDKMessage[]): string {
|
|||||||
return responseText;
|
return responseText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasClaudeCredentials =
|
||||||
|
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
describe("Claude SDK direct behavior", () => {
|
describe("Claude SDK direct behavior", () => {
|
||||||
let canRun = false;
|
let canRunClaudeIntegration = false;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
canRun = await isProviderAvailable("claude");
|
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
||||||
|
if (canRunClaudeIntegration) {
|
||||||
|
expect(await isCommandAvailable("claude")).toBe(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach((context) => {
|
beforeEach((context) => {
|
||||||
if (!canRun) {
|
if (!canRunClaudeIntegration) {
|
||||||
context.skip();
|
context.skip();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -103,8 +96,8 @@ describe("Claude SDK direct behavior", () => {
|
|||||||
const input = new Pushable<SDKUserMessage>();
|
const input = new Pushable<SDKUserMessage>();
|
||||||
const claudeBinary = await findExecutable("claude");
|
const claudeBinary = await findExecutable("claude");
|
||||||
|
|
||||||
// Use same options as the Claude provider
|
// Use same options as claude-agent.ts
|
||||||
const q = claudeQuery({
|
const q = query({
|
||||||
prompt: input,
|
prompt: input,
|
||||||
options: {
|
options: {
|
||||||
cwd,
|
cwd,
|
||||||
@@ -167,7 +160,7 @@ describe("Claude SDK direct behavior", () => {
|
|||||||
expect(sawResult || responseText.length === 0).toBe(true);
|
expect(sawResult || responseText.length === 0).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
input.end();
|
input.end();
|
||||||
rmCwd(cwd);
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}, 120000);
|
}, 120000);
|
||||||
});
|
});
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
import { describe, expect, test, beforeAll, beforeEach } from "vitest";
|
|
||||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import pino from "pino";
|
|
||||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AgentSession,
|
|
||||||
AgentStreamEvent,
|
|
||||||
ToolCallTimelineItem,
|
|
||||||
} from "../../agent-sdk-types.js";
|
|
||||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
|
||||||
import { findExecutable } from "../../../../utils/executable.js";
|
|
||||||
import { withTimeout } from "../../../../utils/promise-timeout.js";
|
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
|
||||||
import { claudeQuery } from "./query.js";
|
|
||||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
|
||||||
|
|
||||||
const logger = pino({ level: "silent" });
|
|
||||||
const client = new ClaudeAgentClient({ logger });
|
|
||||||
|
|
||||||
function tmpCwd(prefix: string): string {
|
|
||||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
|
||||||
return (async function* empty() {})();
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactText(value: string): string {
|
|
||||||
return value.replace(/\s+/g, "").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
|
||||||
return (
|
|
||||||
event.type === "turn_completed" ||
|
|
||||||
event.type === "turn_failed" ||
|
|
||||||
event.type === "turn_canceled"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function nextStreamEvent(
|
|
||||||
stream: AsyncGenerator<AgentStreamEvent>,
|
|
||||||
timeoutMs: number,
|
|
||||||
label: string,
|
|
||||||
): Promise<IteratorResult<AgentStreamEvent>> {
|
|
||||||
return await withTimeout(stream.next(), timeoutMs, `Timed out waiting for ${label}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectUntilTerminal(
|
|
||||||
stream: AsyncGenerator<AgentStreamEvent>,
|
|
||||||
options?: {
|
|
||||||
timeoutMs?: number;
|
|
||||||
onEvent?: (event: AgentStreamEvent) => Promise<void> | void;
|
|
||||||
},
|
|
||||||
): Promise<AgentStreamEvent[]> {
|
|
||||||
const events: AgentStreamEvent[] = [];
|
|
||||||
while (true) {
|
|
||||||
const next = await nextStreamEvent(stream, options?.timeoutMs ?? 45_000, "stream event");
|
|
||||||
if (next.done || !next.value) {
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
const event = next.value;
|
|
||||||
events.push(event);
|
|
||||||
await options?.onEvent?.(event);
|
|
||||||
if (isTerminalEvent(event)) {
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectUntil(
|
|
||||||
stream: AsyncGenerator<AgentStreamEvent>,
|
|
||||||
predicate: (event: AgentStreamEvent) => boolean,
|
|
||||||
timeoutMs = 45_000,
|
|
||||||
): Promise<AgentStreamEvent[]> {
|
|
||||||
const events: AgentStreamEvent[] = [];
|
|
||||||
while (true) {
|
|
||||||
const next = await nextStreamEvent(stream, timeoutMs, "matching stream event");
|
|
||||||
if (next.done || !next.value) {
|
|
||||||
throw new Error("Stream ended before the expected event arrived");
|
|
||||||
}
|
|
||||||
const event = next.value;
|
|
||||||
events.push(event);
|
|
||||||
if (predicate(event) || isTerminalEvent(event)) {
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectSubscribedUntil(
|
|
||||||
session: AgentSession,
|
|
||||||
predicate: (event: AgentStreamEvent) => boolean,
|
|
||||||
timeoutMs = 45_000,
|
|
||||||
): Promise<AgentStreamEvent[]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const events: AgentStreamEvent[] = [];
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
unsubscribe();
|
|
||||||
reject(new Error(`Timed out after ${timeoutMs}ms waiting for subscribed event`));
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
const unsubscribe = session.subscribe((event) => {
|
|
||||||
events.push(event);
|
|
||||||
if (!predicate(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearTimeout(timeout);
|
|
||||||
unsubscribe();
|
|
||||||
resolve(events);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAssistantText(events: AgentStreamEvent[]): string {
|
|
||||||
return events
|
|
||||||
.flatMap((event) => {
|
|
||||||
if (event.type !== "timeline" || event.item.type !== "assistant_message") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [event.item.text];
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getToolCalls(events: AgentStreamEvent[]): ToolCallTimelineItem[] {
|
|
||||||
return events.flatMap((event) => {
|
|
||||||
if (event.type !== "timeline" || event.item.type !== "tool_call") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [event.item];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelineItem | undefined {
|
|
||||||
return [...getToolCalls(events)]
|
|
||||||
.toReversed()
|
|
||||||
.find((item) => item.status === "completed" && item.name.toLowerCase() === "bash");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInternalQuery(session: AgentSession): unknown {
|
|
||||||
return (session as AgentSession & { query?: unknown }).query ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSession(params?: {
|
|
||||||
cwdPrefix?: string;
|
|
||||||
modeId?: string;
|
|
||||||
title?: string;
|
|
||||||
}): Promise<{ cwd: string; session: AgentSession }> {
|
|
||||||
const cwd = tmpCwd(params?.cwdPrefix ?? "claude-agent-integration-");
|
|
||||||
const session = await client.createSession({
|
|
||||||
provider: "claude",
|
|
||||||
cwd,
|
|
||||||
title: params?.title ?? "ClaudeAgentSession integration",
|
|
||||||
modeId: params?.modeId ?? "acceptEdits",
|
|
||||||
model: "haiku",
|
|
||||||
});
|
|
||||||
return { cwd, session };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
|
|
||||||
await handle.session.close().catch(() => undefined);
|
|
||||||
try {
|
|
||||||
rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
||||||
} catch (error) {
|
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
|
||||||
if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ClaudeAgentSession integration", () => {
|
|
||||||
let canRun = false;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
canRun = await isProviderAvailable("claude");
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach((context) => {
|
|
||||||
if (!canRun) {
|
|
||||||
context.skip();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("streams a basic response turn end-to-end", async () => {
|
|
||||||
const handle = await createSession({
|
|
||||||
cwdPrefix: "claude-agent-basic-response-",
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await collectUntilTerminal(
|
|
||||||
streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(events[0]).toMatchObject({
|
|
||||||
type: "turn_started",
|
|
||||||
provider: "claude",
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
events.some(
|
|
||||||
(event) =>
|
|
||||||
event.type === "timeline" &&
|
|
||||||
event.item.type === "assistant_message" &&
|
|
||||||
compactText(event.item.text).includes("hello_world"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(events.at(-1)).toMatchObject({
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "claude",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await cleanupSession(handle);
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
test("keeps bypassPermissions available after a thinking-option restart", async () => {
|
|
||||||
const handle = await createSession({
|
|
||||||
cwdPrefix: "claude-agent-bypass-restart-",
|
|
||||||
modeId: "bypassPermissions",
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handle.session.setMode("acceptEdits");
|
|
||||||
await handle.session.setThinkingOption("high");
|
|
||||||
await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined();
|
|
||||||
} finally {
|
|
||||||
await cleanupSession(handle);
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
test("supportedModels returns the current abstract Claude SDK model shape", async () => {
|
|
||||||
const claudeBinary = await findExecutable("claude");
|
|
||||||
if (!claudeBinary) throw new Error("claude binary required for this integration test");
|
|
||||||
const query = claudeQuery({
|
|
||||||
prompt: createEmptyPrompt(),
|
|
||||||
options: {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
permissionMode: "plan",
|
|
||||||
includePartialMessages: false,
|
|
||||||
settingSources: ["user", "project"],
|
|
||||||
pathToClaudeCodeExecutable: claudeBinary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const models = await query.supportedModels();
|
|
||||||
|
|
||||||
expect(models.length).toBeGreaterThanOrEqual(3);
|
|
||||||
expect(models).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
value: "default",
|
|
||||||
displayName: "Default (recommended)",
|
|
||||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(models).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
value: "haiku",
|
|
||||||
displayName: "Haiku",
|
|
||||||
description: expect.stringContaining("Haiku 4.5"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
models.some(
|
|
||||||
(model) =>
|
|
||||||
model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await query.return?.();
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
test("runs a real Bash tool call and completes it", async () => {
|
|
||||||
const handle = await createSession({
|
|
||||||
cwdPrefix: "claude-agent-basic-tool-",
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await collectUntilTerminal(
|
|
||||||
streamSession(
|
|
||||||
handle.session,
|
|
||||||
[
|
|
||||||
"Use the Bash tool.",
|
|
||||||
"Run exactly: echo TOOL_TEST_OUTPUT",
|
|
||||||
"After the command completes, reply with exactly: TOOL_DONE",
|
|
||||||
].join(" "),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
|
|
||||||
const completedBashCall = getLatestCompletedBashCall(events);
|
|
||||||
|
|
||||||
expect(bashCalls.length).toBeGreaterThan(0);
|
|
||||||
expect(completedBashCall).toBeDefined();
|
|
||||||
expect(completedBashCall?.detail.type).toBe("shell");
|
|
||||||
expect(
|
|
||||||
completedBashCall?.detail.type === "shell" &&
|
|
||||||
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
|
|
||||||
).toBe(true);
|
|
||||||
expect(compactText(getAssistantText(events))).toContain("tool_done");
|
|
||||||
expect(events.at(-1)).toMatchObject({
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "claude",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await cleanupSession(handle);
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
test("interrupts a running Bash turn and continues on the same query", async () => {
|
|
||||||
const handle = await createSession({
|
|
||||||
cwdPrefix: "claude-agent-interrupt-continue-",
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const firstStream = streamSession(
|
|
||||||
handle.session,
|
|
||||||
[
|
|
||||||
"Use the Bash tool.",
|
|
||||||
"Run exactly: sleep 10",
|
|
||||||
"Do not use a background task.",
|
|
||||||
"Do not do anything after starting the command.",
|
|
||||||
].join(" "),
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialEvents = await collectUntil(
|
|
||||||
firstStream,
|
|
||||||
(event) =>
|
|
||||||
event.type === "timeline" &&
|
|
||||||
event.item.type === "tool_call" &&
|
|
||||||
event.item.name.toLowerCase() === "bash",
|
|
||||||
45_000,
|
|
||||||
);
|
|
||||||
const firstQuery = getInternalQuery(handle.session);
|
|
||||||
|
|
||||||
expect(firstQuery).toBeTruthy();
|
|
||||||
|
|
||||||
await handle.session.interrupt();
|
|
||||||
|
|
||||||
const canceledEvents = await collectUntilTerminal(firstStream, {
|
|
||||||
timeoutMs: 20_000,
|
|
||||||
});
|
|
||||||
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
|
|
||||||
|
|
||||||
expect(
|
|
||||||
allFirstTurnEvents.some(
|
|
||||||
(event) => event.type === "turn_canceled" && event.provider === "claude",
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
const followUpEvents = await collectUntilTerminal(
|
|
||||||
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
|
|
||||||
);
|
|
||||||
const secondQuery = getInternalQuery(handle.session);
|
|
||||||
|
|
||||||
expect(secondQuery).toBe(firstQuery);
|
|
||||||
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
|
|
||||||
expect(followUpEvents.at(-1)).toMatchObject({
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "claude",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await cleanupSession(handle);
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
test("creates an autonomous live turn when a background task completes", async () => {
|
|
||||||
const handle = await createSession({
|
|
||||||
cwdPrefix: "claude-agent-autonomous-",
|
|
||||||
});
|
|
||||||
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const foregroundEvents = await collectUntilTerminal(
|
|
||||||
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.",
|
|
||||||
"Reply immediately with exactly: SPAWNED",
|
|
||||||
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
|
|
||||||
].join(" "),
|
|
||||||
),
|
|
||||||
{ timeoutMs: 90_000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
|
|
||||||
|
|
||||||
const liveEvents = await collectSubscribedUntil(
|
|
||||||
handle.session,
|
|
||||||
(event) => isTerminalEvent(event),
|
|
||||||
90_000,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
|
|
||||||
).toBe(true);
|
|
||||||
expect(compactText(getAssistantText(liveEvents))).toContain(
|
|
||||||
autonomousWakeToken.toLowerCase(),
|
|
||||||
);
|
|
||||||
expect(liveEvents.at(-1)).toMatchObject({
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "claude",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await cleanupSession(handle);
|
|
||||||
}
|
|
||||||
}, 180_000);
|
|
||||||
|
|
||||||
test("surfaces permission requests and resumes after approval", async () => {
|
|
||||||
const handle = await createSession({
|
|
||||||
cwdPrefix: "claude-agent-permission-",
|
|
||||||
modeId: "default",
|
|
||||||
});
|
|
||||||
const permissionFile = path.join(handle.cwd, "permission.txt");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await collectUntilTerminal(
|
|
||||||
streamSession(
|
|
||||||
handle.session,
|
|
||||||
[
|
|
||||||
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
|
|
||||||
"If approval is required, wait for approval.",
|
|
||||||
"After the command succeeds, reply with exactly: PERM_DONE",
|
|
||||||
].join(" "),
|
|
||||||
),
|
|
||||||
{
|
|
||||||
timeoutMs: 45_000,
|
|
||||||
onEvent: async (event) => {
|
|
||||||
if (event.type !== "permission_requested") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await handle.session.respondToPermission(event.request.id, {
|
|
||||||
behavior: "allow",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const permissionRequest = events.find(
|
|
||||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
|
||||||
event.type === "permission_requested",
|
|
||||||
);
|
|
||||||
const permissionResolved = events.find(
|
|
||||||
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
|
|
||||||
event.type === "permission_resolved",
|
|
||||||
);
|
|
||||||
const completedBashCall = getLatestCompletedBashCall(events);
|
|
||||||
|
|
||||||
expect(permissionRequest?.request.kind).toBe("tool");
|
|
||||||
expect(permissionResolved).toMatchObject({
|
|
||||||
type: "permission_resolved",
|
|
||||||
provider: "claude",
|
|
||||||
resolution: { behavior: "allow" },
|
|
||||||
});
|
|
||||||
expect(completedBashCall).toBeDefined();
|
|
||||||
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
|
|
||||||
expect(compactText(getAssistantText(events))).toContain("perm_done");
|
|
||||||
expect(events.at(-1)).toMatchObject({
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "claude",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await cleanupSession(handle);
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
});
|
|
||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude-models.js";
|
||||||
|
|
||||||
describe("getClaudeModels", () => {
|
describe("getClaudeModels", () => {
|
||||||
it("returns all claude models", () => {
|
it("returns all claude models", () => {
|
||||||
@@ -10,7 +10,6 @@ describe("getClaudeModels", () => {
|
|||||||
"claude-opus-4-7",
|
"claude-opus-4-7",
|
||||||
"claude-opus-4-6[1m]",
|
"claude-opus-4-6[1m]",
|
||||||
"claude-opus-4-6",
|
"claude-opus-4-6",
|
||||||
"claude-sonnet-4-6[1m]",
|
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
"claude-haiku-4-5",
|
"claude-haiku-4-5",
|
||||||
]);
|
]);
|
||||||
-7
@@ -45,13 +45,6 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
|||||||
isDefault: true,
|
isDefault: true,
|
||||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
id: "claude-sonnet-4-6[1m]",
|
|
||||||
label: "Sonnet 4.6 1M",
|
|
||||||
description: "Sonnet 4.6 with 1M context window",
|
|
||||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
id: "claude-sonnet-4-6",
|
id: "claude-sonnet-4-6",
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
||||||
import { query, type Options, type Query, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createProviderEnv,
|
|
||||||
createProviderEnvSpec,
|
|
||||||
type ProviderRuntimeSettings,
|
|
||||||
} from "../../provider-launch-config.js";
|
|
||||||
import { buildSelfNodeCommand } from "../../../paseo-env.js";
|
|
||||||
import { spawnProcess } from "../../../../utils/spawn.js";
|
|
||||||
|
|
||||||
// Keep the raw SDK query import in this module only. Claude process launch behavior
|
|
||||||
// must stay shared between production and tests so Windows .cmd/.bat handling cannot
|
|
||||||
// diverge from the daemon path.
|
|
||||||
|
|
||||||
export type ClaudeOptions = Options;
|
|
||||||
export type ClaudeQueryInput = Parameters<typeof query>[0] & { options: ClaudeOptions };
|
|
||||||
export type ClaudeQueryFactory = (input: ClaudeQueryInput) => Query;
|
|
||||||
|
|
||||||
export interface ClaudeQueryContext {
|
|
||||||
runtimeSettings?: ProviderRuntimeSettings;
|
|
||||||
launchEnv?: Record<string, string>;
|
|
||||||
queryFactory?: ClaudeQueryFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams {
|
|
||||||
return child.stdin !== null && child.stdout !== null && child.stderr !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveClaudeSpawnCommand(
|
|
||||||
spawnOptions: SpawnOptions,
|
|
||||||
runtimeSettings?: ProviderRuntimeSettings,
|
|
||||||
): { command: string; args: string[] } {
|
|
||||||
const commandConfig = runtimeSettings?.command;
|
|
||||||
if (!commandConfig || commandConfig.mode === "default") {
|
|
||||||
return {
|
|
||||||
command: spawnOptions.command,
|
|
||||||
args: [...spawnOptions.args],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commandConfig.mode === "append") {
|
|
||||||
return {
|
|
||||||
command: spawnOptions.command,
|
|
||||||
args: [...spawnOptions.args, ...(commandConfig.args ?? [])],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: commandConfig.argv[0],
|
|
||||||
args: [...commandConfig.argv.slice(1), ...spawnOptions.args],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyRuntimeSettingsToClaudeOptions(
|
|
||||||
options: ClaudeOptions,
|
|
||||||
runtimeSettings?: ProviderRuntimeSettings,
|
|
||||||
launchEnv?: Record<string, string>,
|
|
||||||
): ClaudeOptions {
|
|
||||||
return {
|
|
||||||
...options,
|
|
||||||
spawnClaudeCodeProcess: (spawnOptions) => {
|
|
||||||
const resolved = resolveClaudeSpawnCommand(spawnOptions, runtimeSettings);
|
|
||||||
// When the SDK passes a default JS runtime ("node"/"bun"), replace it with
|
|
||||||
// process.execPath — the actual node binary running the daemon. This avoids
|
|
||||||
// PATH lookup failures in the managed runtime bundle.
|
|
||||||
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
|
|
||||||
// or the user overrides the command via runtime settings, use that directly.
|
|
||||||
const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun";
|
|
||||||
const providerEnvSpec = createProviderEnvSpec({
|
|
||||||
baseEnv: spawnOptions.env,
|
|
||||||
runtimeSettings,
|
|
||||||
overlays: [launchEnv],
|
|
||||||
});
|
|
||||||
const providerEnv = createProviderEnv({
|
|
||||||
baseEnv: spawnOptions.env,
|
|
||||||
runtimeSettings,
|
|
||||||
overlays: [launchEnv],
|
|
||||||
});
|
|
||||||
const selfNodeCommand = isDefaultRuntime
|
|
||||||
? buildSelfNodeCommand(resolved.args, providerEnv)
|
|
||||||
: null;
|
|
||||||
const command = selfNodeCommand?.command ?? resolved.command;
|
|
||||||
const args = selfNodeCommand?.args ?? resolved.args;
|
|
||||||
const child = spawnProcess(command, args, {
|
|
||||||
cwd: spawnOptions.cwd,
|
|
||||||
...(selfNodeCommand
|
|
||||||
? { env: selfNodeCommand.env, envMode: "internal" as const }
|
|
||||||
: providerEnvSpec),
|
|
||||||
signal: spawnOptions.signal,
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
// Bypass cmd.exe on Windows: the SDK passes --mcp-config with inline JSON
|
|
||||||
// containing double quotes, which cmd.exe mangles (strips quotes, breaks parsing).
|
|
||||||
// The command is always a resolved binary path, so shell routing is unnecessary.
|
|
||||||
shell: false,
|
|
||||||
});
|
|
||||||
if (typeof options.stderr === "function") {
|
|
||||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
||||||
options.stderr?.(chunk.toString());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!isChildProcessWithStreams(child)) {
|
|
||||||
throw new Error("Claude process was spawned without stdio streams");
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function claudeQuery(input: ClaudeQueryInput, context: ClaudeQueryContext = {}): Query {
|
|
||||||
const launchQuery = context.queryFactory ?? query;
|
|
||||||
return launchQuery({
|
|
||||||
...input,
|
|
||||||
options: applyRuntimeSettingsToClaudeOptions(
|
|
||||||
input.options,
|
|
||||||
context.runtimeSettings,
|
|
||||||
context.launchEnv,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1205,7 +1205,7 @@ describe("Codex app-server provider", () => {
|
|||||||
expect(event.item.text).not.toContain("data:image");
|
expect(event.item.text).not.toContain("data:image");
|
||||||
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
|
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
|
||||||
const source = markdownImageSource(event.item.text);
|
const source = markdownImageSource(event.item.text);
|
||||||
expect(source).toMatch(/paseo-attachments[\\/].+\.png$/);
|
expect(source).toMatch(/paseo-attachments\/.+\.png$/);
|
||||||
expect(existsSync(source)).toBe(true);
|
expect(existsSync(source)).toBe(true);
|
||||||
rmSync(source, { force: true });
|
rmSync(source, { force: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import {
|
|||||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||||
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
import { spawnProcess } from "../../../utils/spawn.js";
|
||||||
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||||
import {
|
import {
|
||||||
@@ -1228,20 +1228,6 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;
|
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let authValue = "Not checked";
|
|
||||||
if (resolvedBinary) {
|
|
||||||
try {
|
|
||||||
const { stdout, stderr } = await execCommand(resolvedBinary, ["auth", "list"], {
|
|
||||||
...createProviderEnvSpec(),
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
const text = (stdout.trim() || stderr.trim()).trim();
|
|
||||||
authValue = text ? `\n ${text.replace(/\n/g, "\n ")}` : "(empty)";
|
|
||||||
} catch (error) {
|
|
||||||
authValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (available) {
|
if (available) {
|
||||||
try {
|
try {
|
||||||
const models = await this.listModels({ cwd: homedir(), force: false });
|
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||||
@@ -1277,7 +1263,6 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
|
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
|
||||||
},
|
},
|
||||||
{ label: "Server", value: serverStatus },
|
{ label: "Server", value: serverStatus },
|
||||||
{ label: "Auth", value: authValue },
|
|
||||||
{ label: "Models", value: modelsValue },
|
{ label: "Models", value: modelsValue },
|
||||||
{ label: "Status", value: status },
|
{ label: "Status", value: status },
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
// POSIX-only: POSIX PATH executable probing fixtures
|
|
||||||
/* eslint-disable max-nested-callbacks */
|
|
||||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
|
||||||
import { isPlatform } from "../../../test-utils/platform.js";
|
|
||||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
|
||||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
|
||||||
|
|
||||||
const originalEnv = {
|
|
||||||
PATH: process.env.PATH,
|
|
||||||
PATHEXT: process.env.PATHEXT,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
|
|
||||||
function makeTempDir(prefix: string): string {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
||||||
tempDirs.push(dir);
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isolatePathTo(dir: string): void {
|
|
||||||
process.env.PATH = dir;
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
process.env.PATHEXT = ".CMD";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeProviderShim(dir: string, command: string): string {
|
|
||||||
const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command);
|
|
||||||
const content =
|
|
||||||
process.platform === "win32"
|
|
||||||
? `@echo off\r\necho ${command} 1.0\r\n`
|
|
||||||
: `#!/bin/sh\necho ${command} 1.0\n`;
|
|
||||||
writeFileSync(filePath, content);
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
chmodSync(filePath, 0o755);
|
|
||||||
}
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.PATH = originalEnv.PATH;
|
|
||||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
|
||||||
for (const dir of tempDirs.splice(0)) {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.skipIf(isPlatform("win32"))("provider-availability POSIX-only", () => {
|
|
||||||
test("Codex reports available when the default command resolves from PATH", async () => {
|
|
||||||
const binDir = makeTempDir("provider-availability-codex-");
|
|
||||||
isolatePathTo(binDir);
|
|
||||||
writeProviderShim(binDir, "codex");
|
|
||||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
|
||||||
|
|
||||||
await expect(client.isAvailable()).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("OpenCode reports available when the default command resolves from PATH", async () => {
|
|
||||||
const binDir = makeTempDir("provider-availability-opencode-");
|
|
||||||
isolatePathTo(binDir);
|
|
||||||
writeProviderShim(binDir, "opencode");
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
|
||||||
|
|
||||||
await expect(client.isAvailable()).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
@@ -8,7 +8,7 @@ import type { AgentProvider } from "../agent-sdk-types.js";
|
|||||||
import { AgentManager } from "../agent-manager.js";
|
import { AgentManager } from "../agent-manager.js";
|
||||||
import { AgentStorage } from "../agent-storage.js";
|
import { AgentStorage } from "../agent-storage.js";
|
||||||
|
|
||||||
import { ClaudeAgentClient } from "./claude/agent.js";
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||||
|
|
||||||
@@ -31,6 +31,19 @@ function isolatePathTo(dir: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeProviderShim(dir: string, command: string): string {
|
||||||
|
const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command);
|
||||||
|
const content =
|
||||||
|
process.platform === "win32"
|
||||||
|
? `@echo off\r\necho ${command} 1.0\r\n`
|
||||||
|
: `#!/bin/sh\necho ${command} 1.0\n`;
|
||||||
|
writeFileSync(filePath, content);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
chmodSync(filePath, 0o755);
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.PATH = originalEnv.PATH;
|
process.env.PATH = originalEnv.PATH;
|
||||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||||
@@ -48,12 +61,12 @@ describe("default provider availability", () => {
|
|||||||
await expect(client.isAvailable()).resolves.toBe(false);
|
await expect(client.isAvailable()).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Claude reports unavailable when the default command cannot be resolved", async () => {
|
test("Claude reports available without a PATH binary because the SDK bundles its own cli.js", async () => {
|
||||||
const binDir = makeTempDir("provider-availability-claude-");
|
const binDir = makeTempDir("provider-availability-claude-");
|
||||||
isolatePathTo(binDir);
|
isolatePathTo(binDir);
|
||||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||||
|
|
||||||
await expect(client.isAvailable()).resolves.toBe(false);
|
await expect(client.isAvailable()).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("OpenCode reports unavailable when the default command cannot be resolved", async () => {
|
test("OpenCode reports unavailable when the default command cannot be resolved", async () => {
|
||||||
@@ -64,6 +77,24 @@ describe("default provider availability", () => {
|
|||||||
await expect(client.isAvailable()).resolves.toBe(false);
|
await expect(client.isAvailable()).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Codex reports available when the default command resolves from PATH", async () => {
|
||||||
|
const binDir = makeTempDir("provider-availability-codex-");
|
||||||
|
isolatePathTo(binDir);
|
||||||
|
writeProviderShim(binDir, "codex");
|
||||||
|
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||||
|
|
||||||
|
await expect(client.isAvailable()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenCode reports available when the default command resolves from PATH", async () => {
|
||||||
|
const binDir = makeTempDir("provider-availability-opencode-");
|
||||||
|
isolatePathTo(binDir);
|
||||||
|
writeProviderShim(binDir, "opencode");
|
||||||
|
const client = new OpenCodeAgentClient(createTestLogger());
|
||||||
|
|
||||||
|
await expect(client.isAvailable()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("AgentManager reports Codex unavailable without throwing", async () => {
|
test("AgentManager reports Codex unavailable without throwing", async () => {
|
||||||
const binDir = makeTempDir("provider-availability-manager-bin-");
|
const binDir = makeTempDir("provider-availability-manager-bin-");
|
||||||
isolatePathTo(binDir);
|
isolatePathTo(binDir);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./
|
|||||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
|
||||||
|
|
||||||
describe("paseo daemon bootstrap", () => {
|
describe("paseo daemon bootstrap", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -153,56 +152,52 @@ describe("paseo daemon bootstrap", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// POSIX-only: Unix socket listen paths are invalid Windows listen targets.
|
test("generates a relay pairing offer for unix socket listeners", async () => {
|
||||||
test.skipIf(isPlatform("win32"))(
|
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
|
||||||
"generates a relay pairing offer for unix socket listeners",
|
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||||
async () => {
|
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
|
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
|
||||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
await mkdir(path.dirname(socketPath), { recursive: true });
|
||||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
await mkdir(paseoHome, { recursive: true });
|
||||||
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
|
const logger = pino({ level: "silent" });
|
||||||
await mkdir(path.dirname(socketPath), { recursive: true });
|
|
||||||
await mkdir(paseoHome, { recursive: true });
|
|
||||||
const logger = pino({ level: "silent" });
|
|
||||||
|
|
||||||
const config: PaseoDaemonConfig = {
|
const config: PaseoDaemonConfig = {
|
||||||
listen: socketPath,
|
listen: socketPath,
|
||||||
|
paseoHome,
|
||||||
|
corsAllowedOrigins: [],
|
||||||
|
hostnames: true,
|
||||||
|
mcpEnabled: false,
|
||||||
|
staticDir,
|
||||||
|
mcpDebug: false,
|
||||||
|
agentClients: createTestAgentClients(),
|
||||||
|
agentStoragePath: path.join(paseoHome, "agents"),
|
||||||
|
relayEnabled: true,
|
||||||
|
relayEndpoint: "127.0.0.1:9",
|
||||||
|
relayPublicEndpoint: "127.0.0.1:9",
|
||||||
|
appBaseUrl: "https://app.paseo.sh",
|
||||||
|
openai: undefined,
|
||||||
|
speech: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const daemon = await createPaseoDaemon(config, logger);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await daemon.start();
|
||||||
|
const pairing = await generateLocalPairingOffer({
|
||||||
paseoHome,
|
paseoHome,
|
||||||
corsAllowedOrigins: [],
|
|
||||||
hostnames: true,
|
|
||||||
mcpEnabled: false,
|
|
||||||
staticDir,
|
|
||||||
mcpDebug: false,
|
|
||||||
agentClients: createTestAgentClients(),
|
|
||||||
agentStoragePath: path.join(paseoHome, "agents"),
|
|
||||||
relayEnabled: true,
|
relayEnabled: true,
|
||||||
relayEndpoint: "127.0.0.1:9",
|
relayEndpoint: "127.0.0.1:9",
|
||||||
relayPublicEndpoint: "127.0.0.1:9",
|
relayPublicEndpoint: "127.0.0.1:9",
|
||||||
appBaseUrl: "https://app.paseo.sh",
|
appBaseUrl: "https://app.paseo.sh",
|
||||||
openai: undefined,
|
includeQr: false,
|
||||||
speech: undefined,
|
});
|
||||||
};
|
expect(pairing.relayEnabled).toBe(true);
|
||||||
|
expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
|
||||||
const daemon = await createPaseoDaemon(config, logger);
|
} finally {
|
||||||
|
await daemon.stop().catch(() => undefined);
|
||||||
try {
|
await daemon.agentManager.flush().catch(() => undefined);
|
||||||
await daemon.start();
|
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||||
const pairing = await generateLocalPairingOffer({
|
await rm(staticDir, { recursive: true, force: true });
|
||||||
paseoHome,
|
}
|
||||||
relayEnabled: true,
|
});
|
||||||
relayEndpoint: "127.0.0.1:9",
|
|
||||||
relayPublicEndpoint: "127.0.0.1:9",
|
|
||||||
appBaseUrl: "https://app.paseo.sh",
|
|
||||||
includeQr: false,
|
|
||||||
});
|
|
||||||
expect(pairing.relayEnabled).toBe(true);
|
|
||||||
expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await daemon.stop().catch(() => undefined);
|
|
||||||
await daemon.agentManager.flush().catch(() => undefined);
|
|
||||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
|
||||||
await rm(staticDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,9 +110,10 @@ export function isProviderAvailable(provider: AgentProvider): Promise<boolean> {
|
|||||||
const availability = (async (): Promise<boolean> => {
|
const availability = (async (): Promise<boolean> => {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "claude":
|
case "claude":
|
||||||
const hasClaudeEnvCredentials =
|
return (
|
||||||
Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY);
|
(await isCommandAvailable("claude")) &&
|
||||||
return (await isCommandAvailable("claude")) && (!process.env.CI || hasClaudeEnvCredentials);
|
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
|
||||||
|
);
|
||||||
case "codex":
|
case "codex":
|
||||||
return (
|
return (
|
||||||
(await isCommandAvailable("codex")) &&
|
(await isCommandAvailable("codex")) &&
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/i
|
|||||||
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
||||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||||
import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
|
import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
|
||||||
import { isPlatform } from "../../test-utils/platform.js";
|
|
||||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
import type { SessionOutboundMessage } from "../messages.js";
|
import type { SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
@@ -221,59 +220,54 @@ test("returns error for non-git directory", async () => {
|
|||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 60000); // 1 minute timeout
|
}, 60000); // 1 minute timeout
|
||||||
|
|
||||||
// POSIX-only: asserts repo-root containment across macOS /var symlink normalization.
|
test("returns repo info for git repo with branch and dirty state", async () => {
|
||||||
test.skipIf(isPlatform("win32"))(
|
const cwd = tmpCwd();
|
||||||
"returns repo info for git repo with branch and dirty state",
|
|
||||||
async () => {
|
|
||||||
const cwd = tmpCwd();
|
|
||||||
|
|
||||||
// Initialize git repo
|
// Initialize git repo
|
||||||
const { execSync } = await import("child_process");
|
const { execSync } = await import("child_process");
|
||||||
execSync("git init -b main", { cwd, stdio: "pipe" });
|
execSync("git init -b main", { cwd, stdio: "pipe" });
|
||||||
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||||
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||||
|
|
||||||
// Create and commit a file
|
// Create and commit a file
|
||||||
const testFile = path.join(cwd, "test.txt");
|
const testFile = path.join(cwd, "test.txt");
|
||||||
writeFileSync(testFile, "original content\n");
|
writeFileSync(testFile, "original content\n");
|
||||||
execSync("git add test.txt", { cwd, stdio: "pipe" });
|
execSync("git add test.txt", { cwd, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||||
cwd,
|
cwd,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modify the file (makes repo dirty)
|
// Modify the file (makes repo dirty)
|
||||||
writeFileSync(testFile, "modified content\n");
|
writeFileSync(testFile, "modified content\n");
|
||||||
|
|
||||||
// Create agent in the git repo
|
// Create agent in the git repo
|
||||||
const agent = await ctx.client.createAgent({
|
const agent = await ctx.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: CODEX_TEST_MODEL,
|
model: CODEX_TEST_MODEL,
|
||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
cwd,
|
cwd,
|
||||||
title: "Git Repo Info Test",
|
title: "Git Repo Info Test",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(agent.id).toBeTruthy();
|
expect(agent.id).toBeTruthy();
|
||||||
expect(agent.status).toBe("idle");
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
// Get checkout status
|
// Get checkout status
|
||||||
const result = await ctx.client.getCheckoutStatus(cwd);
|
const result = await ctx.client.getCheckoutStatus(cwd);
|
||||||
|
|
||||||
// Verify repo info returned without error
|
// Verify repo info returned without error
|
||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
expect(result.isGit).toBe(true);
|
expect(result.isGit).toBe(true);
|
||||||
// macOS symlinks /var to /private/var, so we check containment
|
// macOS symlinks /var to /private/var, so we check containment
|
||||||
expect(result.repoRoot).toContain("daemon-e2e-");
|
expect(result.repoRoot).toContain("daemon-e2e-");
|
||||||
expect(result.currentBranch).toBeTruthy();
|
expect(result.currentBranch).toBeTruthy();
|
||||||
expect(result.isDirty).toBe(true);
|
expect(result.isDirty).toBe(true);
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await ctx.client.deleteAgent(agent.id);
|
await ctx.client.deleteAgent(agent.id);
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
},
|
}, 60000); // 1 minute timeout
|
||||||
60000,
|
|
||||||
); // 1 minute timeout
|
|
||||||
|
|
||||||
test("returns clean state when no uncommitted changes", async () => {
|
test("returns clean state when no uncommitted changes", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pino from "pino";
|
|||||||
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||||
import { isProviderAvailable } from "./agent-configs.js";
|
import { isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import pino from "pino";
|
|||||||
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||||
import { isProviderAvailable } from "./agent-configs.js";
|
import { isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import pino from "pino";
|
|||||||
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pino from "pino";
|
|||||||
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||||
import { createMessageCollector } from "../test-utils/message-collector.js";
|
import { createMessageCollector } from "../test-utils/message-collector.js";
|
||||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import pino from "pino";
|
|||||||
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||||
import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js";
|
import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js";
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import type { AgentClient } from "../agent/agent-sdk-types.js";
|
import type { AgentClient } from "../agent/agent-sdk-types.js";
|
||||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||||
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
||||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
// POSIX-only: symlink fixtures
|
|
||||||
/* eslint-disable max-nested-callbacks */
|
|
||||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { listDirectoryEntries, readExplorerFile } from "./service.js";
|
|
||||||
import { isPlatform } from "../../test-utils/platform.js";
|
|
||||||
|
|
||||||
async function createTempDir(prefix: string): Promise<string> {
|
|
||||||
return mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe.skipIf(isPlatform("win32"))("service POSIX-only", () => {
|
|
||||||
it("lists directory entries even when a dangling symlink exists", async () => {
|
|
||||||
const root = await createTempDir("paseo-file-explorer-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mkdir(path.join(root, "packages", "server"), { recursive: true });
|
|
||||||
const serverDir = path.join(root, "packages", "server");
|
|
||||||
await writeFile(path.join(serverDir, "README.md"), "# server\n", "utf-8");
|
|
||||||
await symlink("CLAUDE.md", path.join(serverDir, "AGENTS.md"));
|
|
||||||
|
|
||||||
const result = await listDirectoryEntries({
|
|
||||||
root,
|
|
||||||
relativePath: "packages/server",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.path).toBe("packages/server");
|
|
||||||
const names = result.entries.map((entry) => entry.name);
|
|
||||||
expect(names).toContain("README.md");
|
|
||||||
expect(names).not.toContain("AGENTS.md");
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects symlinked files that resolve outside the workspace", async () => {
|
|
||||||
const root = await createTempDir("paseo-file-explorer-");
|
|
||||||
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const externalFile = path.join(outsideRoot, "secret.txt");
|
|
||||||
await writeFile(externalFile, "top secret\n", "utf-8");
|
|
||||||
await symlink(externalFile, path.join(root, "secret-link.txt"));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
readExplorerFile({
|
|
||||||
root,
|
|
||||||
relativePath: "secret-link.txt",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Access outside of workspace is not allowed");
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
await rm(outsideRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { readExplorerFile } from "./service.js";
|
import { listDirectoryEntries, readExplorerFile } from "./service.js";
|
||||||
|
|
||||||
async function createHomeTempDir(prefix: string): Promise<string> {
|
async function createHomeTempDir(prefix: string): Promise<string> {
|
||||||
return mkdtemp(path.join(os.homedir(), prefix));
|
return mkdtemp(path.join(os.homedir(), prefix));
|
||||||
@@ -13,6 +13,29 @@ async function createTempDir(prefix: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("file explorer service", () => {
|
describe("file explorer service", () => {
|
||||||
|
it("lists directory entries even when a dangling symlink exists", async () => {
|
||||||
|
const root = await createTempDir("paseo-file-explorer-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(root, "packages", "server"), { recursive: true });
|
||||||
|
const serverDir = path.join(root, "packages", "server");
|
||||||
|
await writeFile(path.join(serverDir, "README.md"), "# server\n", "utf-8");
|
||||||
|
await symlink("CLAUDE.md", path.join(serverDir, "AGENTS.md"));
|
||||||
|
|
||||||
|
const result = await listDirectoryEntries({
|
||||||
|
root,
|
||||||
|
relativePath: "packages/server",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.path).toBe("packages/server");
|
||||||
|
const names = result.entries.map((entry) => entry.name);
|
||||||
|
expect(names).toContain("README.md");
|
||||||
|
expect(names).not.toContain("AGENTS.md");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("reads .ex files as text", async () => {
|
it("reads .ex files as text", async () => {
|
||||||
const root = await createTempDir("paseo-file-explorer-");
|
const root = await createTempDir("paseo-file-explorer-");
|
||||||
|
|
||||||
@@ -112,4 +135,25 @@ describe("file explorer service", () => {
|
|||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects symlinked files that resolve outside the workspace", async () => {
|
||||||
|
const root = await createTempDir("paseo-file-explorer-");
|
||||||
|
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const externalFile = path.join(outsideRoot, "secret.txt");
|
||||||
|
await writeFile(externalFile, "top secret\n", "utf-8");
|
||||||
|
await symlink(externalFile, path.join(root, "secret-link.txt"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
readExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath: "secret-link.txt",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Access outside of workspace is not allowed");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
await rm(outsideRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe("resolveLogConfig", () => {
|
|||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
level: "debug",
|
level: "debug",
|
||||||
path: path.resolve(paseoHome, "logs", "programmatic.log"),
|
path: path.join(paseoHome, "logs", "programmatic.log"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readFileSync,
|
|
||||||
realpathSync,
|
|
||||||
rmSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
@@ -32,7 +24,6 @@ import type {
|
|||||||
import { AgentStorage } from "./agent/agent-storage.js";
|
import { AgentStorage } from "./agent/agent-storage.js";
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { LoopService } from "./loop-service.js";
|
import { LoopService } from "./loop-service.js";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
|
||||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||||
|
|
||||||
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
@@ -229,71 +220,60 @@ describe("LoopService", () => {
|
|||||||
let storage: AgentStorage;
|
let storage: AgentStorage;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = realpathSync.native(mkdtempSync(path.join(os.tmpdir(), "loop-service-")));
|
tmpDir = mkdtempSync(path.join(os.tmpdir(), "loop-service-"));
|
||||||
paseoHome = path.join(tmpDir, "paseo-home");
|
paseoHome = path.join(tmpDir, "paseo-home");
|
||||||
workspaceDir = path.join(tmpDir, "workspace");
|
workspaceDir = path.join(tmpDir, "workspace");
|
||||||
storage = new AgentStorage(path.join(tmpDir, "agents"), logger);
|
storage = new AgentStorage(path.join(tmpDir, "agents"), logger);
|
||||||
mkdirSync(workspaceDir, { recursive: true });
|
mkdirSync(workspaceDir, { recursive: true });
|
||||||
workspaceDir = realpathSync.native(workspaceDir);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POSIX-only: real worker agent spawns a PTY whose Windows ConPTY path resolution still fails (error 267) after realpathSync; revisit when we have a Windows dev box.
|
test("runs fresh worker agents until verify-check passes", async () => {
|
||||||
test.skipIf(isPlatform("win32"))(
|
const state = { workerRuns: 0 };
|
||||||
"runs fresh worker agents until verify-check passes",
|
const manager = new AgentManager({
|
||||||
async () => {
|
clients: {
|
||||||
const state = { workerRuns: 0 };
|
claude: new ScriptedAgentClient("claude", {
|
||||||
const verifyScriptPath = path.join(workspaceDir, "verify-check.cjs");
|
async onRun({ config }) {
|
||||||
writeFileSync(verifyScriptPath, 'require("fs").accessSync("done.txt");\n');
|
state.workerRuns += 1;
|
||||||
const manager = new AgentManager({
|
if (config.title?.includes("worker") && state.workerRuns >= 2) {
|
||||||
clients: {
|
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
|
||||||
claude: new ScriptedAgentClient("claude", {
|
}
|
||||||
async onRun({ config }) {
|
if (config.title?.includes("worker")) {
|
||||||
state.workerRuns += 1;
|
return `worker run ${state.workerRuns}`;
|
||||||
if (config.title?.includes("worker") && state.workerRuns >= 2) {
|
}
|
||||||
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
|
return '{"passed":true,"reason":"not used"}';
|
||||||
}
|
},
|
||||||
if (config.title?.includes("worker")) {
|
}),
|
||||||
return `worker run ${state.workerRuns}`;
|
},
|
||||||
}
|
registry: storage,
|
||||||
return '{"passed":true,"reason":"not used"}';
|
logger,
|
||||||
},
|
});
|
||||||
}),
|
const service = new LoopService({ paseoHome, agentManager: manager, logger });
|
||||||
},
|
await service.initialize();
|
||||||
registry: storage,
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
const service = new LoopService({ paseoHome, agentManager: manager, logger });
|
|
||||||
await service.initialize();
|
|
||||||
|
|
||||||
const loop = await service.runLoop({
|
const loop = await service.runLoop({
|
||||||
prompt: "Create done.txt when the task is actually fixed.",
|
prompt: "Create done.txt when the task is actually fixed.",
|
||||||
cwd: workspaceDir,
|
cwd: workspaceDir,
|
||||||
verifyChecks: [
|
verifyChecks: ["test -f done.txt"],
|
||||||
`${JSON.stringify(process.execPath)} ${JSON.stringify(path.basename(verifyScriptPath))}`,
|
sleepMs: 1,
|
||||||
],
|
maxIterations: 3,
|
||||||
sleepMs: 1,
|
});
|
||||||
maxIterations: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitForLoopCompletion(service, loop.id);
|
await waitForLoopCompletion(service, loop.id);
|
||||||
|
|
||||||
const finalLoop = await service.inspectLoop(loop.id);
|
const finalLoop = await service.inspectLoop(loop.id);
|
||||||
expect(finalLoop.status).toBe("succeeded");
|
expect(finalLoop.status).toBe("succeeded");
|
||||||
expect(finalLoop.iterations).toHaveLength(2);
|
expect(finalLoop.iterations).toHaveLength(2);
|
||||||
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(
|
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(finalLoop.iterations[1]?.workerAgentId);
|
||||||
finalLoop.iterations[1]?.workerAgentId,
|
expect(finalLoop.iterations[0]?.status).toBe("failed");
|
||||||
);
|
expect(finalLoop.iterations[1]?.status).toBe("succeeded");
|
||||||
expect(finalLoop.iterations[0]?.status).toBe("failed");
|
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
|
||||||
expect(finalLoop.iterations[1]?.status).toBe("succeeded");
|
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
|
||||||
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
|
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
|
||||||
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
|
});
|
||||||
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("uses worker and verifier provider-model settings when provided", async () => {
|
test("uses worker and verifier provider-model settings when provided", async () => {
|
||||||
const workerConfigs: AgentSessionConfig[] = [];
|
const workerConfigs: AgentSessionConfig[] = [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
type CreatePaseoWorktreeDeps,
|
type CreatePaseoWorktreeDeps,
|
||||||
} from "./paseo-worktree-service.js";
|
} from "./paseo-worktree-service.js";
|
||||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
|
||||||
|
|
||||||
const cleanupPaths: string[] = [];
|
const cleanupPaths: string[] = [];
|
||||||
|
|
||||||
@@ -66,45 +65,41 @@ test("creates a worktree and registers it in the source workspace project withou
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
test("reuses an existing worktree and still upserts the workspace", async () => {
|
||||||
test.skipIf(isPlatform("win32"))(
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
"reuses an existing worktree and still upserts the workspace",
|
cleanupPaths.push(tempDir);
|
||||||
async () => {
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const firstDeps = createDeps();
|
||||||
cleanupPaths.push(tempDir);
|
const first = await createPaseoWorktree(
|
||||||
const paseoHome = path.join(tempDir, ".paseo");
|
{
|
||||||
const firstDeps = createDeps();
|
cwd: repoDir,
|
||||||
const first = await createPaseoWorktree(
|
worktreeSlug: "reuse-me",
|
||||||
{
|
runSetup: false,
|
||||||
cwd: repoDir,
|
paseoHome,
|
||||||
worktreeSlug: "reuse-me",
|
},
|
||||||
runSetup: false,
|
firstDeps,
|
||||||
paseoHome,
|
);
|
||||||
},
|
const events: string[] = [];
|
||||||
firstDeps,
|
const deps = createDeps({
|
||||||
);
|
events,
|
||||||
const events: string[] = [];
|
projects: firstDeps.projects,
|
||||||
const deps = createDeps({
|
workspaces: firstDeps.workspaces,
|
||||||
events,
|
});
|
||||||
projects: firstDeps.projects,
|
|
||||||
workspaces: firstDeps.workspaces,
|
|
||||||
});
|
|
||||||
|
|
||||||
const second = await createPaseoWorktree(
|
const second = await createPaseoWorktree(
|
||||||
{
|
{
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
worktreeSlug: "reuse-me",
|
worktreeSlug: "reuse-me",
|
||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(second.created).toBe(false);
|
expect(second.created).toBe(false);
|
||||||
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
||||||
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
@@ -136,7 +131,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
|||||||
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
||||||
firstAgentContext.prompt ? "renamed-from-agent-context" : null,
|
firstAgentContext.prompt ? "renamed-from-agent-context" : null,
|
||||||
});
|
});
|
||||||
const branchAfterFirst = execFileSync("git", ["branch", "--show-current"], {
|
const branchAfterFirst = execSync("git branch --show-current", {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -162,7 +157,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
|||||||
firstAgentContext: { prompt: "Try another name" },
|
firstAgentContext: { prompt: "Try another name" },
|
||||||
generateBranchNameFromContext: async () => "second-agent-name",
|
generateBranchNameFromContext: async () => "second-agent-name",
|
||||||
});
|
});
|
||||||
const branchAfterSecond = execFileSync("git", ["branch", "--show-current"], {
|
const branchAfterSecond = execSync("git branch --show-current", {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -201,7 +196,7 @@ test("renames the branch even when the app supplies a random placeholder slug",
|
|||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const branchAfter = execFileSync("git", ["branch", "--show-current"], {
|
const branchAfter = execSync("git branch --show-current", {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -258,7 +253,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
|
|||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const branchAfter = execFileSync("git", ["branch", "--show-current"], {
|
const branchAfter = execSync("git branch --show-current", {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -291,7 +286,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
|
|||||||
).resolves.toEqual({ attempted: true, renamed: false, branchName: null });
|
).resolves.toEqual({ attempted: true, renamed: false, branchName: null });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
execFileSync("git", ["branch", "--show-current"], {
|
execSync("git branch --show-current", {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -310,11 +305,11 @@ test("leaves the branch alone when generated branch text is invalid", async () =
|
|||||||
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
|
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
execFileSync("git", ["checkout", "-b", "dev"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
|
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["commit", "-m", "dev"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
const created = await createPaseoWorktree(
|
const created = await createPaseoWorktree(
|
||||||
{
|
{
|
||||||
@@ -339,7 +334,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||||
expect(
|
expect(
|
||||||
execFileSync("git", ["branch", "--show-current"], {
|
execSync("git branch --show-current", {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -375,7 +370,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||||
expect(
|
expect(
|
||||||
execFileSync("git", ["branch", "--show-current"], {
|
execSync("git branch --show-current", {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -529,24 +524,17 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||||
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe" })
|
const repoRoot = execSync("git rev-parse --show-toplevel", { cwd, stdio: "pipe" })
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
const mainRepoRoot = execFileSync(
|
const mainRepoRoot = execSync("git rev-parse --path-format=absolute --git-common-dir", {
|
||||||
"git",
|
|
||||||
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
||||||
{
|
|
||||||
cwd,
|
|
||||||
stdio: "pipe",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.toString()
|
|
||||||
.trim()
|
|
||||||
.replace(/\/\.git$/, "");
|
|
||||||
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
|
||||||
cwd,
|
cwd,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.replace(/\/\.git$/, "");
|
||||||
|
const currentBranch = execSync("git branch --show-current", { cwd, stdio: "pipe" })
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
@@ -578,39 +566,34 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
|||||||
function createGitRepo(): { tempDir: string; repoDir: string } {
|
function createGitRepo(): { tempDir: string; repoDir: string } {
|
||||||
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
|
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||||
return { tempDir, repoDir };
|
return { tempDir, repoDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
|
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
|
||||||
const { tempDir, repoDir } = createGitRepo();
|
const { tempDir, repoDir } = createGitRepo();
|
||||||
execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
|
writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["commit", "-m", "pr-branch"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" });
|
||||||
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
|
const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim();
|
||||||
.toString()
|
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||||
.trim();
|
execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
|
||||||
execFileSync("git", ["branch", "-D", "pr-123"], { cwd: repoDir, stdio: "pipe" });
|
|
||||||
|
|
||||||
const remoteDir = path.join(tempDir, "remote.git");
|
const remoteDir = path.join(tempDir, "remote.git");
|
||||||
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], {
|
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", prHead], {
|
execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${prHead}`, {
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
|
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
|
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
|
||||||
return { tempDir, repoDir };
|
return { tempDir, repoDir };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -22,25 +22,16 @@ function createWorkspaceRepo(options?: {
|
|||||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-")));
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-")));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
mkdirSync(repoDir, { recursive: true });
|
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||||
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
|
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
||||||
stdio: "pipe",
|
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.email", "test@test.com"], {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||||
if (options?.paseoConfig) {
|
if (options?.paseoConfig) {
|
||||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||||
}
|
}
|
||||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tempDir,
|
tempDir,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
@@ -12,25 +12,16 @@ function createWorkspaceRepo(options?: {
|
|||||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-")));
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-")));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
mkdirSync(repoDir, { recursive: true });
|
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||||
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
|
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
||||||
stdio: "pipe",
|
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.email", "test@test.com"], {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||||
if (options?.paseoConfig) {
|
if (options?.paseoConfig) {
|
||||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||||
}
|
}
|
||||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tempDir,
|
tempDir,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { ScriptRouteStore } from "./script-proxy.js";
|
import { ScriptRouteStore } from "./script-proxy.js";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceScriptPayloads,
|
buildWorkspaceScriptPayloads,
|
||||||
@@ -21,25 +21,16 @@ function createWorkspaceRepo(options?: {
|
|||||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-")));
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-")));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
mkdirSync(repoDir, { recursive: true });
|
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||||
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
|
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
||||||
stdio: "pipe",
|
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.email", "test@test.com"], {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||||
if (options?.paseoConfig) {
|
if (options?.paseoConfig) {
|
||||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||||
}
|
}
|
||||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tempDir,
|
tempDir,
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import {
|
|||||||
asDaemonConfigStore,
|
asDaemonConfigStore,
|
||||||
createProviderSnapshotManagerStub,
|
createProviderSnapshotManagerStub,
|
||||||
} from "./test-utils/session-stubs.js";
|
} from "./test-utils/session-stubs.js";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
|
||||||
|
|
||||||
interface SessionHandlerInternals {
|
interface SessionHandlerInternals {
|
||||||
startVoiceTurnController(): Promise<void>;
|
startVoiceTurnController(): Promise<void>;
|
||||||
@@ -706,46 +705,39 @@ describe("project config RPC authorization", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POSIX-only: creates a directory symlink without Windows privileges.
|
test("read_project_config_request accepts a symlink to an active project root", async () => {
|
||||||
test.skipIf(isPlatform("win32"))(
|
const repoRoot = makeRoot();
|
||||||
"read_project_config_request accepts a symlink to an active project root",
|
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
||||||
async () => {
|
const linkRoot = join(makeRoot(), "link");
|
||||||
const repoRoot = makeRoot();
|
symlinkSync(repoRoot, linkRoot, "dir");
|
||||||
writeFileSync(
|
const messages: unknown[] = [];
|
||||||
join(repoRoot, "paseo.json"),
|
const session = createSessionForTest({
|
||||||
JSON.stringify({ worktree: { setup: "npm ci" } }),
|
messages,
|
||||||
);
|
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
|
||||||
const linkRoot = join(makeRoot(), "link");
|
});
|
||||||
symlinkSync(repoRoot, linkRoot, "dir");
|
|
||||||
const messages: unknown[] = [];
|
|
||||||
const session = createSessionForTest({
|
|
||||||
messages,
|
|
||||||
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
|
|
||||||
});
|
|
||||||
|
|
||||||
await session.handleMessage({
|
await session.handleMessage({
|
||||||
type: "read_project_config_request",
|
type: "read_project_config_request",
|
||||||
requestId: "read-symlink-1",
|
requestId: "read-symlink-1",
|
||||||
repoRoot: linkRoot,
|
repoRoot: linkRoot,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(messages).toEqual([
|
expect(messages).toEqual([
|
||||||
{
|
{
|
||||||
type: "read_project_config_response",
|
type: "read_project_config_response",
|
||||||
payload: {
|
payload: {
|
||||||
requestId: "read-symlink-1",
|
requestId: "read-symlink-1",
|
||||||
repoRoot,
|
repoRoot,
|
||||||
ok: true,
|
ok: true,
|
||||||
config: { worktree: { setup: "npm ci" } },
|
config: { worktree: { setup: "npm ci" } },
|
||||||
revision: expect.objectContaining({
|
revision: expect.objectContaining({
|
||||||
mtimeMs: expect.any(Number),
|
mtimeMs: expect.any(Number),
|
||||||
size: expect.any(Number),
|
size: expect.any(Number),
|
||||||
}),
|
}),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]);
|
},
|
||||||
},
|
]);
|
||||||
);
|
});
|
||||||
|
|
||||||
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
|
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
|
||||||
const archivedRoot = makeRoot();
|
const archivedRoot = makeRoot();
|
||||||
|
|||||||
@@ -3007,11 +3007,18 @@ export class Session {
|
|||||||
if (!resolvedWorkspace) {
|
if (!resolvedWorkspace) {
|
||||||
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
||||||
}
|
}
|
||||||
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, {
|
const snapshot = await this.agentManager.createAgent(
|
||||||
labels,
|
{
|
||||||
workspaceId: resolvedWorkspace.workspaceId,
|
...sessionConfig,
|
||||||
initialPrompt: trimmedPrompt,
|
cwd: resolvedWorkspace.cwd,
|
||||||
});
|
},
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
labels,
|
||||||
|
workspaceId: resolvedWorkspace.workspaceId,
|
||||||
|
initialPrompt: trimmedPrompt,
|
||||||
|
},
|
||||||
|
);
|
||||||
await this.forwardAgentUpdate(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
|
|
||||||
await this.sendInitialCreateAgentPrompt({
|
await this.sendInitialCreateAgentPrompt({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user