mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
49 Commits
fix-window
...
v0.1.70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58fc2bb434 | ||
|
|
6e75c72492 | ||
|
|
3ed5567c8a | ||
|
|
ad08868778 | ||
|
|
5b6e974248 | ||
|
|
04b04fe5ee | ||
|
|
9d5a47b1c3 | ||
|
|
5c90449707 | ||
|
|
e490bf1dab | ||
|
|
b131876e69 | ||
|
|
b7d2e6d3ec | ||
|
|
09ed46b997 | ||
|
|
a4d365c8f2 | ||
|
|
13538dd710 | ||
|
|
e60350be08 | ||
|
|
b0c36f2bab | ||
|
|
d708f099be | ||
|
|
cb2fe91998 | ||
|
|
d7820b7a84 | ||
|
|
fb0fbb79f6 | ||
|
|
c97c000d9c | ||
|
|
c4e4a28bc0 | ||
|
|
39e461b872 | ||
|
|
734e15e5a3 | ||
|
|
90bf6571d6 | ||
|
|
90fe71bd54 | ||
|
|
89a500cd3e | ||
|
|
a1ac402154 | ||
|
|
f7eac82593 | ||
|
|
4a9c2450b7 | ||
|
|
27f33be0e7 | ||
|
|
4c11f7c40b | ||
|
|
50405c3b6a | ||
|
|
8406559150 | ||
|
|
f92a296ce0 | ||
|
|
6786024333 | ||
|
|
56855dd6fd | ||
|
|
00a5b27586 | ||
|
|
73ed98c623 | ||
|
|
927309e867 | ||
|
|
2d2ee02ce3 | ||
|
|
4d4fbf7257 | ||
|
|
67c93dba49 | ||
|
|
2d9c7747fb | ||
|
|
4fa1db8d06 | ||
|
|
92be6c0cba | ||
|
|
4f0b264886 | ||
|
|
bad304c2d3 | ||
|
|
0785ee31f0 |
58
.github/workflows/ci.yml
vendored
58
.github/workflows/ci.yml
vendored
@@ -66,7 +66,12 @@ jobs:
|
||||
run: npm run typecheck
|
||||
|
||||
server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: server-tests (${{ matrix.os }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -83,6 +88,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
@@ -95,50 +103,6 @@ jobs:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
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:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -216,7 +180,7 @@ jobs:
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
@@ -271,7 +235,7 @@ jobs:
|
||||
run: npm install
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
3
.github/workflows/server-ci.yml
vendored
3
.github/workflows/server-ci.yml
vendored
@@ -36,6 +36,9 @@ jobs:
|
||||
- name: Install server dependencies
|
||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -66,6 +66,7 @@ CLAUDE.local.md
|
||||
.paseo/
|
||||
.wrangler/
|
||||
**/.wrangler/
|
||||
**/.tanstack/
|
||||
|
||||
# Local agent/tooling artifacts (do not commit)
|
||||
PLAN.md
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"ignorePatterns": ["*.lock"]
|
||||
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
|
||||
}
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -1,5 +1,63 @@
|
||||
# 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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -14,7 +14,7 @@ There are two supported ways to ship from `main`:
|
||||
Before running any stable patch release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- Make sure local `npm run typecheck` passes on that commit.
|
||||
- **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.
|
||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
```bash
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-mGnJDX1LOORj7fDRPcJYIFG0D+rLDyom6LktWhwZasw=";
|
||||
npmDepsHash = "sha256-Fo95v2pBAW1i0K7WPoEwtKbwjeDZ5ed4vJ5p7I7LIYw=";
|
||||
|
||||
# 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).
|
||||
|
||||
1697
package-lock.json
generated
1697
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -67,6 +67,22 @@ describe("combined model selector helpers", () => {
|
||||
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", () => {
|
||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||
|
||||
@@ -48,7 +48,10 @@ export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): b
|
||||
return true;
|
||||
}
|
||||
|
||||
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
|
||||
return tokens.every((token) => haystack.includes(token));
|
||||
}
|
||||
|
||||
@@ -7,12 +7,34 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import {
|
||||
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 SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
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() {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -25,16 +47,18 @@ export function IntegrationsSection() {
|
||||
} = useCliInstall();
|
||||
const {
|
||||
status: skillsStatus,
|
||||
isInstalling: isInstallingSkills,
|
||||
isWorking: isSkillsWorking,
|
||||
install: installSkills,
|
||||
update: updateSkills,
|
||||
uninstall: uninstallSkills,
|
||||
refresh: refreshSkillsStatus,
|
||||
} = useSkillsInstall();
|
||||
} = useSkillsStatus();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!showSection) return undefined;
|
||||
refreshCliStatus();
|
||||
refreshSkillsStatus();
|
||||
void refreshSkillsStatus();
|
||||
return undefined;
|
||||
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
|
||||
);
|
||||
@@ -45,9 +69,33 @@ export function IntegrationsSection() {
|
||||
}, [installCli, isInstallingCli]);
|
||||
|
||||
const handleInstallSkills = useCallback(() => {
|
||||
if (isInstallingSkills) return;
|
||||
installSkills();
|
||||
}, [installSkills, isInstallingSkills]);
|
||||
if (isSkillsWorking) return;
|
||||
void installSkills();
|
||||
}, [installSkills, isSkillsWorking]);
|
||||
|
||||
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(() => {
|
||||
void openExternalUrl(CLI_DOCS_URL);
|
||||
@@ -96,6 +144,8 @@ export function IntegrationsSection() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const skillsState = skillsStatus?.state ?? null;
|
||||
|
||||
return (
|
||||
<SettingsSection title="Integrations" trailing={trailing}>
|
||||
<View style={settingsStyles.card}>
|
||||
@@ -130,30 +180,69 @@ export function IntegrationsSection() {
|
||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Teach your agents to orchestrate through the CLI
|
||||
{skillsState === "drift"
|
||||
? "Update available"
|
||||
: "Teach your agents to orchestrate through the CLI"}
|
||||
</Text>
|
||||
</View>
|
||||
{skillsStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleInstallSkills}
|
||||
disabled={isInstallingSkills}
|
||||
>
|
||||
{isInstallingSkills ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
<SkillsActions
|
||||
state={skillsState}
|
||||
isWorking={isSkillsWorking}
|
||||
onInstall={handleInstallSkills}
|
||||
onUpdate={handleUpdateSkills}
|
||||
onUninstall={handleUninstallSkills}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</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) => ({
|
||||
headerLinks: {
|
||||
flexDirection: "row",
|
||||
@@ -174,4 +263,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -220,10 +220,67 @@ export async function installCli(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
||||
}
|
||||
|
||||
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
|
||||
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 async function installSkills(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
|
||||
function parseSkillsState(value: unknown): SkillsState {
|
||||
switch (value) {
|
||||
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCliInstall, useSkillsInstall } from "./use-install-status";
|
||||
import { useCliInstall, useSkillsStatus } from "./use-install-status";
|
||||
|
||||
const toast = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
@@ -15,9 +15,11 @@ const toast = vi.hoisted(() => ({
|
||||
|
||||
const desktopDaemon = vi.hoisted(() => ({
|
||||
getCliInstallStatus: vi.fn(),
|
||||
getSkillsInstallStatus: vi.fn(),
|
||||
installCli: vi.fn(),
|
||||
getSkillsStatus: vi.fn(),
|
||||
installSkills: vi.fn(),
|
||||
updateSkills: vi.fn(),
|
||||
uninstallSkills: vi.fn(),
|
||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
@@ -89,11 +91,9 @@ describe("useCliInstall", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSkillsInstall", () => {
|
||||
describe("useSkillsStatus", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
|
||||
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -101,34 +101,155 @@ describe("useSkillsInstall", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads skills install status", async () => {
|
||||
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ installed: true });
|
||||
it("loads the current skills status", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
});
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
expect(result.current.isWorking).toBe(false);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts and exposes skills install errors", async () => {
|
||||
const error = new Error("Missing IPC handler");
|
||||
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
|
||||
desktopDaemon.installSkills.mockRejectedValue(error);
|
||||
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||
it("install transitions a not-installed status to up-to-date and reflects the response directly", 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).toEqual({ installed: false });
|
||||
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(() => {
|
||||
result.current.install();
|
||||
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");
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
desktopDaemon.installSkills.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("not-installed");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.install();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(error);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
|
||||
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
|
||||
});
|
||||
|
||||
@@ -2,11 +2,14 @@ import { useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getCliInstallStatus,
|
||||
getSkillsInstallStatus,
|
||||
getSkillsStatus,
|
||||
installCli,
|
||||
installSkills,
|
||||
shouldUseDesktopDaemon,
|
||||
type InstallStatus,
|
||||
type SkillsStatus,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
useDesktopIpcErrorReporter,
|
||||
@@ -14,11 +17,7 @@ import {
|
||||
} from "@/desktop/hooks/desktop-ipc-error";
|
||||
|
||||
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
|
||||
const SKILLS_INSTALL_STATUS_QUERY_KEY = [
|
||||
"desktop",
|
||||
"integrations",
|
||||
"skills-install-status",
|
||||
] as const;
|
||||
const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const;
|
||||
|
||||
interface DesktopInstallHookResult {
|
||||
status: InstallStatus | null;
|
||||
@@ -77,25 +76,43 @@ export function useCliInstall(): DesktopInstallHookResult {
|
||||
};
|
||||
}
|
||||
|
||||
export function useSkillsInstall(): DesktopInstallHookResult {
|
||||
export interface SkillsStatusHookResult {
|
||||
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 reportError = useDesktopIpcErrorReporter();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
|
||||
const statusQuery = useQuery<InstallStatus, Error>({
|
||||
queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
|
||||
queryFn: getSkillsInstallStatus,
|
||||
const statusQuery = useQuery<SkillsStatus, Error>({
|
||||
queryKey: SKILLS_STATUS_QUERY_KEY,
|
||||
queryFn: getSkillsStatus,
|
||||
enabled,
|
||||
retry: false,
|
||||
});
|
||||
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
|
||||
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: statusQuery.error,
|
||||
message: "Unable to check orchestration skills install status.",
|
||||
message: "Unable to check orchestration skills status.",
|
||||
logLabel: "[Integrations] Failed to load skills status",
|
||||
});
|
||||
|
||||
const installMutation = useMutation<InstallStatus, Error>({
|
||||
const setStatus = useCallback(
|
||||
(next: SkillsStatus) => {
|
||||
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const installMutation = useMutation<SkillsStatus, Error>({
|
||||
mutationFn: installSkills,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
@@ -104,23 +121,65 @@ export function useSkillsInstall(): DesktopInstallHookResult {
|
||||
logLabel: "[Integrations] Failed to install skills",
|
||||
});
|
||||
},
|
||||
onSuccess: (nextStatus) => {
|
||||
queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
|
||||
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
|
||||
},
|
||||
onSuccess: setStatus,
|
||||
});
|
||||
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void refetch();
|
||||
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 uninstallMutation = useMutation<SkillsStatus, Error>({
|
||||
mutationFn: uninstallSkills,
|
||||
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]);
|
||||
|
||||
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 {
|
||||
status: installStatus ?? null,
|
||||
status: status ?? null,
|
||||
isLoading,
|
||||
isInstalling,
|
||||
error: statusError ?? installError ?? null,
|
||||
install,
|
||||
isWorking,
|
||||
error:
|
||||
statusError ??
|
||||
installMutation.error ??
|
||||
updateMutation.error ??
|
||||
uninstallMutation.error ??
|
||||
null,
|
||||
refresh,
|
||||
install,
|
||||
update,
|
||||
uninstall,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,21 +184,6 @@ describe("UpdateCalloutSource", () => {
|
||||
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 () => {
|
||||
updaterState.value = {
|
||||
...updaterState.value,
|
||||
|
||||
@@ -13,25 +13,18 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
function resolveUpdateCalloutTitle(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
}): string {
|
||||
if (args.isInstalled) return "Update installed";
|
||||
function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string {
|
||||
if (args.isInstalling) return "Installing update";
|
||||
if (args.isError) return "Update failed";
|
||||
return "Update available";
|
||||
}
|
||||
|
||||
function resolveUpdateCalloutDescription(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
errorMessage: string | null;
|
||||
latestVersion: string | undefined;
|
||||
}): ReactNode {
|
||||
if (args.isInstalled) return "Restart to use the new version.";
|
||||
if (args.isInstalling) return "Installing and restarting...";
|
||||
if (args.isError) return args.errorMessage ?? "Something went wrong.";
|
||||
if (args.latestVersion) {
|
||||
@@ -43,7 +36,6 @@ function resolveUpdateCalloutDescription(args: {
|
||||
}
|
||||
|
||||
function buildUpdateCalloutActions(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
openChangelog: () => void;
|
||||
@@ -53,7 +45,7 @@ function buildUpdateCalloutActions(args: {
|
||||
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
|
||||
if (args.isError) {
|
||||
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
|
||||
} else if (!args.isInstalled) {
|
||||
} else {
|
||||
actions.push({
|
||||
label: args.isInstalling ? "Installing..." : "Install & restart",
|
||||
onPress: args.install,
|
||||
@@ -107,29 +99,21 @@ export function UpdateCalloutSource() {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
status !== "available" &&
|
||||
status !== "installed" &&
|
||||
status !== "installing" &&
|
||||
status !== "error"
|
||||
) {
|
||||
if (status !== "available" && status !== "installing" && status !== "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInstalled = status === "installed";
|
||||
const isError = status === "error";
|
||||
const isAvailable = !isInstalled && !isInstalling && !isError;
|
||||
const isAvailable = !isInstalling && !isError;
|
||||
|
||||
const title = resolveUpdateCalloutTitle({ isInstalled, isInstalling, isError });
|
||||
const title = resolveUpdateCalloutTitle({ isInstalling, isError });
|
||||
const description = resolveUpdateCalloutDescription({
|
||||
isInstalled,
|
||||
isInstalling,
|
||||
isError,
|
||||
errorMessage,
|
||||
latestVersion: availableUpdate?.latestVersion ?? undefined,
|
||||
});
|
||||
const actions = buildUpdateCalloutActions({
|
||||
isInstalled,
|
||||
isInstalling,
|
||||
isError,
|
||||
openChangelog,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { Fragment, useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -745,16 +745,19 @@ function SettingsSidebar({
|
||||
) : null}
|
||||
<View style={sidebarStyles.list}>
|
||||
{items.map((item) => (
|
||||
<SidebarSectionButton
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
isSelected={selectedSectionId === item.id}
|
||||
onSelect={onSelectSection}
|
||||
/>
|
||||
<Fragment key={item.id}>
|
||||
<SidebarSectionButton
|
||||
itemId={item.id}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
isSelected={selectedSectionId === item.id}
|
||||
onSelect={onSelectSection}
|
||||
/>
|
||||
{item.id === "general" ? (
|
||||
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
||||
</View>
|
||||
<SidebarSeparator />
|
||||
<View style={sidebarStyles.list}>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
|
||||
@@ -27,6 +28,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
serverId: string;
|
||||
@@ -38,6 +40,7 @@ interface OpenTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
requiresLocalDaemon: boolean;
|
||||
onOpen: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -82,14 +85,16 @@ export function WorkspaceOpenInEditorButton({
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||
|
||||
const shouldLoadTargets =
|
||||
const shouldQueryWorkspace =
|
||||
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
|
||||
|
||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||
queryKey: ["available-editors", serverId],
|
||||
enabled: shouldLoadTargets,
|
||||
enabled: shouldLoadEditorTargets,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
@@ -112,7 +117,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
|
||||
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
cwd: shouldLoadTargets ? cwd : "",
|
||||
cwd: shouldQueryWorkspace ? cwd : "",
|
||||
});
|
||||
|
||||
const editorTargets = useMemo<OpenTarget[]>(
|
||||
@@ -121,6 +126,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
id: editor.id,
|
||||
label: editor.label,
|
||||
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
||||
requiresLocalDaemon: true,
|
||||
onOpen: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
@@ -149,13 +155,20 @@ export function WorkspaceOpenInEditorButton({
|
||||
id: "github",
|
||||
label: "GitHub",
|
||||
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
||||
requiresLocalDaemon: false,
|
||||
onOpen: () => openExternalUrl(url),
|
||||
};
|
||||
}, [checkoutStatus]);
|
||||
|
||||
const targets = useMemo(
|
||||
() => (githubTarget ? [...editorTargets, githubTarget] : editorTargets),
|
||||
[editorTargets, githubTarget],
|
||||
() =>
|
||||
filterTargetsForDaemonLocation(
|
||||
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
|
||||
{
|
||||
isLocalDaemon,
|
||||
},
|
||||
),
|
||||
[editorTargets, githubTarget, isLocalDaemon],
|
||||
);
|
||||
|
||||
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
||||
@@ -210,7 +223,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
}
|
||||
}, [primaryOption, handleOpenTarget]);
|
||||
|
||||
if (!shouldLoadTargets || !primaryOption || targets.length === 0) {
|
||||
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
13
packages/app/src/screens/workspace/workspace-open-targets.ts
Normal file
13
packages/app/src/screens/workspace/workspace-open-targets.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.69",
|
||||
"@getpaseo/server": "0.1.70",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -60,6 +60,11 @@ const EXPECTED_CLAUDE_MODELS = [
|
||||
model: "Opus 4.6 1M",
|
||||
descriptionFragment: "1M context window",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6[1m]",
|
||||
model: "Sonnet 4.6 1M",
|
||||
descriptionFragment: "1M context window",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
model: "Sonnet 4.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -53,10 +53,22 @@ function pruneOnnxRuntime(nodeModules, platform, arch) {
|
||||
function pruneClaudeAgentSdk(nodeModules, platform, arch) {
|
||||
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
|
||||
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
|
||||
if (!keepName) return;
|
||||
if (keepName) {
|
||||
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
|
||||
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
|
||||
}
|
||||
|
||||
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
|
||||
// (~210 MB each). Paseo requires user-installed `claude` on PATH, matching how
|
||||
// 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) {
|
||||
|
||||
@@ -17,12 +17,13 @@ import {
|
||||
downloadAndInstallUpdate,
|
||||
type AppReleaseChannel,
|
||||
} from "../features/auto-updater.js";
|
||||
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
|
||||
import {
|
||||
installCli,
|
||||
getCliInstallStatus,
|
||||
getSkillsStatus,
|
||||
installSkills,
|
||||
getSkillsInstallStatus,
|
||||
} from "../integrations/integrations-manager.js";
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
} from "../integrations/skills/index.js";
|
||||
import {
|
||||
openLocalTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
@@ -531,8 +532,10 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
get_local_daemon_version: () => getLocalDaemonVersion(),
|
||||
install_cli: () => installCli(),
|
||||
get_cli_install_status: () => getCliInstallStatus(),
|
||||
get_skills_status: () => getSkillsStatus(),
|
||||
install_skills: () => installSkills(),
|
||||
get_skills_install_status: () => getSkillsInstallStatus(),
|
||||
update_skills: () => updateSkills(),
|
||||
uninstall_skills: () => uninstallSkills(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -196,16 +196,9 @@ function buildCheckResult(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
|
||||
// Use a short delay to allow the renderer to receive the response.
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (onBeforeQuit) await onBeforeQuit();
|
||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
} catch (error) {
|
||||
console.error("[auto-updater] quitAndInstall failed:", error);
|
||||
}
|
||||
}, 1500);
|
||||
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> {
|
||||
if (onBeforeQuit) await onBeforeQuit();
|
||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -314,7 +307,7 @@ export async function downloadAndInstallUpdate(
|
||||
|
||||
const readyVersion = cachedUpdateInfo.version;
|
||||
if (isReadyToInstallVersion(readyVersion)) {
|
||||
scheduleQuitAndInstall(onBeforeQuit);
|
||||
await performQuitAndInstall(onBeforeQuit);
|
||||
return {
|
||||
installed: true,
|
||||
version: readyVersion,
|
||||
@@ -336,7 +329,7 @@ export async function downloadAndInstallUpdate(
|
||||
await autoUpdater.downloadUpdate();
|
||||
downloadedUpdateVersion = readyVersion;
|
||||
downloading = false;
|
||||
scheduleQuitAndInstall(onBeforeQuit);
|
||||
await performQuitAndInstall(onBeforeQuit);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
|
||||
1
packages/desktop/src/integrations/cli-install/index.ts
Normal file
1
packages/desktop/src/integrations/cli-install/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getCliInstallStatus, installCli } from "./install.js";
|
||||
71
packages/desktop/src/integrations/cli-install/install.ts
Normal file
71
packages/desktop/src/integrations/cli-install/install.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCliInstallSourcePath } from "./cli-install-path";
|
||||
import { resolveCliInstallSourcePath } from "./path";
|
||||
|
||||
describe("cli-install-path", () => {
|
||||
it("uses the bundled shim for packaged macOS installs", () => {
|
||||
31
packages/desktop/src/integrations/cli-install/paths.ts
Normal file
31
packages/desktop/src/integrations/cli-install/paths.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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);
|
||||
}
|
||||
97
packages/desktop/src/integrations/cli-install/shell-rc.ts
Normal file
97
packages/desktop/src/integrations/cli-install/shell-rc.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
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) };
|
||||
}
|
||||
10
packages/desktop/src/integrations/skills/index.ts
Normal file
10
packages/desktop/src/integrations/skills/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
getSkillsStatus,
|
||||
installSkills,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
type SkillOp,
|
||||
type SkillsState,
|
||||
type SkillsStatus,
|
||||
type SkillTargets,
|
||||
} from "./operations.js";
|
||||
302
packages/desktop/src/integrations/skills/operations.test.ts
Normal file
302
packages/desktop/src/integrations/skills/operations.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
164
packages/desktop/src/integrations/skills/operations.ts
Normal file
164
packages/desktop/src/integrations/skills/operations.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
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);
|
||||
}
|
||||
22
packages/desktop/src/integrations/skills/paths.ts
Normal file
22
packages/desktop/src/integrations/skills/paths.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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");
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { syncSkills } from "./skill-sync";
|
||||
import { removeSkill, syncSkills } from "./sync";
|
||||
|
||||
interface Sandbox {
|
||||
root: string;
|
||||
@@ -70,6 +70,11 @@ describe("syncSkills", () => {
|
||||
"utf-8",
|
||||
);
|
||||
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(
|
||||
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
|
||||
"utf-8",
|
||||
@@ -107,10 +112,10 @@ describe("syncSkills", () => {
|
||||
),
|
||||
).toBe("roles content");
|
||||
|
||||
const claudeLink = path.join(sandbox.claudeDir, "paseo-epic");
|
||||
const lstat = await fs.lstat(claudeLink);
|
||||
expect(lstat.isSymbolicLink()).toBe(true);
|
||||
expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe(
|
||||
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic");
|
||||
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true);
|
||||
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content");
|
||||
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe(
|
||||
"roles content",
|
||||
);
|
||||
});
|
||||
@@ -254,3 +259,46 @@ describe("syncSkills", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ export interface SkillSyncOptions {
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
skillNames: readonly string[];
|
||||
platform?: NodeJS.Platform;
|
||||
onSkillError?: (skillName: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -25,7 +24,7 @@ async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boo
|
||||
return true;
|
||||
}
|
||||
|
||||
async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
export async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
@@ -53,38 +52,24 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<numbe
|
||||
return changed;
|
||||
}
|
||||
|
||||
async function ensureClaudeSkillLink(
|
||||
skillName: string,
|
||||
agentsDir: 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 interface RemoveSkillTargets {
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
}
|
||||
|
||||
// Always rebuild the link rather than diffing it. fs.rm with force: true is
|
||||
// a no-op when nothing is there, and matches existing install behavior.
|
||||
// On Windows, `fs.rm` does not follow junctions, so the agents-side content
|
||||
// is preserved.
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
|
||||
if (platform === "win32") {
|
||||
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);
|
||||
}
|
||||
export async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise<void> {
|
||||
const paths = [
|
||||
path.join(targets.agentsDir, skillName),
|
||||
path.join(targets.claudeDir, skillName),
|
||||
path.join(targets.codexDir, skillName),
|
||||
];
|
||||
for (const p of paths) {
|
||||
await fs.rm(p, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await fs.symlink(target, linkPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
let changedFiles = 0;
|
||||
let processedSkills = 0;
|
||||
|
||||
@@ -100,11 +85,9 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
|
||||
path.join(options.agentsDir, skillName),
|
||||
);
|
||||
|
||||
changedFiles += await ensureClaudeSkillLink(
|
||||
skillName,
|
||||
options.agentsDir,
|
||||
options.claudeDir,
|
||||
platform,
|
||||
changedFiles += await syncDirectoryFiles(
|
||||
bundleSkillDir,
|
||||
path.join(options.claudeDir, skillName),
|
||||
);
|
||||
|
||||
changedFiles += await syncDirectoryFiles(
|
||||
@@ -114,7 +97,8 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
|
||||
|
||||
processedSkills++;
|
||||
} catch (error) {
|
||||
options.onSkillError?.(skillName, error);
|
||||
if (!options.onSkillError) throw error;
|
||||
options.onSkillError(skillName, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
createBeforeQuitHandler,
|
||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||
} from "./daemon/quit-lifecycle.js";
|
||||
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
|
||||
import { runDesktopStartup } from "./desktop-startup.js";
|
||||
|
||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||
@@ -661,10 +660,6 @@ async function bootstrap(): Promise<void> {
|
||||
registerNotificationHandlers();
|
||||
registerOpenerHandlers();
|
||||
|
||||
void autoUpdateSkillsIfInstalled().catch((error) => {
|
||||
log.warn("[integrations] auto-update skills failed", error);
|
||||
});
|
||||
|
||||
await createMainWindow();
|
||||
|
||||
app.on("activate", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.70",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -57,9 +57,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.69",
|
||||
"@getpaseo/relay": "0.1.69",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/highlight": "0.1.70",
|
||||
"@getpaseo/relay": "0.1.70",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
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 supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
||||
@@ -116,17 +117,21 @@ describe("supervisor durable logging", () => {
|
||||
expect(result.log).toContain("raw stderr line\n");
|
||||
});
|
||||
|
||||
test("logs worker signal exits even when the worker cannot log", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
workerSource: `
|
||||
// POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"logs worker signal exits even when the worker cannot log",
|
||||
async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
workerSource: `
|
||||
process.kill(process.pid, "SIGKILL");
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"msg":"Worker exited"');
|
||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
||||
expect(result.log).toContain("Supervisor exiting");
|
||||
});
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"msg":"Worker exited"');
|
||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
||||
expect(result.log).toContain("Supervisor exiting");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,15 +13,13 @@ import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import { createAllClients, shutdownProviders } from "./provider-registry.js";
|
||||
import { isProviderAvailable } from "../daemon-e2e/agent-configs.js";
|
||||
import pino from "pino";
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.4-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
|
||||
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 {
|
||||
url: string;
|
||||
@@ -147,13 +145,20 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
||||
};
|
||||
}
|
||||
|
||||
(shouldRun ? describe : describe.skip)("getStructuredAgentResponse (e2e)", () => {
|
||||
describe("getStructuredAgentResponse (e2e)", () => {
|
||||
let manager: AgentManager;
|
||||
let cwd: string;
|
||||
let agentMcpServer: AgentMcpServerHandle;
|
||||
let canRunCodex = false;
|
||||
let canRunClaude = false;
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
beforeAll(async () => {
|
||||
canRunCodex = !process.env.CI && hasOpenAICredentials;
|
||||
canRunClaude = await isProviderAvailable("claude");
|
||||
if (!canRunCodex && !canRunClaude) {
|
||||
return;
|
||||
}
|
||||
agentMcpServer = await startAgentMcpServer(logger);
|
||||
});
|
||||
|
||||
@@ -174,72 +179,70 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
||||
await shutdownProviders(logger);
|
||||
}, 60000);
|
||||
|
||||
test.runIf(hasOpenAICredentials)(
|
||||
"returns schema-valid JSON from a real Codex agent",
|
||||
async () => {
|
||||
const schema = z.object({
|
||||
title: z.string(),
|
||||
count: z.number(),
|
||||
});
|
||||
test("returns schema-valid JSON from a real Codex agent", async (context) => {
|
||||
if (!canRunCodex) {
|
||||
context.skip();
|
||||
}
|
||||
const schema = z.object({
|
||||
title: z.string(),
|
||||
count: z.number(),
|
||||
});
|
||||
|
||||
const result = await generateStructuredAgentResponse({
|
||||
manager,
|
||||
agentConfig: {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Structured Response Test",
|
||||
},
|
||||
prompt: "Return JSON with a short title and count 2.",
|
||||
schema,
|
||||
maxRetries: 1,
|
||||
});
|
||||
const result = await generateStructuredAgentResponse({
|
||||
manager,
|
||||
agentConfig: {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Structured Response Test",
|
||||
},
|
||||
prompt: "Return JSON with a short title and count 2.",
|
||||
schema,
|
||||
maxRetries: 1,
|
||||
});
|
||||
|
||||
expect(result.title.length).toBeGreaterThan(0);
|
||||
expect(typeof result.count).toBe("number");
|
||||
},
|
||||
180000,
|
||||
);
|
||||
expect(result.title.length).toBeGreaterThan(0);
|
||||
expect(typeof result.count).toBe("number");
|
||||
}, 180000);
|
||||
|
||||
test.runIf(hasClaudeCredentials)(
|
||||
"returns schema-valid JSON from Claude Haiku",
|
||||
async () => {
|
||||
const schema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
test("returns schema-valid JSON from Claude Haiku", async (context) => {
|
||||
if (!canRunClaude) {
|
||||
context.skip();
|
||||
}
|
||||
const schema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
let result: { message: string } | null = null;
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
result = await generateStructuredAgentResponse({
|
||||
manager,
|
||||
agentConfig: {
|
||||
provider: "claude",
|
||||
model: "haiku",
|
||||
thinkingOptionId: "on",
|
||||
cwd,
|
||||
title: "Claude Haiku Structured Test",
|
||||
internal: true,
|
||||
},
|
||||
prompt:
|
||||
'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}',
|
||||
schema,
|
||||
maxRetries: 6,
|
||||
});
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (!result) {
|
||||
throw lastError;
|
||||
let result: { message: string } | null = null;
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
result = await generateStructuredAgentResponse({
|
||||
manager,
|
||||
agentConfig: {
|
||||
provider: "claude",
|
||||
model: "haiku",
|
||||
thinkingOptionId: "on",
|
||||
cwd,
|
||||
title: "Claude Haiku Structured Test",
|
||||
internal: true,
|
||||
},
|
||||
prompt:
|
||||
'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}',
|
||||
schema,
|
||||
maxRetries: 6,
|
||||
});
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (!result) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
expect(result.message.trim().toLowerCase()).toBe("hello");
|
||||
},
|
||||
180000,
|
||||
);
|
||||
expect(result.message.trim().toLowerCase()).toBe("hello");
|
||||
}, 180000);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve as resolvePath } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -24,6 +25,9 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
|
||||
const REPO_CWD = resolvePath("/tmp/repo");
|
||||
const TARGET_CWD = resolvePath("/tmp/target");
|
||||
|
||||
interface LooseSafeParseResult {
|
||||
success: boolean;
|
||||
data: unknown;
|
||||
@@ -42,7 +46,7 @@ interface LooseStructuredContent {
|
||||
|
||||
interface RegisteredMcpTool {
|
||||
inputSchema: LooseInputSchema;
|
||||
callback: (input: unknown) => Promise<{
|
||||
handler: (input: unknown) => Promise<{
|
||||
structuredContent: LooseStructuredContent;
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
}>;
|
||||
@@ -383,7 +387,7 @@ describe("terminal MCP tools", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "capture_terminal");
|
||||
|
||||
const response = await tool.callback({
|
||||
const response = await tool.handler({
|
||||
terminalId: "term-1",
|
||||
scrollback: true,
|
||||
stripAnsi: false,
|
||||
@@ -507,7 +511,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(providerWithEmptyProvider.success).toBe(false);
|
||||
|
||||
await expect(
|
||||
tool.callback({
|
||||
tool.handler({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
title: "Short title",
|
||||
@@ -570,7 +574,7 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
await expect(
|
||||
tool.callback({
|
||||
tool.handler({
|
||||
cwd: "/path/that/does/not/exist",
|
||||
title: "Short title",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -583,7 +587,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-123",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -592,7 +596,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
cwd: existingCwd,
|
||||
title: " Fix auth bug ",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -613,7 +617,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-456",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -622,7 +626,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
cwd: existingCwd,
|
||||
title: " Fix auth ",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -642,7 +646,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-789",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -651,7 +655,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
cwd: existingCwd,
|
||||
title: "Config test",
|
||||
mode: "auto",
|
||||
@@ -685,14 +689,20 @@ describe("create_agent MCP tool", () => {
|
||||
const startedAgentSetupIds: string[] = [];
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-with-worktree",
|
||||
@@ -717,7 +727,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
cwd: repoDir,
|
||||
title: "Worktree agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -757,14 +767,20 @@ describe("create_agent MCP tool", () => {
|
||||
};
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-auto-named-worktree",
|
||||
@@ -787,7 +803,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
cwd: repoDir,
|
||||
title: "Worktree agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -798,7 +814,10 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
|
||||
const initialBranch = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: agentCwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(initialBranch).not.toBe("");
|
||||
@@ -824,19 +843,28 @@ describe("create_agent MCP tool", () => {
|
||||
};
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "-b", "existing-feature"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await writeFile(join(repoDir, "feature.txt"), "feature\n");
|
||||
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-checkout-worktree",
|
||||
@@ -859,7 +887,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
cwd: repoDir,
|
||||
title: "Checkout agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -871,7 +899,9 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
expect(
|
||||
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
|
||||
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("existing-feature");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
@@ -901,7 +931,7 @@ describe("create_agent MCP tool", () => {
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: "/tmp/worktrees/pr-123",
|
||||
projectId: "/tmp/repo",
|
||||
projectId: REPO_CWD,
|
||||
cwd: "/tmp/worktrees/pr-123",
|
||||
kind: "worktree" as const,
|
||||
displayName: "pr-123",
|
||||
@@ -909,7 +939,7 @@ describe("create_agent MCP tool", () => {
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
repoRoot: "/tmp/repo",
|
||||
repoRoot: REPO_CWD,
|
||||
created: true,
|
||||
...(options?.setupContinuation?.kind === "agent"
|
||||
? {
|
||||
@@ -948,8 +978,8 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
cwd: "/tmp/repo",
|
||||
await tool.handler({
|
||||
cwd: REPO_CWD,
|
||||
title: "PR agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Rename this PR branch from prompt",
|
||||
@@ -985,14 +1015,20 @@ describe("create_agent MCP tool", () => {
|
||||
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
};
|
||||
@@ -1013,7 +1049,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
const response = await tool.callback({
|
||||
const response = await tool.handler({
|
||||
cwd: repoDir,
|
||||
target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" },
|
||||
});
|
||||
@@ -1031,19 +1067,27 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
|
||||
const tempDir = realpathSync.native(
|
||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
|
||||
);
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
@@ -1072,13 +1116,13 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
const createTool = registeredTool(server, "create_worktree");
|
||||
const archiveTool = registeredTool(server, "archive_worktree");
|
||||
const created = await createTool.callback({
|
||||
const created = await createTool.handler({
|
||||
cwd: repoDir,
|
||||
target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" },
|
||||
});
|
||||
workspaceGitService.getSnapshot.mockClear();
|
||||
|
||||
await archiveTool.callback({
|
||||
await archiveTool.handler({
|
||||
cwd: repoDir,
|
||||
worktreePath: created.structuredContent.worktreePath,
|
||||
});
|
||||
@@ -1126,9 +1170,9 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "list_worktrees");
|
||||
|
||||
const response = await tool.callback({ cwd: "/tmp/repo" });
|
||||
const response = await tool.handler({ cwd: REPO_CWD });
|
||||
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", {
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
|
||||
reason: "mcp:list-worktrees",
|
||||
});
|
||||
expect(response.structuredContent.worktrees).toEqual([
|
||||
@@ -1188,7 +1232,7 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
cwd: "subdir",
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -1214,7 +1258,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-injected-123",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -1227,7 +1271,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
cwd: existingCwd,
|
||||
title: "Injected config test",
|
||||
mode: "auto",
|
||||
@@ -1251,7 +1295,7 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
await expect(
|
||||
tool.callback({
|
||||
tool.handler({
|
||||
cwd: existingCwd,
|
||||
title: "Bad mode",
|
||||
provider: "opencode/gpt-5.4",
|
||||
@@ -1288,7 +1332,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
title: "Child",
|
||||
provider: "claude/claude-sonnet-4-20250514",
|
||||
initialPrompt: "Do work",
|
||||
@@ -1319,7 +1363,7 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
await expect(
|
||||
tool.callback({
|
||||
tool.handler({
|
||||
title: "Child",
|
||||
provider: "opencode/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
@@ -1354,7 +1398,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
title: "Child",
|
||||
provider: "opencode/gpt-5.4",
|
||||
mode: "build",
|
||||
@@ -1384,7 +1428,7 @@ describe("create_schedule MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await expect(
|
||||
tool.callback({
|
||||
tool.handler({
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Default schedule",
|
||||
@@ -1404,12 +1448,12 @@ describe("create_schedule MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
provider: "codex",
|
||||
});
|
||||
await tool.callback({
|
||||
await tool.handler({
|
||||
prompt: "say hello again",
|
||||
every: "10m",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -1470,7 +1514,7 @@ describe("provider listing MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
const response = await tool.callback({});
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
providers: [
|
||||
@@ -1518,7 +1562,7 @@ describe("provider listing MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
const response = await tool.callback({});
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
providers: [
|
||||
@@ -1555,7 +1599,7 @@ describe("provider listing MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
|
||||
await tool.callback({});
|
||||
await tool.handler({});
|
||||
|
||||
expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1);
|
||||
expect(isAvailable).toHaveBeenCalledTimes(1);
|
||||
@@ -1591,7 +1635,7 @@ describe("model listing MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "list_models");
|
||||
|
||||
await expect(tool.callback({ provider: "codex" })).rejects.toThrow(
|
||||
await expect(tool.handler({ provider: "codex" })).rejects.toThrow(
|
||||
"Provider 'codex' is disabled",
|
||||
);
|
||||
expect(fetchModels).not.toHaveBeenCalled();
|
||||
@@ -1615,7 +1659,7 @@ describe("speak MCP tool", () => {
|
||||
const tool = registeredTool(server, "speak");
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
await tool.callback({ text: "Hello from voice agent." });
|
||||
await tool.handler({ text: "Hello from voice agent." });
|
||||
expect(speak).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "Hello from voice agent.",
|
||||
@@ -1635,7 +1679,7 @@ describe("speak MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "speak");
|
||||
await expect(tool.callback({ text: "Hello." })).rejects.toThrow(
|
||||
await expect(tool.handler({ text: "Hello." })).rejects.toThrow(
|
||||
"No speak handler registered for caller agent",
|
||||
);
|
||||
});
|
||||
@@ -1662,7 +1706,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
createManagedAgent({
|
||||
id: "agent-compact",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
config: { model: "gpt-5.4", thinkingOptionId: "high" },
|
||||
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
|
||||
labels: { role: "researcher" },
|
||||
@@ -1671,7 +1715,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({});
|
||||
const response = await tool.handler({});
|
||||
const structured = z
|
||||
.object({ agents: z.array(z.record(z.unknown())) })
|
||||
.parse(response.structuredContent);
|
||||
@@ -1687,7 +1731,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
thinkingOptionId: "high",
|
||||
effectiveThinkingOptionId: "high",
|
||||
status: "idle",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
lastUserMessageAt: null,
|
||||
@@ -1725,7 +1769,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
const response = await tool.callback({ agentId: "archived-agent" });
|
||||
const response = await tool.handler({ agentId: "archived-agent" });
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
status: "closed",
|
||||
@@ -1781,7 +1825,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
const response = await tool.callback({ agentId: "full-detail-agent" });
|
||||
const response = await tool.handler({ agentId: "full-detail-agent" });
|
||||
const snapshot = z.record(z.unknown()).parse(response.structuredContent.snapshot);
|
||||
|
||||
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
|
||||
@@ -1857,7 +1901,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
|
||||
await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow(
|
||||
await expect(tool.handler({ agentId: "internal-agent" })).rejects.toThrow(
|
||||
"Agent internal-agent not found",
|
||||
);
|
||||
});
|
||||
@@ -1901,7 +1945,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
callerAgentId: "caller-agent",
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({});
|
||||
const response = await tool.handler({});
|
||||
|
||||
const agentIds = agentsOf(response).map((agent) => agent.id);
|
||||
expect(agentIds).toHaveLength(3);
|
||||
@@ -1916,28 +1960,32 @@ describe("agent snapshot MCP serialization", () => {
|
||||
spies.agentManager.listAgents.mockReturnValue([
|
||||
createManagedAgent({
|
||||
id: "running-target",
|
||||
cwd: "/tmp/target",
|
||||
cwd: TARGET_CWD,
|
||||
lifecycle: "running",
|
||||
updatedAt: new Date(recent),
|
||||
}),
|
||||
createManagedAgent({
|
||||
id: "idle-target",
|
||||
cwd: "/tmp/target",
|
||||
cwd: TARGET_CWD,
|
||||
lifecycle: "idle",
|
||||
updatedAt: new Date(recent),
|
||||
}),
|
||||
createManagedAgent({
|
||||
id: "old-running-target",
|
||||
cwd: "/tmp/target",
|
||||
cwd: TARGET_CWD,
|
||||
lifecycle: "running",
|
||||
createdAt: new Date(old),
|
||||
updatedAt: new Date(old),
|
||||
}),
|
||||
]);
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({ id: "recent-archived", cwd: "/tmp/target", archivedAt: recent }),
|
||||
createStoredRecord({ id: "old-archived", cwd: "/tmp/target", archivedAt: old }),
|
||||
createStoredRecord({ id: "recent-other-cwd", cwd: "/tmp/other", archivedAt: recent }),
|
||||
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
|
||||
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
|
||||
createStoredRecord({
|
||||
id: "recent-other-cwd",
|
||||
cwd: resolvePath("/tmp/other"),
|
||||
archivedAt: recent,
|
||||
}),
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -1949,8 +1997,8 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({
|
||||
cwd: "/tmp/target",
|
||||
const response = await tool.handler({
|
||||
cwd: TARGET_CWD,
|
||||
includeArchived: true,
|
||||
sinceHours: 48,
|
||||
statuses: ["running", "closed"],
|
||||
@@ -1990,7 +2038,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({ includeArchived: true });
|
||||
const response = await tool.handler({ includeArchived: true });
|
||||
const agentIds = agentsOf(response).map((agent) => agent.id);
|
||||
|
||||
expect(agentIds).toHaveLength(50);
|
||||
@@ -2009,7 +2057,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({
|
||||
id: "stored-archived-compact",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
updatedAt: now,
|
||||
lastActivityAt: now,
|
||||
archivedAt: now,
|
||||
@@ -2033,7 +2081,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true });
|
||||
const response = await tool.handler({ cwd: REPO_CWD, includeArchived: true });
|
||||
const item = agentsOf(response)[0];
|
||||
|
||||
expect(item).toEqual({
|
||||
@@ -2045,7 +2093,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
status: "closed",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
createdAt: "2026-04-11T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
lastUserMessageAt: null,
|
||||
@@ -2106,7 +2154,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({});
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(agentsOf(response).map((agent) => agent.id)).toEqual([
|
||||
"idle-attention-oldest",
|
||||
@@ -2141,7 +2189,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({ includeArchived: true });
|
||||
const response = await tool.handler({ includeArchived: true });
|
||||
|
||||
const parsed = z.array(AgentListItemPayloadSchema).safeParse(response.structuredContent.agents);
|
||||
if (!parsed.success) {
|
||||
@@ -2181,7 +2229,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.callback({ agentId: "archived-activity-agent" });
|
||||
const response = await tool.handler({ agentId: "archived-activity-agent" });
|
||||
|
||||
expect(response.structuredContent).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -2219,7 +2267,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.callback({ agentId: "live-activity-agent", limit: 1 });
|
||||
const response = await tool.handler({ agentId: "live-activity-agent", limit: 1 });
|
||||
|
||||
const content = String(response.structuredContent.content);
|
||||
expect(content).toContain("Hello world. How are you?");
|
||||
@@ -2250,7 +2298,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.callback({ agentId: "live-activity-agent-2", limit: 2 });
|
||||
const response = await tool.handler({ agentId: "live-activity-agent-2", limit: 2 });
|
||||
|
||||
const content = String(response.structuredContent.content);
|
||||
expect(content).toContain("[User] u3");
|
||||
|
||||
@@ -40,7 +40,7 @@ vi.mock("../../utils/executable.js", () => ({
|
||||
isCommandAvailable: mockState.isCommandAvailable,
|
||||
}));
|
||||
|
||||
vi.mock("./providers/claude-agent.js", () => ({
|
||||
vi.mock("./providers/claude/agent.js", () => ({
|
||||
ClaudeAgentClient: class ClaudeAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
ProviderProfileModel,
|
||||
ProviderRuntimeSettings,
|
||||
} 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 { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { type ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
PermissionOption,
|
||||
PromptResponse,
|
||||
@@ -23,6 +25,7 @@ import { transformPiModels } from "./pi-direct-agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
import * as spawnUtils from "../../../utils/spawn.js";
|
||||
|
||||
interface ACPSessionInternals {
|
||||
sessionId: string | null;
|
||||
@@ -114,6 +117,14 @@ 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(
|
||||
category: "mode" | "model" | "thought_level",
|
||||
values: string[],
|
||||
@@ -303,6 +314,78 @@ 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", () => {
|
||||
test("maps ACP usage fields into Paseo usage", () => {
|
||||
expect(
|
||||
|
||||
@@ -92,7 +92,7 @@ import {
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
|
||||
|
||||
function assertChildWithPipes(
|
||||
child: ChildProcess,
|
||||
@@ -106,6 +106,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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 = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
@@ -1464,7 +1480,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const env = Object.fromEntries(
|
||||
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
|
||||
);
|
||||
const child = spawnProcess(params.command, params.args ?? [], {
|
||||
const terminalCommand = resolveTerminalCommand(params.command, params.args);
|
||||
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
|
||||
cwd: params.cwd ?? this.config.cwd,
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
@@ -1479,6 +1496,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
resolveExit = resolve;
|
||||
rejectExit = reject;
|
||||
});
|
||||
waitForExit.catch(() => undefined);
|
||||
|
||||
const entry: TerminalEntry = {
|
||||
id: terminalId,
|
||||
@@ -1498,9 +1516,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
child.stderr!.on("data", (chunk: Buffer | string) =>
|
||||
appendTerminalOutput(entry, chunk.toString()),
|
||||
);
|
||||
child.once("error", (error) =>
|
||||
rejectExit(error instanceof Error ? error : new Error(String(error))),
|
||||
);
|
||||
child.once("error", (error) => {
|
||||
const spawnError = error instanceof Error ? error : new Error(String(error));
|
||||
appendTerminalOutput(entry, `${spawnError.message}\n`);
|
||||
rejectExit(spawnError);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
const exit = { exitCode: code, signal };
|
||||
entry.exit = exit;
|
||||
|
||||
@@ -1,476 +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 { 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,
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
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.
|
||||
describe("claude agent commands E2E", () => {
|
||||
@@ -1,15 +1,15 @@
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import pino from "pino";
|
||||
|
||||
import { isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
|
||||
describe("claude agent commands contract (real)", () => {
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await isCommandAvailable("claude");
|
||||
canRun = await isProviderAvailable("claude");
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
@@ -19,8 +19,6 @@ describe("claude agent commands contract (real)", () => {
|
||||
});
|
||||
|
||||
test("lists slash commands with the expected contract", async () => {
|
||||
expect(await isCommandAvailable("claude")).toBe(true);
|
||||
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { query, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentLaunchContext } from "../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentLaunchContext } from "../../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import type { ClaudeQueryInput } from "./query.js";
|
||||
|
||||
function createQueryMock(events: unknown[]): Query {
|
||||
let index = 0;
|
||||
@@ -36,7 +37,7 @@ describe("Claude SDK env", () => {
|
||||
PASEO_TEST_FLAG: "launch-value",
|
||||
},
|
||||
};
|
||||
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
||||
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
|
||||
capturedEnv = options.env;
|
||||
return createQueryMock([
|
||||
{
|
||||
@@ -66,6 +67,7 @@ describe("Claude SDK env", () => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession(
|
||||
{
|
||||
@@ -93,7 +95,7 @@ describe("Claude SDK env", () => {
|
||||
PASEO_TEST_FLAG: "resume-launch-value",
|
||||
},
|
||||
};
|
||||
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
||||
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
|
||||
capturedEnv = options.env;
|
||||
return createQueryMock([
|
||||
{
|
||||
@@ -123,6 +125,7 @@ describe("Claude SDK env", () => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.resumeSession(
|
||||
{
|
||||
@@ -6,9 +6,7 @@
|
||||
*
|
||||
* All tests use REAL Claude SDK sessions — no mocks.
|
||||
*
|
||||
* 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.
|
||||
* These tests run when the shared Claude provider availability gate passes.
|
||||
*/
|
||||
import { beforeAll, beforeEach, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
@@ -17,8 +15,8 @@ import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { isCommandAvailable } from "../../../../utils/executable.js";
|
||||
import { ClaudeAgentClient } from "../claude-agent.js";
|
||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -26,8 +24,6 @@ import { ClaudeAgentClient } from "../claude-agent.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));
|
||||
@@ -74,7 +70,14 @@ async function createSession(params?: {
|
||||
|
||||
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
|
||||
await handle.session.close().catch(() => undefined);
|
||||
rmSync(handle.cwd, { recursive: true, force: true });
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startTurnAndCollectEvents(
|
||||
@@ -196,7 +199,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
||||
canRun = await isProviderAvailable("claude");
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { extractUserMessageText } from "./claude-agent.js";
|
||||
import { extractUserMessageText } from "./agent.js";
|
||||
|
||||
describe("extractUserMessageText", () => {
|
||||
test("returns trimmed string content", () => {
|
||||
@@ -0,0 +1,471 @@
|
||||
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,9 +1,9 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
|
||||
interface QueryMock {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
@@ -41,13 +41,7 @@ type PromptHandler = (input: {
|
||||
query: ScriptedQuery;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: sdkMocks.query,
|
||||
}));
|
||||
const queryFactory = vi.fn();
|
||||
|
||||
function createAsyncQueue<T>(): AsyncQueue<T> {
|
||||
const items: T[] = [];
|
||||
@@ -245,14 +239,14 @@ async function waitFor(
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
sdkMocks.query.mockReset();
|
||||
queryFactory.mockReset();
|
||||
});
|
||||
|
||||
test("interrupt only calls query.interrupt and leaves the query open", async () => {
|
||||
const logger = createTestLogger();
|
||||
const queries: ScriptedQuery[] = [];
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const scriptedQuery = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "interrupt-keep-query-session",
|
||||
@@ -261,7 +255,11 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
|
||||
return scriptedQuery;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
@@ -274,7 +272,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
|
||||
await session.interrupt();
|
||||
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
|
||||
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(queries[0]?.return).not.toHaveBeenCalled();
|
||||
|
||||
const firstTurnEvents = await collectUntilTerminal(firstTurn);
|
||||
@@ -291,7 +289,7 @@ test("reuses the existing query after interrupt before starting the next prompt"
|
||||
const logger = createTestLogger();
|
||||
const queries: ScriptedQuery[] = [];
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const scriptedQuery = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "interrupt-reuse-query-session",
|
||||
@@ -311,7 +309,11 @@ test("reuses the existing query after interrupt before starting the next prompt"
|
||||
return scriptedQuery;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
@@ -326,7 +328,7 @@ test("reuses the existing query after interrupt before starting the next prompt"
|
||||
|
||||
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
||||
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||
"first prompt",
|
||||
"second prompt",
|
||||
@@ -342,7 +344,7 @@ test("emits an assistant system notice when Claude changes session id mid-turn",
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryRef = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "claude-original-session",
|
||||
@@ -361,7 +363,11 @@ test("emits an assistant system notice when Claude changes session id mid-turn",
|
||||
return queryRef;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
@@ -390,7 +396,7 @@ test("recovers when the query pump sees a single interrupt abort before the next
|
||||
const prompts: PromptRecord[] = [];
|
||||
let throwAbortOnNext = false;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const scriptedQuery = {
|
||||
next: vi.fn(async () => {
|
||||
if (throwAbortOnNext) {
|
||||
@@ -455,7 +461,11 @@ test("recovers when the query pump sees a single interrupt abort before the next
|
||||
return scriptedQuery;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
@@ -468,7 +478,7 @@ test("recovers when the query pump sees a single interrupt abort before the next
|
||||
|
||||
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
||||
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
|
||||
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
@@ -480,7 +490,7 @@ test("stale abort result after replacement start does not poison the new foregro
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryRef = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "interrupt-stale-result-session",
|
||||
@@ -488,7 +498,11 @@ test("stale abort result after replacement start does not poison the new foregro
|
||||
return queryRef;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
@@ -545,7 +559,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryRef = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "autonomous-live-session",
|
||||
@@ -564,7 +578,11 @@ test("creates an autonomous live turn when assistant output arrives without a fo
|
||||
return queryRef;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
@@ -606,7 +624,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryRef = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "autonomous-handoff-session",
|
||||
@@ -634,7 +652,11 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
|
||||
return queryRef;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
@@ -677,7 +699,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
|
||||
(event) => event?.type === "turn_canceled",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||
"seed prompt",
|
||||
"foreground prompt",
|
||||
@@ -1,13 +1,10 @@
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js";
|
||||
import { isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
import type { AgentStreamEvent, AgentSession } from "../../agent-sdk-types.js";
|
||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
|
||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
return (
|
||||
@@ -32,7 +29,7 @@ describe("Claude max effort availability (real)", () => {
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
||||
canRun = await isProviderAvailable("claude");
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
@@ -1,11 +1,11 @@
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { asInternals } from "../../../test-utils/class-mocks.js";
|
||||
import { ClaudeAgentClient, readEventIdentifiers } from "./agent.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
|
||||
interface QueryMock {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
@@ -66,6 +66,7 @@ async function createSession() {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: sdkQueryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
return client.createSession({
|
||||
provider: "claude",
|
||||
@@ -77,6 +78,7 @@ function createSessionWithLogger(logger: Logger) {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: sdkQueryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
return client.createSession({
|
||||
provider: "claude",
|
||||
@@ -223,6 +225,7 @@ test("logs redacted query summary and never leaks sentinel secrets", async () =>
|
||||
PASEO_RUNTIME_SENTINEL_SECRET: runtimeSecret,
|
||||
},
|
||||
},
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
@@ -657,7 +660,7 @@ test("Grep tool_result string content flows to a search detail with content", as
|
||||
grepEntry,
|
||||
);
|
||||
|
||||
const { mapClaudeCompletedToolCall } = await import("./claude/tool-call-mapper.js");
|
||||
const { mapClaudeCompletedToolCall } = await import("./tool-call-mapper.js");
|
||||
const item = mapClaudeCompletedToolCall({
|
||||
callId: "tool-grep-1",
|
||||
name: "Grep",
|
||||
@@ -797,6 +800,7 @@ test("captures Claude stderr in the turn failure diagnostic when stderr arrives
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: loggerSpy.logger,
|
||||
queryFactory: sdkQueryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
@@ -1,16 +1,16 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import {
|
||||
query,
|
||||
type Options,
|
||||
type Query,
|
||||
type SpawnOptions as ClaudeSpawnOptions,
|
||||
import type {
|
||||
Options,
|
||||
Query,
|
||||
SpawnOptions as ClaudeSpawnOptions,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import * as spawnUtils from "../../../utils/spawn.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import * as spawnUtils from "../../../../utils/spawn.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import type { ClaudeQueryInput } from "./query.js";
|
||||
|
||||
function createQueryMock(events: unknown[]): Query {
|
||||
let index = 0;
|
||||
@@ -47,7 +47,7 @@ describe("Claude spawn override", () => {
|
||||
|
||||
test("bypasses the shell when spawning Claude Code", async () => {
|
||||
let capturedOptions: Options | undefined;
|
||||
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
||||
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
|
||||
capturedOptions = options;
|
||||
return createQueryMock([
|
||||
{
|
||||
@@ -77,6 +77,7 @@ describe("Claude spawn override", () => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
@@ -1,19 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "../agent-manager.js";
|
||||
import { projectTimelineRows } from "../timeline-projection.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "../../agent-manager.js";
|
||||
import { projectTimelineRows } from "../../timeline-projection.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: sdkMocks.query,
|
||||
}));
|
||||
const queryFactory = vi.fn();
|
||||
|
||||
interface QueryMock {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
@@ -150,7 +144,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
const largeOldText = "VERY_LARGE_OLD_STRING".repeat(50);
|
||||
sdkMocks.query.mockImplementation(() =>
|
||||
queryFactory.mockImplementation(() =>
|
||||
buildQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
@@ -247,11 +241,15 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sdkMocks.query.mockReset();
|
||||
queryFactory.mockReset();
|
||||
});
|
||||
|
||||
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
|
||||
const session = await new ClaudeAgentClient({ logger }).createSession({
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
@@ -298,9 +296,13 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
});
|
||||
|
||||
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
|
||||
sdkMocks.query.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
||||
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
||||
|
||||
const session = await new ClaudeAgentClient({ logger }).createSession({
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
@@ -1,19 +1,24 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
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 {
|
||||
ClaudeAgentClient,
|
||||
convertClaudeHistoryEntry,
|
||||
normalizeClaudeAskUserQuestionUpdatedInput,
|
||||
} from "./claude-agent.js";
|
||||
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
} from "./agent.js";
|
||||
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
|
||||
interface TestClaudeSession {
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
convertUsage(message: SDKMessage): AgentUsage | undefined;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("convertClaudeHistoryEntry", () => {
|
||||
test("maps user tool results to timeline items", () => {
|
||||
const toolUseId = "toolu_test";
|
||||
@@ -346,7 +351,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("returns hardcoded claude models", async () => {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
|
||||
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
@@ -354,6 +359,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6[1m]",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]);
|
||||
@@ -368,6 +374,59 @@ 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", () => {
|
||||
test("maps frontend header-keyed answers to Claude question text keys", () => {
|
||||
expect(
|
||||
@@ -429,7 +488,10 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
||||
});
|
||||
|
||||
test("respondToPermission preserves full question input when UI returns answers-only payload", async () => {
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
@@ -504,7 +566,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
async function createSessionForTest(): Promise<TestClaudeSession> {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
@@ -604,6 +666,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
const nonPersistedClient = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: nonPersistedQueryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const nonPersistedSession = await nonPersistedClient.createSession(
|
||||
{
|
||||
@@ -622,6 +685,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
const persistedClient = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: persistedQueryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const persistedSession = await persistedClient.createSession(
|
||||
{
|
||||
@@ -979,7 +1043,11 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
],
|
||||
]);
|
||||
const client = new ClaudeAgentClient({ logger, queryFactory });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
@@ -1,20 +1,16 @@
|
||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { promises } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
query,
|
||||
type AgentDefinition,
|
||||
type CanUseTool,
|
||||
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
||||
type Options,
|
||||
type PermissionMode,
|
||||
type PermissionResult,
|
||||
type PermissionUpdate,
|
||||
type Query,
|
||||
type SpawnOptions,
|
||||
type SDKMessage,
|
||||
type SDKPartialAssistantMessage,
|
||||
type SDKTaskProgressMessage,
|
||||
@@ -28,22 +24,23 @@ import {
|
||||
mapClaudeCompletedToolCall,
|
||||
mapClaudeFailedToolCall,
|
||||
mapClaudeRunningToolCall,
|
||||
} from "./claude/tool-call-mapper.js";
|
||||
} from "./tool-call-mapper.js";
|
||||
import {
|
||||
mapTaskNotificationSystemRecordToToolCall,
|
||||
mapTaskNotificationUserContentToToolCall,
|
||||
} from "./claude/task-notification-tool-call.js";
|
||||
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude/claude-models.js";
|
||||
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
||||
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
||||
} from "./task-notification-tool-call.js";
|
||||
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||
import { parsePartialJsonObject } from "./partial-json.js";
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
} from "../diagnostic-utils.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
|
||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
||||
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
@@ -73,20 +70,19 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
} from "../../agent-sdk-types.js";
|
||||
import {
|
||||
createProviderEnv,
|
||||
createProviderEnvSpec,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { buildSelfNodeCommand } from "../../paseo-env.js";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||
} from "../../provider-launch-config.js";
|
||||
import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js";
|
||||
import { withTimeout } from "../../../../utils/promise-timeout.js";
|
||||
import { execCommand } from "../../../../utils/spawn.js";
|
||||
import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js";
|
||||
|
||||
const fsPromises = promises;
|
||||
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
|
||||
const CLAUDE_SETTING_SOURCES: NonNullable<ClaudeOptions["settingSources"]> = ["user", "project"];
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
@@ -155,10 +151,6 @@ function isUnknownArray(value: unknown): value is readonly unknown[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams {
|
||||
return child.stdin !== null && child.stdout !== null && child.stderr !== null;
|
||||
}
|
||||
|
||||
function isImageMimeType(
|
||||
value: string,
|
||||
): value is "image/jpeg" | "image/png" | "image/gif" | "image/webp" {
|
||||
@@ -239,7 +231,6 @@ interface SlashCommandInvocation {
|
||||
rawInput: string;
|
||||
}
|
||||
|
||||
// Orchestrator instructions moved to shared module.
|
||||
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
||||
|
||||
export interface ClaudeContentChunk {
|
||||
@@ -247,13 +238,12 @@ export interface ClaudeContentChunk {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ClaudeOptions = Options;
|
||||
|
||||
interface ClaudeAgentClientOptions {
|
||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
queryFactory?: typeof query;
|
||||
queryFactory?: ClaudeQueryFactory;
|
||||
resolveBinary?: () => Promise<string>;
|
||||
}
|
||||
|
||||
interface ClaudeAgentSessionOptions {
|
||||
@@ -263,7 +253,8 @@ interface ClaudeAgentSessionOptions {
|
||||
launchEnv?: Record<string, string>;
|
||||
persistSession?: boolean;
|
||||
logger: Logger;
|
||||
queryFactory?: typeof query;
|
||||
queryFactory?: ClaudeQueryFactory;
|
||||
resolveBinary: () => Promise<string>;
|
||||
}
|
||||
|
||||
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
@@ -303,86 +294,6 @@ function extractSessionIdRaw(msg: {
|
||||
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 {
|
||||
return (
|
||||
value === "low" ||
|
||||
@@ -1250,13 +1161,15 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly queryFactory: typeof query;
|
||||
private readonly queryFactory?: ClaudeQueryFactory;
|
||||
private readonly resolveBinary: () => Promise<string>;
|
||||
|
||||
constructor(options: ClaudeAgentClientOptions) {
|
||||
this.defaults = options.defaults;
|
||||
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.queryFactory = options.queryFactory ?? query;
|
||||
this.queryFactory = options.queryFactory;
|
||||
this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings));
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -1272,6 +1185,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
persistSession: options?.persistSession,
|
||||
logger: this.logger,
|
||||
queryFactory: this.queryFactory,
|
||||
resolveBinary: this.resolveBinary,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1298,6 +1212,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
launchEnv: launchContext?.env,
|
||||
logger: this.logger,
|
||||
queryFactory: this.queryFactory,
|
||||
resolveBinary: this.resolveBinary,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1329,9 +1244,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
if (command?.mode === "replace") {
|
||||
return await isCommandAvailable(command.argv[0]);
|
||||
}
|
||||
// Default mode uses @anthropic-ai/claude-agent-sdk's bundled cli.js run
|
||||
// via process.execPath. No external `claude` binary is required.
|
||||
return true;
|
||||
return await isCommandAvailable("claude");
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
@@ -1383,6 +1296,24 @@ 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(
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
): Promise<string | null> {
|
||||
@@ -1550,7 +1481,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly persistSession?: boolean;
|
||||
private readonly logger: Logger;
|
||||
private readonly queryFactory: typeof query;
|
||||
private readonly queryFactory?: ClaudeQueryFactory;
|
||||
private readonly resolveBinary: () => Promise<string>;
|
||||
private query: Query | null = null;
|
||||
private input: AsyncMessageInput<SDKUserMessage> | null = null;
|
||||
private claudeSessionId: string | null;
|
||||
@@ -1598,7 +1530,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.persistSession = options.persistSession;
|
||||
this.logger = options.logger;
|
||||
this.queryFactory = options.queryFactory ?? query;
|
||||
this.queryFactory = options.queryFactory;
|
||||
this.resolveBinary = options.resolveBinary;
|
||||
const handle = options.handle;
|
||||
|
||||
if (handle) {
|
||||
@@ -2218,7 +2151,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const options = await this.buildOptions();
|
||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||
this.input = input;
|
||||
this.query = this.queryFactory({ prompt: input.iterable, options });
|
||||
this.query = claudeQuery(
|
||||
{ prompt: input.iterable, options },
|
||||
{
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
launchEnv: this.launchEnv,
|
||||
queryFactory: this.queryFactory,
|
||||
},
|
||||
);
|
||||
// Do not kick off background control-plane queries here. Methods like
|
||||
// supportedCommands()/setPermissionMode() may execute immediately after
|
||||
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
|
||||
@@ -2256,11 +2196,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
? this.config.thinkingOptionId
|
||||
: undefined;
|
||||
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: undefined, effort: undefined };
|
||||
@@ -2290,7 +2225,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
],
|
||||
});
|
||||
|
||||
const claudeBinary = await findExecutable("claude");
|
||||
const claudeBinary = await this.resolveBinary();
|
||||
this.logger.debug(
|
||||
{
|
||||
claudeBinary,
|
||||
@@ -2311,7 +2246,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
allowDangerouslySkipPermissions: true,
|
||||
agents: this.defaults?.agents,
|
||||
canUseTool: this.handlePermissionRequest,
|
||||
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
|
||||
pathToClaudeCodeExecutable: claudeBinary,
|
||||
// Use Claude Code preset system prompt and load CLAUDE.md files
|
||||
// Append provider-agnostic system prompt and orchestrator instructions for agents.
|
||||
systemPrompt: {
|
||||
@@ -2353,11 +2288,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
...this.runtimeSettings.disallowedTools,
|
||||
];
|
||||
}
|
||||
return this.applyRuntimeSettings(base);
|
||||
}
|
||||
|
||||
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
|
||||
return applyRuntimeSettingsToClaudeOptions(options, this.runtimeSettings, this.launchEnv);
|
||||
return base;
|
||||
}
|
||||
|
||||
private normalizeMcpServers(
|
||||
@@ -3,19 +3,13 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import type { AgentPersistenceHandle, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
import type { AgentPersistenceHandle, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
lastQuery: null as ReturnType<typeof buildSdkQueryMock> | null,
|
||||
}));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: sdkMocks.query,
|
||||
}));
|
||||
const queryFactory = vi.fn();
|
||||
let lastQuery: ReturnType<typeof buildSdkQueryMock> | null = null;
|
||||
|
||||
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
|
||||
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
||||
@@ -99,9 +93,9 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
let previousClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
sdkMocks.query.mockImplementation(() => {
|
||||
queryFactory.mockImplementation(() => {
|
||||
const mock = buildSdkQueryMock();
|
||||
sdkMocks.lastQuery = mock;
|
||||
lastQuery = mock;
|
||||
return mock;
|
||||
});
|
||||
|
||||
@@ -145,8 +139,8 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sdkMocks.query.mockReset();
|
||||
sdkMocks.lastQuery = null;
|
||||
queryFactory.mockReset();
|
||||
lastQuery = null;
|
||||
if (previousClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
@@ -157,7 +151,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
|
||||
test("does not replay persisted history during the first live stream turn", async () => {
|
||||
const logger = createTestLogger();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId: "history-session",
|
||||
@@ -194,7 +192,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
|
||||
test("still exposes persisted history through streamHistory", async () => {
|
||||
const logger = createTestLogger();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId: "history-session",
|
||||
@@ -223,7 +225,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
|
||||
test("listCommands includes rewind command", async () => {
|
||||
const logger = createTestLogger();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId: "history-session",
|
||||
@@ -245,7 +251,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
|
||||
test("slash /rewind uses latest user message id from persisted history", async () => {
|
||||
const logger = createTestLogger();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId: "history-session",
|
||||
@@ -276,9 +286,9 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
|
||||
expect(events.some((event) => event.type === "turn_started")).toBe(true);
|
||||
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(sdkMocks.lastQuery).toBeTruthy();
|
||||
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
|
||||
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", {
|
||||
expect(lastQuery).toBeTruthy();
|
||||
expect(lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
|
||||
expect(lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", {
|
||||
dryRun: false,
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude-models.js";
|
||||
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||
|
||||
describe("getClaudeModels", () => {
|
||||
it("returns all claude models", () => {
|
||||
@@ -10,6 +10,7 @@ describe("getClaudeModels", () => {
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6[1m]",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]);
|
||||
@@ -45,6 +45,13 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
isDefault: true,
|
||||
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",
|
||||
id: "claude-sonnet-4-6",
|
||||
120
packages/server/src/server/agent/providers/claude/query.ts
Normal file
120
packages/server/src/server/agent/providers/claude/query.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Direct SDK behavior tests - uses same setup as claude-agent.ts
|
||||
* Direct SDK behavior tests - uses same setup as the Claude provider
|
||||
*/
|
||||
import { mkdtempSync, rmSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
||||
import { findExecutable } from "../../../../utils/executable.js";
|
||||
import { claudeQuery } from "./query.js";
|
||||
|
||||
class Pushable<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
@@ -53,6 +55,17 @@ 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 {
|
||||
let responseText = "";
|
||||
for (const event of events) {
|
||||
@@ -72,21 +85,15 @@ function extractTextFromEvents(events: SDKMessage[]): string {
|
||||
return responseText;
|
||||
}
|
||||
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
describe("Claude SDK direct behavior", () => {
|
||||
let canRunClaudeIntegration = false;
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
||||
if (canRunClaudeIntegration) {
|
||||
expect(await isCommandAvailable("claude")).toBe(true);
|
||||
}
|
||||
canRun = await isProviderAvailable("claude");
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
if (!canRunClaudeIntegration) {
|
||||
if (!canRun) {
|
||||
context.skip();
|
||||
}
|
||||
});
|
||||
@@ -96,8 +103,8 @@ describe("Claude SDK direct behavior", () => {
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
const claudeBinary = await findExecutable("claude");
|
||||
|
||||
// Use same options as claude-agent.ts
|
||||
const q = query({
|
||||
// Use same options as the Claude provider
|
||||
const q = claudeQuery({
|
||||
prompt: input,
|
||||
options: {
|
||||
cwd,
|
||||
@@ -160,7 +167,7 @@ describe("Claude SDK direct behavior", () => {
|
||||
expect(sawResult || responseText.length === 0).toBe(true);
|
||||
} finally {
|
||||
input.end();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmCwd(cwd);
|
||||
}
|
||||
}, 120000);
|
||||
});
|
||||
@@ -1205,7 +1205,7 @@ describe("Codex app-server provider", () => {
|
||||
expect(event.item.text).not.toContain("data:image");
|
||||
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
|
||||
const source = markdownImageSource(event.item.text);
|
||||
expect(source).toMatch(/paseo-attachments\/.+\.png$/);
|
||||
expect(source).toMatch(/paseo-attachments[\\/].+\.png$/);
|
||||
expect(existsSync(source)).toBe(true);
|
||||
rmSync(source, { force: true });
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
||||
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
import {
|
||||
@@ -1228,6 +1228,20 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
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) {
|
||||
try {
|
||||
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||
@@ -1263,6 +1277,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
|
||||
},
|
||||
{ label: "Server", value: serverStatus },
|
||||
{ label: "Auth", value: authValue },
|
||||
{ label: "Models", value: modelsValue },
|
||||
{ label: "Status", value: status },
|
||||
]),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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 { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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 { 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 { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
|
||||
@@ -31,19 +31,6 @@ 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(() => {
|
||||
process.env.PATH = originalEnv.PATH;
|
||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||
@@ -61,12 +48,12 @@ describe("default provider availability", () => {
|
||||
await expect(client.isAvailable()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("Claude reports available without a PATH binary because the SDK bundles its own cli.js", async () => {
|
||||
test("Claude reports unavailable when the default command cannot be resolved", async () => {
|
||||
const binDir = makeTempDir("provider-availability-claude-");
|
||||
isolatePathTo(binDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
await expect(client.isAvailable()).resolves.toBe(true);
|
||||
await expect(client.isAvailable()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("OpenCode reports unavailable when the default command cannot be resolved", async () => {
|
||||
@@ -77,24 +64,6 @@ describe("default provider availability", () => {
|
||||
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 () => {
|
||||
const binDir = makeTempDir("provider-availability-manager-bin-");
|
||||
isolatePathTo(binDir);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./
|
||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
describe("paseo daemon bootstrap", () => {
|
||||
afterEach(() => {
|
||||
@@ -152,52 +153,56 @@ describe("paseo daemon bootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("generates a relay pairing offer for unix socket listeners", async () => {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
|
||||
await mkdir(path.dirname(socketPath), { recursive: true });
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const logger = pino({ level: "silent" });
|
||||
// POSIX-only: Unix socket listen paths are invalid Windows listen targets.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"generates a relay pairing offer for unix socket listeners",
|
||||
async () => {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
|
||||
await mkdir(path.dirname(socketPath), { recursive: true });
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
const config: PaseoDaemonConfig = {
|
||||
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({
|
||||
const config: PaseoDaemonConfig = {
|
||||
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",
|
||||
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 });
|
||||
}
|
||||
});
|
||||
openai: undefined,
|
||||
speech: undefined,
|
||||
};
|
||||
|
||||
const daemon = await createPaseoDaemon(config, logger);
|
||||
|
||||
try {
|
||||
await daemon.start();
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
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,10 +110,9 @@ export function isProviderAvailable(provider: AgentProvider): Promise<boolean> {
|
||||
const availability = (async (): Promise<boolean> => {
|
||||
switch (provider) {
|
||||
case "claude":
|
||||
return (
|
||||
(await isCommandAvailable("claude")) &&
|
||||
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
|
||||
);
|
||||
const hasClaudeEnvCredentials =
|
||||
Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY);
|
||||
return (await isCommandAvailable("claude")) && (!process.env.CI || hasClaudeEnvCredentials);
|
||||
case "codex":
|
||||
return (
|
||||
(await isCommandAvailable("codex")) &&
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
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 { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
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 { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
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 { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/i
|
||||
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
||||
import { withTimeout } from "../../utils/promise-timeout.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 { SessionOutboundMessage } from "../messages.js";
|
||||
|
||||
@@ -220,54 +221,59 @@ test("returns error for non-git directory", async () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000); // 1 minute timeout
|
||||
|
||||
test("returns repo info for git repo with branch and dirty state", async () => {
|
||||
const cwd = tmpCwd();
|
||||
// POSIX-only: asserts repo-root containment across macOS /var symlink normalization.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"returns repo info for git repo with branch and dirty state",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
// Initialize git repo
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("git init -b main", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||
// Initialize git repo
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("git init -b main", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||
|
||||
// Create and commit a file
|
||||
const testFile = path.join(cwd, "test.txt");
|
||||
writeFileSync(testFile, "original content\n");
|
||||
execSync("git add test.txt", { cwd, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
// Create and commit a file
|
||||
const testFile = path.join(cwd, "test.txt");
|
||||
writeFileSync(testFile, "original content\n");
|
||||
execSync("git add test.txt", { cwd, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
// Modify the file (makes repo dirty)
|
||||
writeFileSync(testFile, "modified content\n");
|
||||
// Modify the file (makes repo dirty)
|
||||
writeFileSync(testFile, "modified content\n");
|
||||
|
||||
// Create agent in the git repo
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Git Repo Info Test",
|
||||
});
|
||||
// Create agent in the git repo
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Git Repo Info Test",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Get checkout status
|
||||
const result = await ctx.client.getCheckoutStatus(cwd);
|
||||
// Get checkout status
|
||||
const result = await ctx.client.getCheckoutStatus(cwd);
|
||||
|
||||
// Verify repo info returned without error
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.isGit).toBe(true);
|
||||
// macOS symlinks /var to /private/var, so we check containment
|
||||
expect(result.repoRoot).toContain("daemon-e2e-");
|
||||
expect(result.currentBranch).toBeTruthy();
|
||||
expect(result.isDirty).toBe(true);
|
||||
// Verify repo info returned without error
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.isGit).toBe(true);
|
||||
// macOS symlinks /var to /private/var, so we check containment
|
||||
expect(result.repoRoot).toContain("daemon-e2e-");
|
||||
expect(result.currentBranch).toBeTruthy();
|
||||
expect(result.isDirty).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000); // 1 minute timeout
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
60000,
|
||||
); // 1 minute timeout
|
||||
|
||||
test("returns clean state when no uncommitted changes", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
@@ -6,7 +6,7 @@ import pino from "pino";
|
||||
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.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";
|
||||
|
||||
function tmpCwd(): string {
|
||||
|
||||
@@ -6,7 +6,7 @@ import pino from "pino";
|
||||
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.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";
|
||||
|
||||
function tmpCwd(): string {
|
||||
|
||||
@@ -6,7 +6,7 @@ import pino from "pino";
|
||||
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.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";
|
||||
|
||||
function tmpCwd(): string {
|
||||
|
||||
@@ -6,7 +6,7 @@ import pino from "pino";
|
||||
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.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 { createMessageCollector } from "../test-utils/message-collector.js";
|
||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
|
||||
@@ -6,7 +6,7 @@ import pino from "pino";
|
||||
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.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 { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
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 { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, rm, 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 { readExplorerFile } from "./service.js";
|
||||
|
||||
async function createHomeTempDir(prefix: string): Promise<string> {
|
||||
return mkdtemp(path.join(os.homedir(), prefix));
|
||||
@@ -13,29 +13,6 @@ async function createTempDir(prefix: string): Promise<string> {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const root = await createTempDir("paseo-file-explorer-");
|
||||
|
||||
@@ -135,25 +112,4 @@ describe("file explorer service", () => {
|
||||
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: {
|
||||
level: "debug",
|
||||
path: path.join(paseoHome, "logs", "programmatic.log"),
|
||||
path: path.resolve(paseoHome, "logs", "programmatic.log"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||
import type {
|
||||
@@ -24,6 +32,7 @@ import type {
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { LoopService } from "./loop-service.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
|
||||
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -220,60 +229,71 @@ describe("LoopService", () => {
|
||||
let storage: AgentStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), "loop-service-"));
|
||||
tmpDir = realpathSync.native(mkdtempSync(path.join(os.tmpdir(), "loop-service-")));
|
||||
paseoHome = path.join(tmpDir, "paseo-home");
|
||||
workspaceDir = path.join(tmpDir, "workspace");
|
||||
storage = new AgentStorage(path.join(tmpDir, "agents"), logger);
|
||||
mkdirSync(workspaceDir, { recursive: true });
|
||||
workspaceDir = realpathSync.native(workspaceDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("runs fresh worker agents until verify-check passes", async () => {
|
||||
const state = { workerRuns: 0 };
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
claude: new ScriptedAgentClient("claude", {
|
||||
async onRun({ config }) {
|
||||
state.workerRuns += 1;
|
||||
if (config.title?.includes("worker") && state.workerRuns >= 2) {
|
||||
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
|
||||
}
|
||||
if (config.title?.includes("worker")) {
|
||||
return `worker run ${state.workerRuns}`;
|
||||
}
|
||||
return '{"passed":true,"reason":"not used"}';
|
||||
},
|
||||
}),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const service = new LoopService({ paseoHome, agentManager: manager, logger });
|
||||
await service.initialize();
|
||||
// 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.skipIf(isPlatform("win32"))(
|
||||
"runs fresh worker agents until verify-check passes",
|
||||
async () => {
|
||||
const state = { workerRuns: 0 };
|
||||
const verifyScriptPath = path.join(workspaceDir, "verify-check.cjs");
|
||||
writeFileSync(verifyScriptPath, 'require("fs").accessSync("done.txt");\n');
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
claude: new ScriptedAgentClient("claude", {
|
||||
async onRun({ config }) {
|
||||
state.workerRuns += 1;
|
||||
if (config.title?.includes("worker") && state.workerRuns >= 2) {
|
||||
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
|
||||
}
|
||||
if (config.title?.includes("worker")) {
|
||||
return `worker run ${state.workerRuns}`;
|
||||
}
|
||||
return '{"passed":true,"reason":"not used"}';
|
||||
},
|
||||
}),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const service = new LoopService({ paseoHome, agentManager: manager, logger });
|
||||
await service.initialize();
|
||||
|
||||
const loop = await service.runLoop({
|
||||
prompt: "Create done.txt when the task is actually fixed.",
|
||||
cwd: workspaceDir,
|
||||
verifyChecks: ["test -f done.txt"],
|
||||
sleepMs: 1,
|
||||
maxIterations: 3,
|
||||
});
|
||||
const loop = await service.runLoop({
|
||||
prompt: "Create done.txt when the task is actually fixed.",
|
||||
cwd: workspaceDir,
|
||||
verifyChecks: [
|
||||
`${JSON.stringify(process.execPath)} ${JSON.stringify(path.basename(verifyScriptPath))}`,
|
||||
],
|
||||
sleepMs: 1,
|
||||
maxIterations: 3,
|
||||
});
|
||||
|
||||
await waitForLoopCompletion(service, loop.id);
|
||||
await waitForLoopCompletion(service, loop.id);
|
||||
|
||||
const finalLoop = await service.inspectLoop(loop.id);
|
||||
expect(finalLoop.status).toBe("succeeded");
|
||||
expect(finalLoop.iterations).toHaveLength(2);
|
||||
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(finalLoop.iterations[1]?.workerAgentId);
|
||||
expect(finalLoop.iterations[0]?.status).toBe("failed");
|
||||
expect(finalLoop.iterations[1]?.status).toBe("succeeded");
|
||||
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
|
||||
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
|
||||
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
|
||||
});
|
||||
const finalLoop = await service.inspectLoop(loop.id);
|
||||
expect(finalLoop.status).toBe("succeeded");
|
||||
expect(finalLoop.iterations).toHaveLength(2);
|
||||
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(
|
||||
finalLoop.iterations[1]?.workerAgentId,
|
||||
);
|
||||
expect(finalLoop.iterations[0]?.status).toBe("failed");
|
||||
expect(finalLoop.iterations[1]?.status).toBe("succeeded");
|
||||
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
|
||||
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 () => {
|
||||
const workerConfigs: AgentSessionConfig[] = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type CreatePaseoWorktreeDeps,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
@@ -65,41 +66,45 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
]);
|
||||
});
|
||||
|
||||
test("reuses an existing worktree and still upserts the workspace", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const firstDeps = createDeps();
|
||||
const first = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "reuse-me",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
firstDeps,
|
||||
);
|
||||
const events: string[] = [];
|
||||
const deps = createDeps({
|
||||
events,
|
||||
projects: firstDeps.projects,
|
||||
workspaces: firstDeps.workspaces,
|
||||
});
|
||||
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"reuses an existing worktree and still upserts the workspace",
|
||||
async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const firstDeps = createDeps();
|
||||
const first = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "reuse-me",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
firstDeps,
|
||||
);
|
||||
const events: string[] = [];
|
||||
const deps = createDeps({
|
||||
events,
|
||||
projects: firstDeps.projects,
|
||||
workspaces: firstDeps.workspaces,
|
||||
});
|
||||
|
||||
const second = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "reuse-me",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
const second = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "reuse-me",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
||||
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
||||
});
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
||||
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
||||
},
|
||||
);
|
||||
|
||||
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
@@ -131,7 +136,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
||||
firstAgentContext.prompt ? "renamed-from-agent-context" : null,
|
||||
});
|
||||
const branchAfterFirst = execSync("git branch --show-current", {
|
||||
const branchAfterFirst = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -157,7 +162,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
firstAgentContext: { prompt: "Try another name" },
|
||||
generateBranchNameFromContext: async () => "second-agent-name",
|
||||
});
|
||||
const branchAfterSecond = execSync("git branch --show-current", {
|
||||
const branchAfterSecond = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -196,7 +201,7 @@ test("renames the branch even when the app supplies a random placeholder slug",
|
||||
: null,
|
||||
});
|
||||
|
||||
const branchAfter = execSync("git branch --show-current", {
|
||||
const branchAfter = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -253,7 +258,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
|
||||
: null,
|
||||
});
|
||||
|
||||
const branchAfter = execSync("git branch --show-current", {
|
||||
const branchAfter = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -286,7 +291,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
|
||||
).resolves.toEqual({ attempted: true, renamed: false, branchName: null });
|
||||
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -305,11 +310,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 () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "-b", "dev"], { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "dev"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
@@ -334,7 +339,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
|
||||
}),
|
||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -370,7 +375,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
|
||||
}),
|
||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -524,17 +529,24 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
||||
}
|
||||
|
||||
function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||
const repoRoot = execSync("git rev-parse --show-toplevel", { cwd, stdio: "pipe" })
|
||||
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
const mainRepoRoot = execSync("git rev-parse --path-format=absolute --git-common-dir", {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
const mainRepoRoot = execFileSync(
|
||||
"git",
|
||||
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
{
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
},
|
||||
)
|
||||
.toString()
|
||||
.trim()
|
||||
.replace(/\/\.git$/, "");
|
||||
const currentBranch = execSync("git branch --show-current", { cwd, stdio: "pipe" })
|
||||
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
@@ -566,34 +578,39 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||
function createGitRepo(): { tempDir: string; repoDir: string } {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" });
|
||||
const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim();
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "pr-branch"], { cwd: repoDir, stdio: "pipe" });
|
||||
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
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");
|
||||
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
|
||||
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${prHead}`, {
|
||||
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", prHead], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -22,16 +22,25 @@ function createWorkspaceRepo(options?: {
|
||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
|
||||
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");
|
||||
if (options?.paseoConfig) {
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -12,16 +12,25 @@ function createWorkspaceRepo(options?: {
|
||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
|
||||
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");
|
||||
if (options?.paseoConfig) {
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import {
|
||||
buildWorkspaceScriptPayloads,
|
||||
@@ -21,16 +21,25 @@ function createWorkspaceRepo(options?: {
|
||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
|
||||
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");
|
||||
if (options?.paseoConfig) {
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
asDaemonConfigStore,
|
||||
createProviderSnapshotManagerStub,
|
||||
} from "./test-utils/session-stubs.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
interface SessionHandlerInternals {
|
||||
startVoiceTurnController(): Promise<void>;
|
||||
@@ -705,39 +706,46 @@ describe("project config RPC authorization", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("read_project_config_request accepts a symlink to an active project root", async () => {
|
||||
const repoRoot = makeRoot();
|
||||
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
||||
const linkRoot = join(makeRoot(), "link");
|
||||
symlinkSync(repoRoot, linkRoot, "dir");
|
||||
const messages: unknown[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
|
||||
});
|
||||
// POSIX-only: creates a directory symlink without Windows privileges.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"read_project_config_request accepts a symlink to an active project root",
|
||||
async () => {
|
||||
const repoRoot = makeRoot();
|
||||
writeFileSync(
|
||||
join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({ worktree: { setup: "npm ci" } }),
|
||||
);
|
||||
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({
|
||||
type: "read_project_config_request",
|
||||
requestId: "read-symlink-1",
|
||||
repoRoot: linkRoot,
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "read_project_config_request",
|
||||
requestId: "read-symlink-1",
|
||||
repoRoot: linkRoot,
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "read-symlink-1",
|
||||
repoRoot,
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
revision: expect.objectContaining({
|
||||
mtimeMs: expect.any(Number),
|
||||
size: expect.any(Number),
|
||||
}),
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "read-symlink-1",
|
||||
repoRoot,
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
revision: expect.objectContaining({
|
||||
mtimeMs: expect.any(Number),
|
||||
size: expect.any(Number),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
|
||||
const archivedRoot = makeRoot();
|
||||
|
||||
@@ -3007,18 +3007,11 @@ export class Session {
|
||||
if (!resolvedWorkspace) {
|
||||
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
||||
}
|
||||
const snapshot = await this.agentManager.createAgent(
|
||||
{
|
||||
...sessionConfig,
|
||||
cwd: resolvedWorkspace.cwd,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
labels,
|
||||
workspaceId: resolvedWorkspace.workspaceId,
|
||||
initialPrompt: trimmedPrompt,
|
||||
},
|
||||
);
|
||||
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, {
|
||||
labels,
|
||||
workspaceId: resolvedWorkspace.workspaceId,
|
||||
initialPrompt: trimmedPrompt,
|
||||
});
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
|
||||
await this.sendInitialCreateAgentPrompt({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import path from "node:path";
|
||||
import type pino from "pino";
|
||||
import { Session, type SessionOptions } from "./session.js";
|
||||
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
||||
@@ -36,6 +37,9 @@ type WorkspaceUpdatePayload = Extract<
|
||||
{ type: "workspace_update" }
|
||||
>["payload"];
|
||||
|
||||
const REPO_CWD = path.resolve("/tmp/repo");
|
||||
const REPO_SUBSCRIPTION_REQUEST_ID = `subscription:${REPO_CWD}`;
|
||||
|
||||
function createWorkspaceRuntimeSnapshot(
|
||||
cwd: string,
|
||||
overrides?: {
|
||||
@@ -243,7 +247,7 @@ function seedGitWorkspace(input: {
|
||||
input.projectId,
|
||||
createPersistedProjectRecord({
|
||||
projectId: input.projectId,
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: input.cwd,
|
||||
displayName: "repo",
|
||||
kind: "git",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -274,7 +278,7 @@ describe("workspace git watch targets", () => {
|
||||
workspaces,
|
||||
projectId: "proj-1",
|
||||
workspaceId: "ws-10",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
name: "main",
|
||||
});
|
||||
sessionAny.workspaceUpdatesSubscription = {
|
||||
@@ -289,22 +293,22 @@ describe("workspace git watch targets", () => {
|
||||
id: "ws-10",
|
||||
projectId: "proj-1",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectRootPath: REPO_CWD,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
workspaceDirectory: "/tmp/repo",
|
||||
workspaceDirectory: REPO_CWD,
|
||||
};
|
||||
|
||||
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
|
||||
|
||||
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||
|
||||
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
||||
{ cwd: "/tmp/repo" },
|
||||
{ cwd: REPO_CWD },
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
@@ -314,7 +318,7 @@ describe("workspace git watch targets", () => {
|
||||
};
|
||||
|
||||
subscriptions[0]?.listener(
|
||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "renamed-branch",
|
||||
},
|
||||
@@ -349,7 +353,7 @@ describe("workspace git watch targets", () => {
|
||||
workspaces,
|
||||
projectId: "proj-1",
|
||||
workspaceId: "ws-10",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
name: "main",
|
||||
});
|
||||
sessionAny.workspaceUpdatesSubscription = {
|
||||
@@ -360,11 +364,11 @@ describe("workspace git watch targets", () => {
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
};
|
||||
|
||||
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||
emitted.length = 0;
|
||||
|
||||
subscriptions[0]?.listener(
|
||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "feature/server-push",
|
||||
isDirty: true,
|
||||
@@ -380,9 +384,9 @@ describe("workspace git watch targets", () => {
|
||||
) as Array<{ type: "checkout_status_update"; payload: CheckoutStatusUpdatePayload }>;
|
||||
expect(statusUpdates).toHaveLength(1);
|
||||
expect(statusUpdates[0]?.payload).toMatchObject({
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
isGit: true,
|
||||
repoRoot: "/tmp/repo",
|
||||
repoRoot: REPO_CWD,
|
||||
currentBranch: "feature/server-push",
|
||||
isDirty: true,
|
||||
baseRef: "main",
|
||||
@@ -393,10 +397,10 @@ describe("workspace git watch targets", () => {
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
error: null,
|
||||
requestId: "subscription:/tmp/repo",
|
||||
requestId: REPO_SUBSCRIPTION_REQUEST_ID,
|
||||
});
|
||||
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
||||
{ cwd: "/tmp/repo" },
|
||||
{ cwd: REPO_CWD },
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
@@ -412,7 +416,7 @@ describe("workspace git watch targets", () => {
|
||||
workspaces,
|
||||
projectId: "proj-1",
|
||||
workspaceId: "ws-10",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
name: "main",
|
||||
});
|
||||
sessionAny.workspaceUpdatesSubscription = {
|
||||
@@ -423,11 +427,11 @@ describe("workspace git watch targets", () => {
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
};
|
||||
|
||||
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||
emitted.length = 0;
|
||||
|
||||
subscriptions[0]?.listener(
|
||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
github: {
|
||||
featuresEnabled: true,
|
||||
pullRequest: {
|
||||
@@ -457,7 +461,7 @@ describe("workspace git watch targets", () => {
|
||||
| { payload: CheckoutStatusUpdatePayload }
|
||||
| undefined;
|
||||
expect(statusUpdate?.payload.prStatus).toEqual({
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
status: {
|
||||
number: 456,
|
||||
url: "https://github.com/acme/repo/pull/456",
|
||||
@@ -481,7 +485,7 @@ describe("workspace git watch targets", () => {
|
||||
},
|
||||
githubFeaturesEnabled: true,
|
||||
error: null,
|
||||
requestId: "subscription:/tmp/repo",
|
||||
requestId: REPO_SUBSCRIPTION_REQUEST_ID,
|
||||
});
|
||||
|
||||
await session.cleanup();
|
||||
@@ -491,7 +495,7 @@ describe("workspace git watch targets", () => {
|
||||
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
|
||||
|
||||
workspaceGitService.getSnapshot.mockResolvedValue(
|
||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
github: {
|
||||
featuresEnabled: true,
|
||||
pullRequest: {
|
||||
@@ -508,15 +512,15 @@ describe("workspace git watch targets", () => {
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout_pr_status_request",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
requestId: "req-pr-status",
|
||||
});
|
||||
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REPO_CWD);
|
||||
expect(
|
||||
emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
|
||||
).toEqual({
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
status: {
|
||||
number: undefined,
|
||||
url: "https://github.com/acme/repo/pull/456",
|
||||
@@ -543,12 +547,12 @@ describe("workspace git watch targets", () => {
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout_pr_status_request",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
requestId: "req-pr-cached",
|
||||
});
|
||||
|
||||
expect(workspaceGitService.refresh).not.toHaveBeenCalled();
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REPO_CWD);
|
||||
expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,6 +185,18 @@ function getOpenResponse(emitted: SessionOutboundMessage[], requestId: string) {
|
||||
}
|
||||
|
||||
const T0 = "2026-01-01T00:00:00.000Z";
|
||||
const FOO = path.resolve("/foo");
|
||||
const FOO_SUB = path.join(FOO, "sub");
|
||||
const BAR = path.resolve("/bar");
|
||||
const BAR_BAZ = path.join(BAR, "baz");
|
||||
const TOOLBOX = path.resolve("/toolbox");
|
||||
const TOOLBOX_FLOMO = path.join(TOOLBOX, "flomo-cli");
|
||||
const USERS_DEVELOPER = path.resolve("/Users/me/Developer");
|
||||
const USERS_PROJECT = path.join(USERS_DEVELOPER, "projects", "foo");
|
||||
const PROJECTS = path.resolve("/projects");
|
||||
const SOME_GIT_REPO = path.join(PROJECTS, "some-git-repo");
|
||||
const PARENT = path.resolve("/parent");
|
||||
const PARENT_CHILD = path.join(PARENT, "child");
|
||||
|
||||
function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
|
||||
return createPersistedWorkspaceRecord({
|
||||
@@ -240,13 +252,13 @@ function dirProject(rootPath: string, archivedAt: string | null = null) {
|
||||
// S1. Open a fresh git repo: creates a workspace at the canonical root.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S1: open fresh git repo creates workspace at canonical root", async () => {
|
||||
const h = createHarness({ gitRoots: ["/foo"] });
|
||||
await openProject(h.session, "/foo");
|
||||
const h = createHarness({ gitRoots: [FOO] });
|
||||
await openProject(h.session, FOO);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe("/foo");
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(FOO);
|
||||
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
||||
expect(h.workspaces.has("/foo")).toBe(true);
|
||||
expect(h.workspaces.has(FOO)).toBe(true);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -255,12 +267,12 @@ test("S1: open fresh git repo creates workspace at canonical root", async () =>
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S2: open fresh non-git directory creates a directory workspace at exact path", async () => {
|
||||
const h = createHarness({});
|
||||
await openProject(h.session, "/bar");
|
||||
await openProject(h.session, BAR);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe("/bar");
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(BAR);
|
||||
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
||||
expect(h.workspaces.has("/bar")).toBe(true);
|
||||
expect(h.workspaces.has(BAR)).toBe(true);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -269,15 +281,15 @@ test("S2: open fresh non-git directory creates a directory workspace at exact pa
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S3: re-open active workspace by exact path returns the same record", async () => {
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/foo")],
|
||||
projects: [gitProject("/foo")],
|
||||
gitRoots: ["/foo"],
|
||||
workspaces: [gitWorkspace(FOO)],
|
||||
projects: [gitProject(FOO)],
|
||||
gitRoots: [FOO],
|
||||
});
|
||||
await openProject(h.session, "/foo");
|
||||
await openProject(h.session, FOO);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.id).toBe("/foo");
|
||||
expect(resp?.workspace?.id).toBe(FOO);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
expect(h.workspaces.get("/foo")?.archivedAt).toBeNull();
|
||||
expect(h.workspaces.get(FOO)?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -286,13 +298,13 @@ test("S3: re-open active workspace by exact path returns the same record", async
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S4: open subdir of active git workspace returns the repo-root workspace", async () => {
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/foo")],
|
||||
projects: [gitProject("/foo")],
|
||||
gitRoots: ["/foo"],
|
||||
workspaces: [gitWorkspace(FOO)],
|
||||
projects: [gitProject(FOO)],
|
||||
gitRoots: [FOO],
|
||||
});
|
||||
await openProject(h.session, "/foo/sub");
|
||||
await openProject(h.session, FOO_SUB);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.id).toBe("/foo");
|
||||
expect(resp?.workspace?.id).toBe(FOO);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
});
|
||||
|
||||
@@ -302,14 +314,14 @@ test("S4: open subdir of active git workspace returns the repo-root workspace",
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S5: open subdir of active non-git directory creates a SEPARATE workspace", async () => {
|
||||
const h = createHarness({
|
||||
workspaces: [dirWorkspace("/bar")],
|
||||
projects: [dirProject("/bar")],
|
||||
workspaces: [dirWorkspace(BAR)],
|
||||
projects: [dirProject(BAR)],
|
||||
});
|
||||
await openProject(h.session, "/bar/baz");
|
||||
await openProject(h.session, BAR_BAZ);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe("/bar/baz");
|
||||
expect(h.workspaces.has("/bar")).toBe(true);
|
||||
expect(h.workspaces.has("/bar/baz")).toBe(true);
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ);
|
||||
expect(h.workspaces.has(BAR)).toBe(true);
|
||||
expect(h.workspaces.has(BAR_BAZ)).toBe(true);
|
||||
expect(h.workspaces.size).toBe(2);
|
||||
});
|
||||
|
||||
@@ -320,13 +332,13 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace",
|
||||
test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
||||
projects: [gitProject("/toolbox", archivedAt)],
|
||||
gitRoots: ["/toolbox"],
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, "/toolbox");
|
||||
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull();
|
||||
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull();
|
||||
await openProject(h.session, TOOLBOX);
|
||||
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -334,15 +346,15 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the inner root", async () => {
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/foo")],
|
||||
projects: [gitProject("/foo")],
|
||||
gitRoots: ["/foo", "/foo/sub"],
|
||||
workspaces: [gitWorkspace(FOO)],
|
||||
projects: [gitProject(FOO)],
|
||||
gitRoots: [FOO, FOO_SUB],
|
||||
});
|
||||
await openProject(h.session, "/foo/sub");
|
||||
await openProject(h.session, FOO_SUB);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe("/foo/sub");
|
||||
expect(h.workspaces.has("/foo")).toBe(true);
|
||||
expect(h.workspaces.has("/foo/sub")).toBe(true);
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
|
||||
expect(h.workspaces.has(FOO)).toBe(true);
|
||||
expect(h.workspaces.has(FOO_SUB)).toBe(true);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -353,12 +365,12 @@ test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the in
|
||||
test("S8: open child of archived non-git ancestor creates fresh workspace; ancestor stays archived", async () => {
|
||||
const archivedAt = "2026-04-04T17:15:22.423Z";
|
||||
const h = createHarness({
|
||||
workspaces: [dirWorkspace("/Users/me/Developer", archivedAt)],
|
||||
projects: [dirProject("/Users/me/Developer", archivedAt)],
|
||||
workspaces: [dirWorkspace(USERS_DEVELOPER, archivedAt)],
|
||||
projects: [dirProject(USERS_DEVELOPER, archivedAt)],
|
||||
});
|
||||
await openProject(h.session, "/Users/me/Developer/projects/foo");
|
||||
expect(h.workspaces.get("/Users/me/Developer")?.archivedAt).toBe(archivedAt);
|
||||
expect(h.workspaces.has("/Users/me/Developer/projects/foo")).toBe(true);
|
||||
await openProject(h.session, USERS_PROJECT);
|
||||
expect(h.workspaces.get(USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
|
||||
expect(h.workspaces.has(USERS_PROJECT)).toBe(true);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -369,12 +381,12 @@ test("S8: open child of archived non-git ancestor creates fresh workspace; ances
|
||||
test("S9: opening child of archived git workspace does NOT auto-unarchive the parent", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
||||
projects: [gitProject("/toolbox", archivedAt)],
|
||||
gitRoots: ["/toolbox"],
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, "/toolbox/flomo-cli");
|
||||
expect(h.workspaces.get("/toolbox")?.archivedAt).toBe(archivedAt);
|
||||
await openProject(h.session, TOOLBOX_FLOMO);
|
||||
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -389,18 +401,18 @@ test("S9: opening child of archived git workspace does NOT auto-unarchive the pa
|
||||
test("S10: opening a git repo nested inside an archived non-git directory creates fresh workspace; ancestor stays archived", async () => {
|
||||
const archivedAt = "2026-04-04T17:15:22.423Z";
|
||||
const h = createHarness({
|
||||
workspaces: [dirWorkspace("/projects", archivedAt)],
|
||||
projects: [dirProject("/projects", archivedAt)],
|
||||
gitRoots: ["/projects/some-git-repo"],
|
||||
workspaces: [dirWorkspace(PROJECTS, archivedAt)],
|
||||
projects: [dirProject(PROJECTS, archivedAt)],
|
||||
gitRoots: [SOME_GIT_REPO],
|
||||
});
|
||||
await openProject(h.session, "/projects/some-git-repo");
|
||||
await openProject(h.session, SOME_GIT_REPO);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe("/projects/some-git-repo");
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(SOME_GIT_REPO);
|
||||
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
||||
expect(h.workspaces.has("/projects/some-git-repo")).toBe(true);
|
||||
expect(h.workspaces.get("/projects")?.archivedAt).toBe(archivedAt);
|
||||
expect(h.projects.get("/projects")?.archivedAt).toBe(archivedAt);
|
||||
expect(h.workspaces.has(SOME_GIT_REPO)).toBe(true);
|
||||
expect(h.workspaces.get(PROJECTS)?.archivedAt).toBe(archivedAt);
|
||||
expect(h.projects.get(PROJECTS)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -411,19 +423,19 @@ test("S10: opening a git repo nested inside an archived non-git directory create
|
||||
test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
||||
projects: [gitProject("/toolbox", archivedAt)],
|
||||
gitRoots: ["/toolbox"],
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, "/toolbox");
|
||||
await openProject(h.session, TOOLBOX);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.id).toBe("/toolbox");
|
||||
expect(resp?.workspace?.projectId).toBe("/toolbox");
|
||||
expect(resp?.workspace?.id).toBe(TOOLBOX);
|
||||
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
expect(h.projects.size).toBe(1);
|
||||
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull();
|
||||
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull();
|
||||
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -434,12 +446,12 @@ test("S11: re-opening an archived project by exact path unarchives project + wor
|
||||
test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix fallback", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [dirWorkspace("/parent", archivedAt)],
|
||||
projects: [dirProject("/parent", archivedAt)],
|
||||
workspaces: [dirWorkspace(PARENT, archivedAt)],
|
||||
projects: [dirProject(PARENT, archivedAt)],
|
||||
});
|
||||
const found = await asInternals<{
|
||||
findWorkspaceByDirectory(cwd: string): Promise<unknown>;
|
||||
}>(h.session).findWorkspaceByDirectory("/parent/child");
|
||||
}>(h.session).findWorkspaceByDirectory(PARENT_CHILD);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
@@ -456,11 +468,11 @@ test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix
|
||||
test("S13: subfolder of an archived git repo opens as a directory workspace", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
||||
projects: [gitProject("/toolbox", archivedAt)],
|
||||
gitRoots: ["/toolbox"],
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, "/toolbox/flomo-cli");
|
||||
await openProject(h.session, TOOLBOX_FLOMO);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user