mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d9563352 | ||
|
|
045b373168 | ||
|
|
985ad52cce | ||
|
|
d198c68b9e | ||
|
|
751a07124f | ||
|
|
defb4f82f7 | ||
|
|
4570e65ce8 | ||
|
|
db0d63dd90 | ||
|
|
1a8fdcd388 | ||
|
|
417abed6a5 | ||
|
|
6afdeef84a | ||
|
|
77c82dfdbd | ||
|
|
29277c900d | ||
|
|
ed1943058a | ||
|
|
e0361ddd22 | ||
|
|
ce9474055e | ||
|
|
8f9b4c8828 | ||
|
|
af4e0de9ab | ||
|
|
3acc71b8ad | ||
|
|
0759932dad | ||
|
|
95f45e4e2b | ||
|
|
a5c2b97e1d | ||
|
|
d32462e9ee | ||
|
|
33262843a5 | ||
|
|
1cd02a0e1a | ||
|
|
40ab9e3f20 |
72
.github/workflows/nix-build.yml
vendored
72
.github/workflows/nix-build.yml
vendored
@@ -1,72 +0,0 @@
|
||||
name: Nix Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-build.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-build.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Commit hash/lockfile updates (main push only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/package.nix
|
||||
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||
git push
|
||||
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Nix Update Hash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-update-hash.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-hash:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Commit hash/lockfile updates
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
|
||||
git config user.name "paseo-ai[bot]"
|
||||
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/npm-deps.hash
|
||||
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
|
||||
git push
|
||||
99
.github/workflows/nix.yml
vendored
Normal file
99
.github/workflows/nix.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Smoke Nix daemon
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
export PASEO_HOME
|
||||
PASEO_HOME="$(mktemp -d)"
|
||||
export PASEO_LISTEN=127.0.0.1:6767
|
||||
|
||||
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
kill "$DAEMON_PID"
|
||||
wait "$DAEMON_PID" || true
|
||||
fi
|
||||
rm -rf "$PASEO_HOME"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
|
||||
deadline=$((SECONDS + 30))
|
||||
while (( SECONDS < deadline )); do
|
||||
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
|
||||
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
|
||||
echo "$STATUS_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
echo "Nix daemon exited before becoming reachable."
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "::group::daemon.log"
|
||||
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo-server stdout/stderr"
|
||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo daemon status"
|
||||
./result/bin/paseo daemon status || true
|
||||
echo "::endgroup::"
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Build Nix desktop package
|
||||
run: nix build .#desktop -o result-desktop
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.75 - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- Set the speech-to-text language used by dictation and voice mode from settings. ([#941](https://github.com/getpaseo/paseo/pull/941))
|
||||
- NixOS: `services.paseo.settings` renders declarative daemon config to disk, and typed `services.paseo.relay` options auto-wire the relay endpoint. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
|
||||
- NixOS: Paseo desktop is now packaged in the flake — `nix run github:getpaseo/paseo#desktop` launches the Electron app. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Codex resume failures now surface as explicit errors instead of leaving the agent silently stuck. ([#947](https://github.com/getpaseo/paseo/pull/947))
|
||||
- Custom providers extending Codex now route correctly when they set a custom `OPENAI_BASE_URL`. ([#915](https://github.com/getpaseo/paseo/pull/915))
|
||||
- Copilot's **Allow All** mode (previously misnamed Autopilot) now actually suppresses tool, path, and URL permission prompts. ([#935](https://github.com/getpaseo/paseo/pull/935))
|
||||
- Desktop: daemon startup no longer fails when a stale PID file is left next to a still-running daemon. ([#913](https://github.com/getpaseo/paseo/pull/913) by [@biaoma-ty](https://github.com/biaoma-ty))
|
||||
- iPhone HEIC photos now attach correctly from the image picker. ([#934](https://github.com/getpaseo/paseo/pull/934))
|
||||
- Scheduled agents now archive automatically after each run instead of piling up in the active list. ([#945](https://github.com/getpaseo/paseo/pull/945))
|
||||
- Windows: Codex command summaries show the underlying command instead of `pwsh`, `powershell`, or `cmd` wrappers. ([#931](https://github.com/getpaseo/paseo/pull/931) by [@32r4](https://github.com/32r4))
|
||||
- iPad: settings sidebar and main sidebar respect the top safe area in wide layouts. ([#922](https://github.com/getpaseo/paseo/pull/922), [#937](https://github.com/getpaseo/paseo/pull/937) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
||||
|
||||
## 0.1.74 - 2026-05-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OpenCode agent turns no longer stall.** Paseo now follows OpenCode's global event stream, so turns stream reliably without falling back to fragile recovery paths. ([#916](https://github.com/getpaseo/paseo/pull/916))
|
||||
|
||||
## 0.1.73 - 2026-05-10
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -59,6 +59,30 @@ Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
### Codex with an OpenAI-compatible endpoint
|
||||
|
||||
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "My Codex",
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-...",
|
||||
"OPENAI_BASE_URL": "https://custom-relay.example.com"
|
||||
},
|
||||
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
|
||||
@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
providers: Record<providerId, ProviderOverride>
|
||||
},
|
||||
features: {
|
||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
|
||||
@@ -39,7 +39,14 @@ In any worktree-style or portless setup, never assume default ports.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
|
||||
session, and agent-manager traces for stuck-state debugging.
|
||||
|
||||
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
|
||||
49
docs/opencode-global-event-baseline.md
Normal file
49
docs/opencode-global-event-baseline.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# OpenCode Global Event Verification
|
||||
|
||||
Date: 2026-05-11
|
||||
|
||||
## Objective
|
||||
|
||||
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
|
||||
|
||||
## Environment
|
||||
|
||||
- `opencode --version`: `1.14.46`
|
||||
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
|
||||
- `node --version`: `v22.20.0`
|
||||
- `npm --version`: `10.9.3`
|
||||
|
||||
Each OpenCode test file was run independently with:
|
||||
|
||||
```bash
|
||||
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
|
||||
```
|
||||
|
||||
## Baseline
|
||||
|
||||
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
|
||||
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
|
||||
|
||||
## Post-Change Result
|
||||
|
||||
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
|
||||
|
||||
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
|
||||
|
||||
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `git diff --check`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
@@ -26,11 +26,18 @@
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||
isLinux = nixpkgs.lib.elem system [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
in
|
||||
{
|
||||
default = paseo;
|
||||
paseo = paseo;
|
||||
}
|
||||
// nixpkgs.lib.optionalAttrs isLinux {
|
||||
desktop = pkgs.callPackage ./nix/desktop-package.nix { };
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
|
||||
157
nix/desktop-package.nix
Normal file
157
nix/desktop-package.nix
Normal file
@@ -0,0 +1,157 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
buildNpmPackage,
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
electron,
|
||||
libuv,
|
||||
# Shares the daemon's npm-deps hash — same package-lock.json, same fetcher.
|
||||
# Override via `.override { npmDepsHash = "..."; }` if your nixpkgs computes a
|
||||
# different value.
|
||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "paseo-desktop";
|
||||
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./..;
|
||||
filter = path: type:
|
||||
let
|
||||
baseName = builtins.baseNameOf path;
|
||||
relPath = lib.removePrefix (toString ./..) path;
|
||||
in
|
||||
# Exclude mobile-only platform code (we only need the web/electron build)
|
||||
!(lib.hasPrefix "/packages/app/android" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||
# Website is unrelated to the desktop app
|
||||
&& !(lib.hasPrefix "/packages/website" relPath)
|
||||
# Test fixtures and build artifacts
|
||||
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||
&& baseName != "node_modules"
|
||||
&& baseName != ".git"
|
||||
&& baseName != ".paseo"
|
||||
&& baseName != ".DS_Store"
|
||||
&& baseName != "release";
|
||||
};
|
||||
|
||||
nodejs = nodejs_22;
|
||||
inherit npmDepsHash;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty)
|
||||
makeWrapper
|
||||
copyDesktopItems
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
|
||||
|
||||
dontNpmBuild = true;
|
||||
|
||||
env = {
|
||||
EXPO_NO_TELEMETRY = "1";
|
||||
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
|
||||
# to phone home during the build.
|
||||
CI = "1";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Native deps (terminal emulation; libuv-linked on Linux)
|
||||
npm rebuild node-pty
|
||||
|
||||
# Daemon workspaces (highlight + relay + server + cli)
|
||||
npm run build:daemon
|
||||
|
||||
# App workspace deps not covered by build:daemon
|
||||
npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||
|
||||
# Expo web export for the Electron renderer
|
||||
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
|
||||
|
||||
# Desktop main process (tsc only — NOT electron-builder)
|
||||
npm run build:main --workspace=@getpaseo/desktop
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/share/paseo-desktop $out/bin
|
||||
|
||||
# Preserve the monorepo layout so main.js's dev-mode path resolution
|
||||
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
|
||||
# without patching: invoked unpackaged via `electron path/to/main.js`,
|
||||
# `app.isPackaged` is false, so these relative paths are used.
|
||||
#
|
||||
# Copy the entire packages/ tree (not just built artifacts) because npm
|
||||
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
|
||||
# Missing any workspace package leaves dangling symlinks and fails the
|
||||
# noBrokenSymlinks output check. The cleanSourceWith filter above already
|
||||
# drops the big platform-specific things (android/ios, website, tests).
|
||||
cp package.json $out/share/paseo-desktop/
|
||||
cp -a packages $out/share/paseo-desktop/
|
||||
cp -a node_modules $out/share/paseo-desktop/
|
||||
|
||||
# Skills directory referenced at runtime by some agents
|
||||
if [ -d skills ]; then
|
||||
cp -a skills $out/share/paseo-desktop/
|
||||
fi
|
||||
|
||||
# Hicolor icon for desktop environments
|
||||
install -Dm644 packages/desktop/assets/icon.png \
|
||||
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
|
||||
|
||||
# Launcher wraps nixpkgs electron.
|
||||
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
|
||||
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
|
||||
# `security.wrappers` via a NixOS module for users who want the sandbox.
|
||||
#
|
||||
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
|
||||
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
|
||||
# (defaults to http://localhost:8081 — the Expo dev server, which doesn't
|
||||
# exist here). Point it at the `paseo://` protocol handler instead, which
|
||||
# serves from `__dirname/../../app/dist` (our install layout matches).
|
||||
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
|
||||
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
|
||||
--add-flags "--no-sandbox" \
|
||||
--set EXPO_DEV_URL "paseo://app/"
|
||||
|
||||
copyDesktopItems
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
desktopItems = [
|
||||
(makeDesktopItem {
|
||||
name = "paseo-desktop";
|
||||
desktopName = "Paseo";
|
||||
genericName = "AI Coding Agents";
|
||||
comment = "Self-hosted daemon for AI coding agents";
|
||||
exec = "paseo-desktop";
|
||||
icon = "paseo-desktop";
|
||||
categories = [ "Development" ];
|
||||
startupWMClass = "Paseo";
|
||||
})
|
||||
];
|
||||
|
||||
meta = {
|
||||
description = "Paseo desktop app (Electron wrapper)";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo-desktop";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
@@ -81,7 +81,48 @@ in
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||
description = ''
|
||||
Whether to enable relay-based remote access. When false, the daemon
|
||||
runs with `--no-relay` and only accepts direct (LAN/loopback)
|
||||
connections.
|
||||
'';
|
||||
};
|
||||
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "hosted" "remote" ];
|
||||
default = "hosted";
|
||||
description = ''
|
||||
How the daemon reaches the relay when `relay.enable = true`:
|
||||
|
||||
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
|
||||
Preserves the current behavior; no extra options needed.
|
||||
- `"remote"`: connect to a self-hosted relay at
|
||||
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
|
||||
`PASEO_RELAY_USE_TLS` for the daemon.
|
||||
|
||||
A `"local"` mode (running a relay on the same host as a systemd
|
||||
unit) is not yet implemented — the relay package currently only
|
||||
ships a Cloudflare Workers adapter. Tracked separately.
|
||||
'';
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "relay.example.com";
|
||||
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 443;
|
||||
description = "Relay port. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
useTls = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,9 +152,50 @@ in
|
||||
'';
|
||||
description = "Extra environment variables for the Paseo daemon.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = (pkgs.formats.json { }).type;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
daemon.mcp = { enabled = true; injectIntoAgents = false; };
|
||||
agents.providers.myAcp = {
|
||||
extends = "acp";
|
||||
label = "My Agent";
|
||||
command = { path = "/run/current-system/sw/bin/my-acp"; };
|
||||
};
|
||||
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
|
||||
and installed on every service start.
|
||||
|
||||
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
|
||||
or the mobile app toggling MCP injection / provider overrides) are
|
||||
overwritten on the next restart. Pick one: manage via this option, or
|
||||
manage via the CLI — not both.
|
||||
|
||||
The full schema is defined by `PersistedConfigSchema` in
|
||||
`packages/server/src/server/persisted-config.ts`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
config = lib.mkIf cfg.enable (
|
||||
let
|
||||
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
|
||||
in
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
|
||||
message = ''
|
||||
services.paseo.relay.host must be set when relay.mode = "remote".
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
@@ -131,6 +213,10 @@ in
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
preStart = lib.mkIf (cfg.settings != { }) ''
|
||||
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
|
||||
'';
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PASEO_HOME = cfg.dataDir;
|
||||
@@ -149,6 +235,9 @@ in
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
|
||||
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
|
||||
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
@@ -172,5 +261,6 @@ in
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
1
nix/npm-deps.hash
Normal file
1
nix/npm-deps.hash
Normal file
@@ -0,0 +1 @@
|
||||
sha256-LczD9EmK6LuaJuZQu1v/q8zBE92LVynRR85dp6IdfCo=
|
||||
@@ -7,6 +7,15 @@
|
||||
makeWrapper,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
|
||||
# can override via `.override { npmDepsHash = "sha256-..."; }` without
|
||||
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
|
||||
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
|
||||
#
|
||||
# The default is read from a sidecar file so the CI auto-updater can replace
|
||||
# the hash with a single file write instead of a sed against this source.
|
||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
@@ -40,9 +49,9 @@ buildNpmPackage rec {
|
||||
|
||||
nodejs = nodejs_22;
|
||||
|
||||
# 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-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
|
||||
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
||||
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
||||
inherit npmDepsHash;
|
||||
|
||||
# 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).
|
||||
|
||||
39
package-lock.json
generated
39
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -21769,6 +21769,18 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-manipulator": {
|
||||
"version": "14.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
|
||||
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-image-loader": "~6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-picker": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
|
||||
@@ -38848,7 +38860,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38886,6 +38898,7 @@
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
@@ -38974,10 +38987,10 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.73",
|
||||
"@getpaseo/server": "0.1.75",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39020,7 +39033,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -39069,7 +39082,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -39105,7 +39118,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -39131,7 +39144,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39146,12 +39159,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/highlight": "0.1.73",
|
||||
"@getpaseo/relay": "0.1.73",
|
||||
"@getpaseo/highlight": "0.1.75",
|
||||
"@getpaseo/relay": "0.1.75",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
@@ -39694,7 +39707,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -63,6 +63,7 @@
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
|
||||
@@ -791,10 +791,13 @@ function DesktopSidebar({
|
||||
|
||||
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
|
||||
[resizeAnimatedStyle, insetsTop],
|
||||
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
|
||||
[resizeAnimatedStyle],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(
|
||||
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
|
||||
[insetsTop],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
|
||||
const resizeHandleStyle = useMemo(
|
||||
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
||||
[],
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("expo-image-manipulator", () => ({
|
||||
SaveFormat: { PNG: "png" },
|
||||
ImageManipulator: {
|
||||
manipulate: (_source: string) => ({
|
||||
async renderAsync() {
|
||||
return {
|
||||
release() {},
|
||||
async saveAsync(options: { format?: string }) {
|
||||
return {
|
||||
uri:
|
||||
options.format === "png"
|
||||
? "file:///cache/ImageManipulator/safe-picked.png"
|
||||
: "file:///cache/ImageManipulator/unsafe-picked.jpg",
|
||||
width: 100,
|
||||
height: 100,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
release() {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { normalizePickedImageAssets } from "./image-attachment-picker.native";
|
||||
|
||||
describe("native image attachment picker", () => {
|
||||
it("preserves native picked JPEG and PNG attachment inputs", async () => {
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "file:///photos/IMG_0001.JPG",
|
||||
mimeType: "image/jpeg",
|
||||
fileName: "picked.jpeg",
|
||||
},
|
||||
{
|
||||
uri: "file:///photos/screenshot.png",
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" },
|
||||
mimeType: "image/jpeg",
|
||||
fileName: "picked.jpg",
|
||||
},
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///photos/screenshot.png" },
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "file:///photos/IMG_0001.HEIC",
|
||||
mimeType: "image/png",
|
||||
fileName: "picked.png",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" },
|
||||
mimeType: "image/png",
|
||||
fileName: "picked.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
129
packages/app/src/hooks/image-attachment-picker.native.ts
Normal file
129
packages/app/src/hooks/image-attachment-picker.native.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
|
||||
|
||||
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
|
||||
|
||||
export interface PickedImageAttachmentInput {
|
||||
source: PickedImageSource;
|
||||
mimeType?: string | null;
|
||||
fileName?: string | null;
|
||||
}
|
||||
|
||||
export interface ExpoImagePickerAssetLike {
|
||||
uri: string;
|
||||
mimeType?: string | null;
|
||||
fileName?: string | null;
|
||||
file?: File | null;
|
||||
}
|
||||
|
||||
interface SupportedPickedImageFormat {
|
||||
mimeType: "image/jpeg" | "image/png";
|
||||
extension: "jpg" | "png";
|
||||
}
|
||||
|
||||
const JPEG_FORMAT: SupportedPickedImageFormat = {
|
||||
mimeType: "image/jpeg",
|
||||
extension: "jpg",
|
||||
};
|
||||
|
||||
const PNG_FORMAT: SupportedPickedImageFormat = {
|
||||
mimeType: "image/png",
|
||||
extension: "png",
|
||||
};
|
||||
|
||||
function extensionFromPath(path: string | null | undefined): string | null {
|
||||
const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
|
||||
return match?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null {
|
||||
if (extension === "jpg" || extension === "jpeg") {
|
||||
return JPEG_FORMAT;
|
||||
}
|
||||
if (extension === "png") {
|
||||
return PNG_FORMAT;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function supportedFormatForMimeType(
|
||||
mimeType: string | null | undefined,
|
||||
): SupportedPickedImageFormat | null {
|
||||
const normalizedMimeType = mimeType?.toLowerCase();
|
||||
if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") {
|
||||
return JPEG_FORMAT;
|
||||
}
|
||||
if (normalizedMimeType === "image/png") {
|
||||
return PNG_FORMAT;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickedAssetSupportedFormat(
|
||||
asset: ExpoImagePickerAssetLike,
|
||||
): SupportedPickedImageFormat | null {
|
||||
const uriExtension = extensionFromPath(asset.uri);
|
||||
if (uriExtension) {
|
||||
return supportedFormatForExtension(uriExtension);
|
||||
}
|
||||
|
||||
return (
|
||||
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
|
||||
supportedFormatForMimeType(asset.mimeType)
|
||||
);
|
||||
}
|
||||
|
||||
function replaceFileExtension(
|
||||
fileName: string | null | undefined,
|
||||
extension: SupportedPickedImageFormat["extension"],
|
||||
): string | null {
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`;
|
||||
}
|
||||
|
||||
async function exportPickedImageAsPng(uri: string): Promise<string> {
|
||||
const context = ImageManipulator.manipulate(uri);
|
||||
let image: Awaited<ReturnType<typeof context.renderAsync>> | null = null;
|
||||
|
||||
try {
|
||||
image = await context.renderAsync();
|
||||
const result = await image.saveAsync({
|
||||
format: SaveFormat.PNG,
|
||||
});
|
||||
return result.uri;
|
||||
} finally {
|
||||
image?.release();
|
||||
context.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizePickedImageAssets(
|
||||
assets: readonly ExpoImagePickerAssetLike[],
|
||||
): Promise<PickedImageAttachmentInput[]> {
|
||||
return await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
const supportedFormat = pickedAssetSupportedFormat(asset);
|
||||
if (supportedFormat) {
|
||||
return {
|
||||
source: { kind: "file_uri", uri: asset.uri },
|
||||
mimeType: supportedFormat.mimeType,
|
||||
fileName: replaceFileExtension(asset.fileName, supportedFormat.extension),
|
||||
};
|
||||
}
|
||||
|
||||
const convertedUri = await exportPickedImageAsPng(asset.uri);
|
||||
|
||||
return {
|
||||
source: { kind: "file_uri", uri: convertedUri },
|
||||
mimeType: PNG_FORMAT.mimeType,
|
||||
fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
|
||||
throw new Error("Desktop dialog API is not available on native.");
|
||||
}
|
||||
@@ -720,9 +720,16 @@ function SettingsSidebar({
|
||||
}, [hosts, localServerId]);
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
||||
const insets = useSafeAreaInsets();
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const isDesktop = layout === "desktop";
|
||||
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer,
|
||||
isDesktop ? { paddingTop: insets.top } : null,
|
||||
],
|
||||
[insets.top, isDesktop],
|
||||
);
|
||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
||||
const selectedServerId = view.kind === "host" ? view.serverId : null;
|
||||
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"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.73",
|
||||
"@getpaseo/server": "0.1.75",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
||||
@@ -70,6 +71,34 @@ function desktopSettingsWithManagement(enabled: boolean) {
|
||||
};
|
||||
}
|
||||
|
||||
type MockChildProcess = EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
pid: number;
|
||||
spawnfile: string;
|
||||
spawnargs: string[];
|
||||
unref: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createMockChildProcess(): MockChildProcess {
|
||||
const child = new EventEmitter() as MockChildProcess;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.pid = 1234;
|
||||
child.spawnfile = "node";
|
||||
child.spawnargs = ["node", "daemon.js"];
|
||||
child.unref = vi.fn();
|
||||
return child;
|
||||
}
|
||||
|
||||
function scheduleFailedStartupOutput(child: MockChildProcess): void {
|
||||
setImmediate(() => {
|
||||
child.stdout.emit("data", Buffer.from(`${"x".repeat(80_000)}stdout-tail`));
|
||||
child.stderr.emit("data", Buffer.from(`${"y".repeat(80_000)}stderr-tail`));
|
||||
child.emit("exit", 1, null);
|
||||
});
|
||||
}
|
||||
|
||||
describe("daemon-manager commands", () => {
|
||||
beforeEach(() => {
|
||||
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
|
||||
@@ -165,4 +194,61 @@ describe("daemon-manager commands", () => {
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses a reachable daemon when the PID file is stale", async () => {
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "stale_pid",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-1",
|
||||
pid: 7675,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
daemonVersion: "1.2.2",
|
||||
desktopManaged: true,
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await expect(handlers.start_desktop_daemon()).resolves.toEqual({
|
||||
serverId: "server-1",
|
||||
status: "running",
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
pid: null,
|
||||
home: "/tmp/paseo-home",
|
||||
version: "1.2.2",
|
||||
desktopManaged: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(mocks.spawnProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds captured daemon startup output", async () => {
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "stopped",
|
||||
connectedDaemon: "unreachable",
|
||||
serverId: "",
|
||||
});
|
||||
mocks.spawnProcess.mockImplementation(() => {
|
||||
const child = createMockChildProcess();
|
||||
scheduleFailedStartupOutput(child);
|
||||
return child;
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
let thrown: Error | null = null;
|
||||
try {
|
||||
await handlers.start_desktop_daemon();
|
||||
} catch (error) {
|
||||
thrown = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
const message = thrown?.message ?? "";
|
||||
expect(message).toContain("Daemon failed to start: exit code 1");
|
||||
expect(message).toContain("output truncated to the last 65536 chars");
|
||||
expect(message).toContain("stdout-tail");
|
||||
expect(message).toContain("stderr-tail");
|
||||
expect(message.length).toBeLessThan(150_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const STARTUP_POLL_INTERVAL_MS = 200;
|
||||
const STARTUP_POLL_MAX_ATTEMPTS = 150;
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||
const STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS = 64 * 1024;
|
||||
|
||||
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||
|
||||
@@ -69,6 +70,11 @@ interface DesktopPairingOffer {
|
||||
qr: string | null;
|
||||
}
|
||||
|
||||
interface StartupOutputCapture {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function parseReleaseChannel(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): AppReleaseChannel | undefined {
|
||||
@@ -145,6 +151,30 @@ function tailFile(filePath: string, lines = 50): string {
|
||||
}
|
||||
}
|
||||
|
||||
function createStartupOutputCapture(): StartupOutputCapture {
|
||||
return { text: "", truncated: false };
|
||||
}
|
||||
|
||||
function appendStartupOutput(capture: StartupOutputCapture, chunk: Buffer): StartupOutputCapture {
|
||||
const nextText = capture.text + chunk.toString();
|
||||
if (nextText.length <= STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS) {
|
||||
return { text: nextText, truncated: capture.truncated };
|
||||
}
|
||||
|
||||
return {
|
||||
text: nextText.slice(-STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS),
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
function formatStartupOutput(capture: StartupOutputCapture): string {
|
||||
if (!capture.truncated) {
|
||||
return capture.text;
|
||||
}
|
||||
|
||||
return `[output truncated to the last ${STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS} chars]\n${capture.text}`;
|
||||
}
|
||||
|
||||
function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void {
|
||||
log.info("[desktop daemon]", message, {
|
||||
pid: process.pid,
|
||||
@@ -197,17 +227,28 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
||||
unknown
|
||||
>;
|
||||
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
|
||||
const running = localDaemon === "running";
|
||||
const connectedDaemon =
|
||||
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
|
||||
const hasRunningLocalProcess = localDaemon === "running";
|
||||
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
|
||||
const apiReachable = connectedDaemon === "reachable";
|
||||
let status: DesktopDaemonState = "stopped";
|
||||
if (apiReachable || hasRunningLocalProcess) {
|
||||
status = "running";
|
||||
} else if (localDaemon === "unresponsive") {
|
||||
status = "errored";
|
||||
}
|
||||
|
||||
return {
|
||||
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
|
||||
status: running ? "running" : "stopped",
|
||||
status,
|
||||
listen: typeof payload.listen === "string" ? payload.listen : null,
|
||||
hostname: running && typeof payload.hostname === "string" ? payload.hostname : null,
|
||||
pid: running && typeof payload.pid === "number" ? payload.pid : null,
|
||||
hostname:
|
||||
status === "running" && typeof payload.hostname === "string" ? payload.hostname : null,
|
||||
pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null,
|
||||
home,
|
||||
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
|
||||
desktopManaged: payload.desktopManaged === true,
|
||||
desktopManaged: hasRunningLocalProcess && payload.desktopManaged === true,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -248,15 +289,17 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
|
||||
|
||||
function buildStartupFailureError(
|
||||
result: { code: number | null; signal: string | null; error?: Error },
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
stdout: StartupOutputCapture,
|
||||
stderr: StartupOutputCapture,
|
||||
): Error {
|
||||
const reason = result.error
|
||||
? result.error.message
|
||||
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
|
||||
const parts = [`Daemon failed to start: ${reason}`];
|
||||
if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
|
||||
if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`);
|
||||
const formattedStderr = formatStartupOutput(stderr).trim();
|
||||
const formattedStdout = formatStartupOutput(stdout).trim();
|
||||
if (formattedStderr) parts.push(`stderr:\n${formattedStderr}`);
|
||||
if (formattedStdout) parts.push(`stdout:\n${formattedStdout}`);
|
||||
const logs = tailFile(logFilePath(), 15);
|
||||
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
|
||||
return new Error(parts.join("\n\n"));
|
||||
@@ -337,13 +380,13 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdout = createStartupOutputCapture();
|
||||
let stderr = createStartupOutputCapture();
|
||||
child.stdout!.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
stdout = appendStartupOutput(stdout, data);
|
||||
});
|
||||
child.stderr!.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
stderr = appendStartupOutput(stderr, data);
|
||||
});
|
||||
|
||||
logDesktopDaemonLifecycle("detached spawn returned", {
|
||||
@@ -381,8 +424,8 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
logDesktopDaemonLifecycle("detached startup grace period completed", {
|
||||
childPid: child.pid ?? null,
|
||||
exitedEarly: result.exitedEarly,
|
||||
stdout: stdout.slice(0, 2000),
|
||||
stderr: stderr.slice(0, 2000),
|
||||
stdout: formatStartupOutput(stdout).slice(0, 2000),
|
||||
stderr: formatStartupOutput(stderr).slice(0, 2000),
|
||||
...(result.exitedEarly
|
||||
? {
|
||||
exitCode: result.code,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -58,8 +58,8 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/highlight": "0.1.73",
|
||||
"@getpaseo/relay": "0.1.73",
|
||||
"@getpaseo/highlight": "0.1.75",
|
||||
"@getpaseo/relay": "0.1.75",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
|
||||
@@ -10,12 +10,9 @@ import {
|
||||
import { resolvePaseoHome } from "../src/server/paseo-home.js";
|
||||
import { loadPersistedConfig } from "../src/server/persisted-config.js";
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
|
||||
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
|
||||
|
||||
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const DEFAULT_LOG_ROTATE_SIZE = "10m";
|
||||
const DEFAULT_LOG_ROTATE_MAX_FILES = 2;
|
||||
|
||||
interface DaemonRunnerConfig {
|
||||
devMode: boolean;
|
||||
workerArgs: string[];
|
||||
@@ -77,28 +74,6 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
|
||||
return existsSync(runnerPath) ? runnerPath : null;
|
||||
}
|
||||
|
||||
function resolveSupervisorLogFile(
|
||||
paseoHome: string,
|
||||
persistedConfig: ReturnType<typeof loadPersistedConfig>,
|
||||
) {
|
||||
const configuredFile = persistedConfig.log?.file;
|
||||
const configuredPath = configuredFile?.path;
|
||||
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
|
||||
if (configuredPath) {
|
||||
logPath = path.isAbsolute(configuredPath)
|
||||
? configuredPath
|
||||
: path.resolve(paseoHome, configuredPath);
|
||||
}
|
||||
|
||||
return {
|
||||
path: logPath,
|
||||
rotate: {
|
||||
maxSize: configuredFile?.rotate?.maxSize ?? DEFAULT_LOG_ROTATE_SIZE,
|
||||
maxFiles: configuredFile?.rotate?.maxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = parseConfig(process.argv.slice(2));
|
||||
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
|
||||
@@ -113,7 +88,7 @@ async function main(): Promise<void> {
|
||||
|
||||
const paseoHome = resolvePaseoHome(workerEnv);
|
||||
const persistedConfig = loadPersistedConfig(paseoHome);
|
||||
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
|
||||
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv);
|
||||
|
||||
try {
|
||||
await acquirePidLock(paseoHome, null, {
|
||||
|
||||
42
packages/server/scripts/supervisor-log-config.ts
Normal file
42
packages/server/scripts/supervisor-log-config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { loadPersistedConfig } from "../src/server/persisted-config.js";
|
||||
|
||||
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const DEFAULT_LOG_ROTATE_SIZE = "10m";
|
||||
const DEFAULT_LOG_ROTATE_MAX_FILES = 3;
|
||||
|
||||
export function resolveSupervisorLogFile(
|
||||
paseoHome: string,
|
||||
persistedConfig: ReturnType<typeof loadPersistedConfig>,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
const configuredFile = persistedConfig.log?.file;
|
||||
const configuredPath = configuredFile?.path;
|
||||
const envRotateSize = env.PASEO_LOG_ROTATE_SIZE?.trim();
|
||||
const envRotateMaxFiles = parseOptionalPositiveInteger(env.PASEO_LOG_ROTATE_COUNT);
|
||||
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
|
||||
if (configuredPath) {
|
||||
logPath = path.isAbsolute(configuredPath)
|
||||
? configuredPath
|
||||
: path.resolve(paseoHome, configuredPath);
|
||||
}
|
||||
|
||||
return {
|
||||
path: logPath,
|
||||
rotate: {
|
||||
maxSize: configuredFile?.rotate?.maxSize ?? envRotateSize ?? DEFAULT_LOG_ROTATE_SIZE,
|
||||
maxFiles:
|
||||
configuredFile?.rotate?.maxFiles ?? envRotateMaxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInteger(value: string | undefined): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
|
||||
|
||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
||||
@@ -87,6 +88,57 @@ async function runSupervisorFixture(options: {
|
||||
}
|
||||
|
||||
describe("supervisor durable logging", () => {
|
||||
test("resolves rotation defaults", () => {
|
||||
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
|
||||
const logFile = resolveSupervisorLogFile(paseoHome, {}, {});
|
||||
|
||||
expect(logFile).toEqual({
|
||||
path: path.join(paseoHome, "daemon.log"),
|
||||
rotate: { maxSize: "10m", maxFiles: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
test("lets persisted rotation override env rotation defaults", () => {
|
||||
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
|
||||
const logFile = resolveSupervisorLogFile(
|
||||
paseoHome,
|
||||
{
|
||||
log: {
|
||||
file: {
|
||||
path: "logs/daemon.log",
|
||||
rotate: { maxSize: "25m", maxFiles: 4 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
PASEO_LOG_ROTATE_SIZE: "200m",
|
||||
PASEO_LOG_ROTATE_COUNT: "12",
|
||||
},
|
||||
);
|
||||
|
||||
expect(logFile).toEqual({
|
||||
path: path.resolve(paseoHome, "logs", "daemon.log"),
|
||||
rotate: { maxSize: "25m", maxFiles: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
test("uses env rotation when persisted rotation is absent", () => {
|
||||
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
|
||||
const logFile = resolveSupervisorLogFile(
|
||||
paseoHome,
|
||||
{},
|
||||
{
|
||||
PASEO_LOG_ROTATE_SIZE: "50m",
|
||||
PASEO_LOG_ROTATE_COUNT: "8",
|
||||
},
|
||||
);
|
||||
|
||||
expect(logFile).toEqual({
|
||||
path: path.join(paseoHome, "daemon.log"),
|
||||
rotate: { maxSize: "50m", maxFiles: 8 },
|
||||
});
|
||||
});
|
||||
|
||||
test("writes supervised worker stdout and stderr to daemon.log", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
workerSource: `
|
||||
|
||||
@@ -716,6 +716,7 @@ test("createAgent passes daemon launch env through the provider launch context",
|
||||
modeId: "auto",
|
||||
});
|
||||
expect(client.lastLaunchContext).toEqual({
|
||||
agentId: snapshot.id,
|
||||
env: {
|
||||
PASEO_AGENT_ID: snapshot.id,
|
||||
},
|
||||
@@ -1179,6 +1180,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
|
||||
},
|
||||
});
|
||||
expect(client.lastResumeLaunchContext).toEqual({
|
||||
agentId: resumed.id,
|
||||
env: {
|
||||
PASEO_AGENT_ID: resumed.id,
|
||||
},
|
||||
@@ -1283,6 +1285,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
|
||||
});
|
||||
|
||||
expect(client.lastCreateLaunchContext).toEqual({
|
||||
agentId: snapshot.id,
|
||||
env: {
|
||||
PASEO_AGENT_ID: snapshot.id,
|
||||
},
|
||||
@@ -1293,6 +1296,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
|
||||
});
|
||||
|
||||
expect(client.lastResumeLaunchContext).toEqual({
|
||||
agentId: snapshot.id,
|
||||
env: {
|
||||
PASEO_AGENT_ID: snapshot.id,
|
||||
},
|
||||
|
||||
@@ -10,30 +10,31 @@ import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentSlashCommand,
|
||||
AgentMode,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPermissionResult,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentProvider,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
AgentRuntimeInfo,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentSlashCommand,
|
||||
type AgentMode,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPermissionResult,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentProvider,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type ListPersistedAgentsOptions,
|
||||
type PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||
@@ -1005,18 +1006,28 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId: agent.activeForegroundTurnId ?? undefined,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
pendingPermissions: agent.pendingPermissions.size,
|
||||
},
|
||||
"closeAgent: start",
|
||||
"agent.manager.close.start",
|
||||
);
|
||||
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
|
||||
await agent.session.close();
|
||||
this.timelineStore.delete(agentId);
|
||||
await this.persistSnapshot(closedAgent);
|
||||
this.emitClosedAgent(closedAgent, { persist: false });
|
||||
this.logger.trace({ agentId }, "closeAgent: completed");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: closedAgent.provider,
|
||||
sessionId: closedAgent.persistence?.sessionId ?? undefined,
|
||||
},
|
||||
"agent.manager.close.complete",
|
||||
);
|
||||
}
|
||||
|
||||
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
||||
@@ -1470,22 +1481,28 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: existingAgent.provider,
|
||||
sessionId: existingAgent.persistence?.sessionId ?? undefined,
|
||||
turnId: existingAgent.activeForegroundTurnId ?? undefined,
|
||||
lifecycle: existingAgent.lifecycle,
|
||||
activeForegroundTurnId: existingAgent.activeForegroundTurnId,
|
||||
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
|
||||
promptType: typeof prompt === "string" ? "string" : "structured",
|
||||
hasRunOptions: Boolean(options),
|
||||
},
|
||||
"streamAgent: requested",
|
||||
"agent.manager.stream.request",
|
||||
);
|
||||
if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: existingAgent.provider,
|
||||
sessionId: existingAgent.persistence?.sessionId ?? undefined,
|
||||
turnId: existingAgent.activeForegroundTurnId ?? undefined,
|
||||
lifecycle: existingAgent.lifecycle,
|
||||
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
|
||||
},
|
||||
"streamAgent: rejected because a foreground run is already in flight",
|
||||
"agent.manager.stream.reject",
|
||||
);
|
||||
throw new Error(`Agent ${agentId} already has an active run`);
|
||||
}
|
||||
@@ -1522,10 +1539,13 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
},
|
||||
"streamAgent: started",
|
||||
"agent.manager.stream.start",
|
||||
);
|
||||
|
||||
turnStream = this.foregroundRuns.createTurnStream(turnId);
|
||||
@@ -1577,11 +1597,14 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
sessionId: mutableAgent.persistence?.sessionId ?? undefined,
|
||||
turnId,
|
||||
lifecycle: mutableAgent.lifecycle,
|
||||
terminalError,
|
||||
pendingReplacement: mutableAgent.pendingReplacement,
|
||||
},
|
||||
"finalizeForegroundTurn: applying terminal state",
|
||||
"agent.manager.finalize",
|
||||
);
|
||||
if (!shouldHoldBusyForReplacement) {
|
||||
this.touchUpdatedAt(mutableAgent);
|
||||
@@ -2371,6 +2394,16 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private enqueueSessionEvent(agentId: string, event: AgentStreamEvent): void {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: event.provider,
|
||||
sessionId: this.agents.get(agentId)?.persistence?.sessionId ?? undefined,
|
||||
turnId: getAgentStreamEventTurnId(event),
|
||||
event,
|
||||
},
|
||||
"agent.manager.enqueue",
|
||||
);
|
||||
const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
@@ -2382,6 +2415,16 @@ export class AgentManager {
|
||||
if (current.session == null) {
|
||||
return;
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: event.provider,
|
||||
sessionId: current.persistence?.sessionId ?? undefined,
|
||||
turnId: getAgentStreamEventTurnId(event),
|
||||
event,
|
||||
},
|
||||
"agent.manager.dequeue",
|
||||
);
|
||||
await this.dispatchSessionEvent(current, event);
|
||||
return;
|
||||
})
|
||||
@@ -2405,8 +2448,19 @@ export class AgentManager {
|
||||
agent: ActiveManagedAgent,
|
||||
event: AgentStreamEvent,
|
||||
): Promise<void> {
|
||||
const turnId = (event as { turnId?: string }).turnId;
|
||||
const turnId = getAgentStreamEventTurnId(event);
|
||||
const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId);
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: event.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId,
|
||||
matchingWaiterCount: matchingWaiters.length,
|
||||
event,
|
||||
},
|
||||
"agent.manager.dispatch_session_event",
|
||||
);
|
||||
|
||||
const shouldNotifyWaiters = await this.handleStreamEvent(agent, event);
|
||||
|
||||
@@ -2417,6 +2471,18 @@ export class AgentManager {
|
||||
this.foregroundRuns.notifyWaiters(matchingWaiters, event, {
|
||||
terminal: isTurnTerminalEvent(event),
|
||||
});
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: event.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId,
|
||||
notifiedWaiterCount: matchingWaiters.length,
|
||||
terminal: isTurnTerminalEvent(event),
|
||||
event,
|
||||
},
|
||||
"agent.manager.notify_waiters",
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveInitialPersistedTitle(
|
||||
@@ -2529,7 +2595,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
|
||||
const turnId = (event as { turnId?: string }).turnId;
|
||||
const turnId = getAgentStreamEventTurnId(event);
|
||||
if (turnId == null) {
|
||||
return;
|
||||
}
|
||||
@@ -2540,6 +2606,16 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
this.foregroundRuns.notifyAgentWaiters(agent, event);
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: event.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId,
|
||||
event,
|
||||
},
|
||||
"agent.manager.notify_waiters.coalesced",
|
||||
);
|
||||
}
|
||||
|
||||
private async handleStreamEvent(
|
||||
@@ -2547,8 +2623,9 @@ export class AgentManager {
|
||||
event: AgentStreamEvent,
|
||||
options?: HandleStreamEventOptions,
|
||||
): Promise<boolean> {
|
||||
const eventTurnId = (event as { turnId?: string }).turnId;
|
||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
||||
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
|
||||
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
|
||||
if (
|
||||
eventTurnId &&
|
||||
isTurnTerminalEvent(event) &&
|
||||
@@ -2561,6 +2638,7 @@ export class AgentManager {
|
||||
if (!options?.fromHistory) {
|
||||
this.touchUpdatedAt(agent);
|
||||
if (this.agentStreamCoalescer.handle(agent.id, event)) {
|
||||
this.traceCoalescerBuffered(agent, event, eventTurnId);
|
||||
return false;
|
||||
}
|
||||
this.agentStreamCoalescer.flushFor(agent.id);
|
||||
@@ -2588,9 +2666,71 @@ export class AgentManager {
|
||||
this.dispatchStream(agent.id, event);
|
||||
}
|
||||
|
||||
this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
|
||||
|
||||
return flags.shouldNotifyWaiters;
|
||||
}
|
||||
|
||||
private traceHandleStreamEventStart(
|
||||
agent: ActiveManagedAgent,
|
||||
event: AgentStreamEvent,
|
||||
turnId: string | undefined,
|
||||
isForegroundEvent: boolean,
|
||||
): void {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: event.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
isForegroundEvent,
|
||||
event,
|
||||
},
|
||||
"agent.manager.handle_stream_event.start",
|
||||
);
|
||||
}
|
||||
|
||||
private traceCoalescerBuffered(
|
||||
agent: ActiveManagedAgent,
|
||||
event: AgentStreamEvent,
|
||||
turnId: string | undefined,
|
||||
): void {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: event.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId,
|
||||
event,
|
||||
},
|
||||
"agent.manager.coalescer.buffer",
|
||||
);
|
||||
}
|
||||
|
||||
private traceHandleStreamEventEnd(
|
||||
agent: ActiveManagedAgent,
|
||||
event: AgentStreamEvent,
|
||||
turnId: string | undefined,
|
||||
flags: StreamEventFlags,
|
||||
): void {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: event.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
shouldDispatchEvent: flags.shouldDispatchEvent,
|
||||
shouldNotifyWaiters: flags.shouldNotifyWaiters,
|
||||
event,
|
||||
},
|
||||
"agent.manager.handle_stream_event.end",
|
||||
);
|
||||
}
|
||||
|
||||
private dispatchStreamEventByType(params: {
|
||||
agent: ActiveManagedAgent;
|
||||
event: AgentStreamEvent;
|
||||
@@ -2747,11 +2887,13 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId: eventTurnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
eventTurnId,
|
||||
},
|
||||
"handleStreamEvent: turn_completed",
|
||||
"agent.manager.turn.completed",
|
||||
);
|
||||
agent.lastUsage = event.usage;
|
||||
agent.lastError = undefined;
|
||||
@@ -2778,6 +2920,9 @@ export class AgentManager {
|
||||
this.logger.warn(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId: eventTurnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
eventTurnId,
|
||||
@@ -2818,11 +2963,14 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId: eventTurnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
eventTurnId,
|
||||
},
|
||||
"handleStreamEvent: turn_canceled",
|
||||
"agent.manager.turn.canceled",
|
||||
);
|
||||
if (!isForegroundEvent && !agent.pendingReplacement) {
|
||||
agent.lifecycle = "idle";
|
||||
@@ -2843,11 +2991,13 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId: eventTurnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
eventTurnId,
|
||||
},
|
||||
"handleStreamEvent: turn_started",
|
||||
"agent.manager.turn.started",
|
||||
);
|
||||
if (!isForegroundEvent) {
|
||||
agent.lifecycle = "running";
|
||||
@@ -2993,6 +3143,20 @@ export class AgentManager {
|
||||
|
||||
this.syncFeaturesFromSession(agent);
|
||||
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId: agent.activeForegroundTurnId ?? undefined,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
pendingPermissions: agent.pendingPermissions.size,
|
||||
persist: options?.persist !== false,
|
||||
},
|
||||
"agent.manager.emit_state",
|
||||
);
|
||||
|
||||
this.dispatch({
|
||||
type: "agent_state",
|
||||
agent: { ...agent },
|
||||
@@ -3120,6 +3284,18 @@ export class AgentManager {
|
||||
event: AgentStreamEvent,
|
||||
metadata?: { seq?: number; epoch?: string },
|
||||
): void {
|
||||
const agent = this.agents.get(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: event.provider,
|
||||
sessionId: agent?.persistence?.sessionId ?? undefined,
|
||||
turnId: getAgentStreamEventTurnId(event),
|
||||
metadata,
|
||||
event,
|
||||
},
|
||||
"agent.manager.dispatch_stream",
|
||||
);
|
||||
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
|
||||
}
|
||||
|
||||
@@ -3215,6 +3391,7 @@ export class AgentManager {
|
||||
|
||||
private buildLaunchContext(agentId: string): AgentLaunchContext {
|
||||
return {
|
||||
agentId,
|
||||
env: {
|
||||
PASEO_AGENT_ID: agentId,
|
||||
},
|
||||
|
||||
@@ -17,6 +17,19 @@ export function startAgentRun(
|
||||
logger: Logger,
|
||||
options?: StartAgentRunOptions,
|
||||
): { outOfBand: boolean } {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: snapshot?.provider,
|
||||
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
||||
turnId: snapshot?.activeForegroundTurnId ?? undefined,
|
||||
promptType: typeof prompt === "string" ? "string" : "structured",
|
||||
hasRunOptions: Boolean(options?.runOptions),
|
||||
replaceRunning: Boolean(options?.replaceRunning),
|
||||
},
|
||||
"agent.session.start_stream.request",
|
||||
);
|
||||
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
|
||||
// in-flight turn — replaceAgentRun would interrupt the running turn. The
|
||||
// intercept lives at this layer so it covers every prompt entrypoint.
|
||||
@@ -28,12 +41,38 @@ export function startAgentRun(
|
||||
const iterator = shouldReplace
|
||||
? agentManager.replaceAgentRun(agentId, prompt, runOptions)
|
||||
: agentManager.streamAgent(agentId, prompt, runOptions);
|
||||
logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: snapshot?.provider,
|
||||
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
||||
shouldReplace,
|
||||
},
|
||||
"agent.session.start_stream.iterator_returned",
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const _ of iterator) {
|
||||
// Events are broadcast via AgentManager subscribers.
|
||||
}
|
||||
logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: snapshot?.provider,
|
||||
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
||||
},
|
||||
"agent.session.iterator.drained",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: snapshot?.provider,
|
||||
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
||||
err: error,
|
||||
},
|
||||
"agent.session.iterator.error",
|
||||
);
|
||||
logger.error({ err: error, agentId }, "Agent stream failed");
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -358,6 +358,10 @@ export type AgentStreamEvent =
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
|
||||
return "turnId" in event ? event.turnId : undefined;
|
||||
}
|
||||
|
||||
export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
|
||||
|
||||
export type AgentPermissionUpdate = AgentMetadata;
|
||||
@@ -476,6 +480,7 @@ export interface AgentSessionConfig {
|
||||
}
|
||||
|
||||
export interface AgentLaunchContext {
|
||||
agentId?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
import { getAgentStreamEventTurnId, type AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
|
||||
export interface ForegroundTurnWaiter {
|
||||
turnId: string;
|
||||
@@ -105,7 +105,7 @@ export class ForegroundRunState {
|
||||
event: AgentStreamEvent,
|
||||
options?: { terminal?: boolean },
|
||||
): void {
|
||||
const waiters = this.getMatchingWaiters(agent, eventTurnId(event));
|
||||
const waiters = this.getMatchingWaiters(agent, getAgentStreamEventTurnId(event));
|
||||
this.notifyWaiters(waiters, event, { terminal: options?.terminal ?? false });
|
||||
}
|
||||
|
||||
@@ -226,7 +226,3 @@ function settlePendingForegroundRun(pendingRun: PendingForegroundRun): void {
|
||||
pendingRun.settled = true;
|
||||
pendingRun.resolveSettled();
|
||||
}
|
||||
|
||||
function eventTurnId(event: AgentStreamEvent): string | undefined {
|
||||
return (event as { turnId?: string }).turnId;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AgentModeVisuals {
|
||||
|
||||
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
|
||||
AgentModeVisuals & {
|
||||
// Marks the provider's most-permissioned no-prompt mode. Selecting it means tools run without approval; the runtime mechanism is provider-specific.
|
||||
isUnattended?: boolean;
|
||||
};
|
||||
|
||||
@@ -96,9 +97,9 @@ const COPILOT_MODES: AgentProviderModeDefinition[] = [
|
||||
colorTier: "planning",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
label: "Autopilot",
|
||||
description: "Autonomous mode that runs until task completion without user interaction",
|
||||
id: "allow-all",
|
||||
label: "Allow All",
|
||||
description: "Automatically approves all Copilot tool, path, and URL requests.",
|
||||
icon: "ShieldOff",
|
||||
colorTier: "dangerous",
|
||||
isUnattended: true,
|
||||
|
||||
@@ -67,10 +67,21 @@ export interface BuildProviderRegistryOptions {
|
||||
isDev?: boolean;
|
||||
}
|
||||
|
||||
interface ProviderClientFactoryOptions extends Pick<
|
||||
BuildProviderRegistryOptions,
|
||||
"workspaceGitService"
|
||||
> {
|
||||
customProvider?: {
|
||||
id: string;
|
||||
label: string;
|
||||
extends: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ProviderClientFactory = (
|
||||
logger: Logger,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
options?: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
|
||||
options?: ProviderClientFactoryOptions,
|
||||
) => AgentClient;
|
||||
|
||||
interface ResolvedProvider {
|
||||
@@ -92,6 +103,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
codex: (logger, runtimeSettings, options) =>
|
||||
new CodexAppServerAgentClient(logger, runtimeSettings, {
|
||||
workspaceGitService: options?.workspaceGitService,
|
||||
customProvider: options?.customProvider,
|
||||
}),
|
||||
copilot: (logger, runtimeSettings) =>
|
||||
new CopilotACPAgentClient({
|
||||
@@ -510,10 +522,11 @@ function addDerivedProviders(
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseProvider = resolvedProviders.get(override.extends);
|
||||
const baseProviderId = override.extends;
|
||||
const baseProvider = resolvedProviders.get(baseProviderId);
|
||||
if (!baseProvider) {
|
||||
throw new Error(
|
||||
`Custom provider '${providerId}' extends unknown provider '${override.extends}'`,
|
||||
`Custom provider '${providerId}' extends unknown provider '${baseProviderId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -522,7 +535,7 @@ function addDerivedProviders(
|
||||
toRuntimeSettings(override),
|
||||
);
|
||||
const baseDefinition = baseProvider.definition;
|
||||
const baseFactory = getProviderClientFactory(override.extends);
|
||||
const baseFactory = getProviderClientFactory(baseProviderId);
|
||||
|
||||
resolvedProviders.set(providerId, {
|
||||
definition: createDerivedDefinition(providerId, baseDefinition, override),
|
||||
@@ -530,8 +543,15 @@ function addDerivedProviders(
|
||||
profileModels: override.models ?? [],
|
||||
additionalModels: override.additionalModels ?? [],
|
||||
enabled: override.enabled !== false,
|
||||
derivedFromProviderId: override.extends,
|
||||
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
|
||||
derivedFromProviderId: baseProviderId,
|
||||
createBaseClient: (logger) =>
|
||||
baseFactory(logger, mergedRuntimeSettings, {
|
||||
customProvider: {
|
||||
id: providerId,
|
||||
label: override.label ?? providerId,
|
||||
extends: baseProviderId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ import {
|
||||
resolveACPModeSelection,
|
||||
resolveACPModelSelection,
|
||||
} from "./acp-agent.js";
|
||||
import {
|
||||
COPILOT_ALLOW_ALL_MODE_ID,
|
||||
COPILOT_MODES,
|
||||
beforeCopilotModeWriter,
|
||||
transformCopilotConfigOptions,
|
||||
transformCopilotModeId,
|
||||
transformCopilotSessionResponse,
|
||||
writeCopilotProviderMode,
|
||||
} from "./copilot-acp-agent.js";
|
||||
import { transformPiModels } from "./pi-direct-agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
@@ -140,6 +149,73 @@ function selectConfigOption(
|
||||
};
|
||||
}
|
||||
|
||||
function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
modeId: modeId ?? undefined,
|
||||
},
|
||||
{
|
||||
provider: "copilot",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["copilot", "--acp"],
|
||||
defaultModes: COPILOT_MODES,
|
||||
sessionResponseTransformer: transformCopilotSessionResponse,
|
||||
configOptionsTransformer: transformCopilotConfigOptions,
|
||||
modeIdTransformer: transformCopilotModeId,
|
||||
providerModeWriter: writeCopilotProviderMode,
|
||||
beforeModeWriter: beforeCopilotModeWriter,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function copilotModeConfigOption(currentValue: string): SessionConfigOption {
|
||||
return {
|
||||
id: "mode",
|
||||
name: "Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue,
|
||||
options: [
|
||||
{
|
||||
value: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
name: "Agent",
|
||||
},
|
||||
{
|
||||
value: "https://agentclientprotocol.com/protocol/session-modes#plan",
|
||||
name: "Plan",
|
||||
},
|
||||
{
|
||||
value: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
name: "Autopilot",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function copilotAllowAllConfigOption(currentValue: "on" | "off"): SessionConfigOption {
|
||||
return {
|
||||
id: "allow_all",
|
||||
name: "Allow All",
|
||||
category: "permissions",
|
||||
type: "select",
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: "on", name: "On" },
|
||||
{ value: "off", name: "Off" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string {
|
||||
if (category === "mode") {
|
||||
return "Mode";
|
||||
@@ -190,7 +266,7 @@ function prepareConfiguredOverrideSession(
|
||||
|
||||
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
|
||||
const logger = createTestLogger();
|
||||
const childLogger = { warn: vi.fn() };
|
||||
const childLogger = { trace: vi.fn(), warn: vi.fn() };
|
||||
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
||||
const session = createSessionWithConfig({}, logger);
|
||||
const setSessionConfigOption = vi.fn(async () => ({
|
||||
@@ -577,7 +653,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
expect(await validSession.getCurrentMode()).toBe("default");
|
||||
|
||||
const logger = createTestLogger();
|
||||
const childLogger = { warn: vi.fn() };
|
||||
const childLogger = { trace: vi.fn(), warn: vi.fn() };
|
||||
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
||||
const invalidSession = createSessionWithConfig(
|
||||
{ modeId: "acceptEdits", model: "opus" },
|
||||
@@ -796,12 +872,10 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("passes Copilot Autopilot ACP permission requests through to the user", async () => {
|
||||
// Zed parity: Copilot Autopilot no longer auto-approves ACP permission requests,
|
||||
// so the accepted UX regression is that every tool request reaches the user UI.
|
||||
test("passes generic ACP permission requests through to the user", async () => {
|
||||
const session = createSessionWithConfig({
|
||||
provider: "copilot",
|
||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
provider: "cursor-acp",
|
||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
});
|
||||
const events: Array<{ type: string; request?: { id: string } }> = [];
|
||||
const permissionOptions: PermissionOption[] = [
|
||||
@@ -835,6 +909,126 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
outcome: { outcome: "selected", optionId: "allow-once" },
|
||||
});
|
||||
});
|
||||
|
||||
test("maps Copilot Allow All mode to allow_all ACP config on session start", async () => {
|
||||
const setSessionConfigOption = vi.fn(async () => ({
|
||||
configOptions: [
|
||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
||||
copilotAllowAllConfigOption("on"),
|
||||
],
|
||||
}));
|
||||
const setSessionMode = vi.fn(async () => undefined);
|
||||
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
|
||||
const { internals } = prepareConfiguredOverrideSession(session, {
|
||||
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
availableModes: COPILOT_MODES,
|
||||
configOptions: [
|
||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
||||
copilotAllowAllConfigOption("off"),
|
||||
],
|
||||
connection: { setSessionConfigOption, setSessionMode },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
||||
await internals.applyConfiguredOverrides();
|
||||
unsubscribe();
|
||||
|
||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
configId: "allow_all",
|
||||
value: "on",
|
||||
});
|
||||
expect(setSessionMode).not.toHaveBeenCalled();
|
||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
||||
expect(events.some((event) => event.type === "permission_requested")).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts Copilot's legacy autopilot mode ID as Allow All", async () => {
|
||||
const setSessionConfigOption = vi.fn(async () => ({
|
||||
configOptions: [
|
||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
||||
copilotAllowAllConfigOption("on"),
|
||||
],
|
||||
}));
|
||||
const setSessionMode = vi.fn(async () => undefined);
|
||||
const session = createCopilotSessionWithConfig();
|
||||
prepareConfiguredOverrideSession(session, {
|
||||
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
availableModes: COPILOT_MODES,
|
||||
configOptions: [
|
||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
||||
copilotAllowAllConfigOption("off"),
|
||||
],
|
||||
connection: { setSessionConfigOption, setSessionMode },
|
||||
});
|
||||
|
||||
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#autopilot");
|
||||
|
||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
configId: "allow_all",
|
||||
value: "on",
|
||||
});
|
||||
expect(setSessionMode).not.toHaveBeenCalled();
|
||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
||||
});
|
||||
|
||||
test("switching Copilot away from Allow All turns allow_all off before setting the ACP mode", async () => {
|
||||
const setSessionConfigOption = vi.fn(async (input: { value: string }) => ({
|
||||
configOptions: [
|
||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
||||
copilotAllowAllConfigOption(input.value === "on" ? "on" : "off"),
|
||||
],
|
||||
}));
|
||||
const setSessionMode = vi.fn(async () => undefined);
|
||||
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
|
||||
prepareConfiguredOverrideSession(session, {
|
||||
currentMode: COPILOT_ALLOW_ALL_MODE_ID,
|
||||
availableModes: COPILOT_MODES,
|
||||
configOptions: [
|
||||
copilotModeConfigOption(COPILOT_ALLOW_ALL_MODE_ID),
|
||||
copilotAllowAllConfigOption("on"),
|
||||
],
|
||||
connection: { setSessionConfigOption, setSessionMode },
|
||||
});
|
||||
|
||||
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#agent");
|
||||
|
||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
configId: "allow_all",
|
||||
value: "off",
|
||||
});
|
||||
expect(setSessionMode).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
});
|
||||
});
|
||||
|
||||
test("trusts Copilot allow_all config updates as the current mode source", async () => {
|
||||
const session = createCopilotSessionWithConfig();
|
||||
const internals = asInternals<ACPSessionInternals>(session);
|
||||
|
||||
const events = internals.translateSessionUpdate({
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
||||
copilotAllowAllConfigOption("on"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(events).toMatchObject([
|
||||
{
|
||||
type: "mode_changed",
|
||||
provider: "copilot",
|
||||
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
|
||||
availableModes: expect.arrayContaining([
|
||||
expect.objectContaining({ id: COPILOT_ALLOW_ALL_MODE_ID, label: "Allow All" }),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveModelDefinitionsFromACP", () => {
|
||||
|
||||
@@ -54,35 +54,36 @@ import {
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionRequestKind,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptContentBlock,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListModesOptions,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
ToolCallDetail,
|
||||
ToolCallTimelineItem,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionRequestKind,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptContentBlock,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type ListModesOptions,
|
||||
type ListModelsOptions,
|
||||
type ListPersistedAgentsOptions,
|
||||
type McpServerConfig,
|
||||
type PersistedAgentDescriptor,
|
||||
type ToolCallDetail,
|
||||
type ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
createProviderEnvSpec,
|
||||
@@ -226,7 +227,13 @@ interface ACPAgentClientOptions {
|
||||
defaultModes?: AgentMode[];
|
||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||
modeIdTransformer?: (modeId: string) => string | null;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
providerModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPProviderModeWriteResult>;
|
||||
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
|
||||
thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
@@ -245,7 +252,13 @@ interface ACPAgentSessionOptions {
|
||||
defaultModes: AgentMode[];
|
||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||
modeIdTransformer?: (modeId: string) => string | null;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
providerModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPProviderModeWriteResult>;
|
||||
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
|
||||
thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
@@ -253,6 +266,7 @@ interface ACPAgentSessionOptions {
|
||||
) => Promise<void>;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
handle?: AgentPersistenceHandle;
|
||||
agentId?: string;
|
||||
launchEnv?: Record<string, string>;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
@@ -337,6 +351,26 @@ interface ACPModelSelection {
|
||||
hasAvailableModels: boolean;
|
||||
}
|
||||
|
||||
export interface ACPProviderModeWriterContext {
|
||||
connection: ClientSideConnection;
|
||||
sessionId: string;
|
||||
requestedModeId: string;
|
||||
currentModeId: string | null;
|
||||
selection: ACPModeSelection;
|
||||
configOptions: SessionConfigOption[];
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface ACPProviderModeWriteResult {
|
||||
handled: boolean;
|
||||
currentModeId?: string;
|
||||
configOptions?: SessionConfigOption[];
|
||||
}
|
||||
|
||||
export interface ACPBeforeModeWriteResult {
|
||||
configOptions?: SessionConfigOption[];
|
||||
}
|
||||
|
||||
export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined {
|
||||
if (!usage) {
|
||||
return undefined;
|
||||
@@ -465,7 +499,17 @@ export class ACPAgentClient implements AgentClient {
|
||||
private readonly sessionResponseTransformer?: (
|
||||
response: SessionStateResponse,
|
||||
) => SessionStateResponse;
|
||||
private readonly configOptionsTransformer?: (
|
||||
configOptions: SessionConfigOption[],
|
||||
) => SessionConfigOption[];
|
||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
private readonly providerModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPProviderModeWriteResult>;
|
||||
private readonly beforeModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPBeforeModeWriteResult>;
|
||||
private readonly thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
@@ -477,13 +521,20 @@ export class ACPAgentClient implements AgentClient {
|
||||
constructor(options: ACPAgentClientOptions) {
|
||||
this.provider = options.provider;
|
||||
this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES;
|
||||
this.logger = options.logger.child({ module: "agent", provider: options.provider });
|
||||
this.logger = options.logger.child({
|
||||
module: "agent",
|
||||
provider: options.provider,
|
||||
});
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.defaultCommand = options.defaultCommand;
|
||||
this.defaultModes = options.defaultModes ?? [];
|
||||
this.modelTransformer = options.modelTransformer;
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||
this.modeIdTransformer = options.modeIdTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.providerModeWriter = options.providerModeWriter;
|
||||
this.beforeModeWriter = options.beforeModeWriter;
|
||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
||||
@@ -504,9 +555,14 @@ export class ACPAgentClient implements AgentClient {
|
||||
defaultModes: this.defaultModes,
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
beforeModeWriter: this.beforeModeWriter,
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
@@ -545,10 +601,15 @@ export class ACPAgentClient implements AgentClient {
|
||||
defaultModes: this.defaultModes,
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
beforeModeWriter: this.beforeModeWriter,
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
handle,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
@@ -747,7 +808,16 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse {
|
||||
return this.sessionResponseTransformer ? this.sessionResponseTransformer(response) : response;
|
||||
const transformed = this.sessionResponseTransformer
|
||||
? this.sessionResponseTransformer(response)
|
||||
: response;
|
||||
if (!this.configOptionsTransformer || !transformed.configOptions) {
|
||||
return transformed;
|
||||
}
|
||||
return {
|
||||
...transformed,
|
||||
configOptions: this.configOptionsTransformer(transformed.configOptions),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,12 +833,23 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private readonly sessionResponseTransformer?: (
|
||||
response: SessionStateResponse,
|
||||
) => SessionStateResponse;
|
||||
private readonly configOptionsTransformer?: (
|
||||
configOptions: SessionConfigOption[],
|
||||
) => SessionConfigOption[];
|
||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
private readonly providerModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPProviderModeWriteResult>;
|
||||
private readonly beforeModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPBeforeModeWriteResult>;
|
||||
private readonly thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
private readonly agentId?: string;
|
||||
private readonly launchEnv?: Record<string, string>;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private readonly pendingPermissions = new Map<string, PendingPermission>();
|
||||
@@ -814,9 +895,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.defaultModes = options.defaultModes;
|
||||
this.modelTransformer = options.modelTransformer;
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||
this.modeIdTransformer = options.modeIdTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.providerModeWriter = options.providerModeWriter;
|
||||
this.beforeModeWriter = options.beforeModeWriter;
|
||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||
this.availableModes = options.defaultModes;
|
||||
this.agentId = options.agentId;
|
||||
this.launchEnv = options.launchEnv;
|
||||
this.initialHandle = options.handle;
|
||||
this.config = { ...config, provider: options.provider };
|
||||
@@ -1068,6 +1154,25 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
throw new Error("ACP session not initialized");
|
||||
}
|
||||
|
||||
const context = this.createProviderModeWriterContext(modeId, selection);
|
||||
const providerResult = this.providerModeWriter
|
||||
? await this.providerModeWriter(context)
|
||||
: { handled: false };
|
||||
if (providerResult.handled) {
|
||||
this.currentMode = providerResult.currentModeId ?? modeId;
|
||||
if (providerResult.configOptions) {
|
||||
this.configOptions = this.transformConfigOptions(providerResult.configOptions);
|
||||
}
|
||||
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
|
||||
this.pushEvent({
|
||||
type: "mode_changed",
|
||||
provider: this.provider,
|
||||
currentModeId: this.currentMode,
|
||||
availableModes: [...this.availableModes],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.hasAvailableModes) {
|
||||
if (!selection.availableMode) {
|
||||
this.warnInvalidSelection(
|
||||
@@ -1078,7 +1183,32 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const modeOption = selection.configOption;
|
||||
if (!modeOption) {
|
||||
throw new Error(`${this.provider} does not expose ACP mode switching`);
|
||||
}
|
||||
if (!selection.configChoice) {
|
||||
this.warnInvalidSelection(
|
||||
modeId,
|
||||
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
|
||||
modeOption.options,
|
||||
)
|
||||
.map((option) => option.value)
|
||||
.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.beforeModeWriter) {
|
||||
const beforeResult = await this.beforeModeWriter(context);
|
||||
if (beforeResult?.configOptions) {
|
||||
this.configOptions = this.transformConfigOptions(beforeResult.configOptions);
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.hasAvailableModes) {
|
||||
await this.connection.setSessionMode({ sessionId: this.sessionId, modeId });
|
||||
this.currentMode = modeId;
|
||||
this.pushEvent({
|
||||
@@ -1094,17 +1224,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
if (!modeOption) {
|
||||
throw new Error(`${this.provider} does not expose ACP mode switching`);
|
||||
}
|
||||
if (!selection.configChoice) {
|
||||
this.warnInvalidSelection(
|
||||
modeId,
|
||||
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
|
||||
modeOption.options,
|
||||
)
|
||||
.map((option) => option.value)
|
||||
.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.connection.setSessionConfigOption({
|
||||
sessionId: this.sessionId,
|
||||
@@ -1127,6 +1246,24 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
});
|
||||
}
|
||||
|
||||
private createProviderModeWriterContext(
|
||||
requestedModeId: string,
|
||||
selection: ACPModeSelection,
|
||||
): ACPProviderModeWriterContext {
|
||||
if (!this.connection || !this.sessionId) {
|
||||
throw new Error("ACP session not initialized");
|
||||
}
|
||||
return {
|
||||
connection: this.connection,
|
||||
sessionId: this.sessionId,
|
||||
requestedModeId,
|
||||
currentModeId: this.currentMode,
|
||||
selection,
|
||||
configOptions: this.configOptions,
|
||||
logger: this.logger,
|
||||
};
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
if (!this.connection || !this.sessionId) {
|
||||
throw new Error("ACP session not initialized");
|
||||
@@ -1281,9 +1418,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
requestedValue: string;
|
||||
label: string;
|
||||
}): string {
|
||||
this.configOptions = response.configOptions;
|
||||
this.configOptions = this.transformConfigOptions(response.configOptions);
|
||||
const responseOption = findSelectConfigOption({
|
||||
configOptions: response.configOptions,
|
||||
configOptions: this.configOptions,
|
||||
category,
|
||||
id: configId,
|
||||
});
|
||||
@@ -1409,7 +1546,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
// Match Zed acp.rs:3189-3220 — pure pass-through. Accepted UX regression: Copilot Autopilot will now prompt the user for every tool request.
|
||||
// Match Zed acp.rs:3189-3220: generic ACP permission requests stay pure pass-through.
|
||||
const requestId = randomUUID();
|
||||
let toolSnapshot =
|
||||
this.toolCalls.get(params.toolCall.toolCallId) ??
|
||||
@@ -1439,11 +1576,31 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: this.provider,
|
||||
sessionId: params.sessionId,
|
||||
rawEvent: params,
|
||||
},
|
||||
"provider.acp.raw_event",
|
||||
);
|
||||
if (params.sessionId !== this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const events = this.translateSessionUpdate(params.update);
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: this.provider,
|
||||
sessionId: this.sessionId,
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
rawEvent: params,
|
||||
events,
|
||||
},
|
||||
"provider.acp.parsed_event",
|
||||
);
|
||||
if (this.replayingHistory) {
|
||||
for (const event of events) {
|
||||
if (event.type === "timeline") {
|
||||
@@ -1622,7 +1779,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
? this.sessionResponseTransformer(response)
|
||||
: response;
|
||||
|
||||
this.configOptions = transformed.configOptions ?? [];
|
||||
this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []);
|
||||
|
||||
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
|
||||
this.availableModes = modeInfo.modes;
|
||||
@@ -1635,6 +1792,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
|
||||
}
|
||||
|
||||
private transformConfigOptions(configOptions: SessionConfigOption[]): SessionConfigOption[] {
|
||||
return this.configOptionsTransformer
|
||||
? this.configOptionsTransformer(configOptions)
|
||||
: configOptions;
|
||||
}
|
||||
|
||||
private transformModeId(modeId: string): string | null {
|
||||
return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId;
|
||||
}
|
||||
|
||||
private async applyConfiguredOverrides(): Promise<void> {
|
||||
const configuredModeId = this.config.modeId;
|
||||
if (configuredModeId && configuredModeId !== this.currentMode) {
|
||||
@@ -1772,11 +1939,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
private handleCurrentModeUpdate(update: CurrentModeUpdate): void {
|
||||
this.currentMode = update.currentModeId;
|
||||
this.currentMode = this.transformModeId(update.currentModeId);
|
||||
}
|
||||
|
||||
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
|
||||
this.configOptions = update.configOptions;
|
||||
this.configOptions = this.transformConfigOptions(update.configOptions);
|
||||
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
|
||||
const nextMode = modeInfo.currentModeId;
|
||||
const nextModel = deriveCurrentConfigValue(this.configOptions, "model");
|
||||
@@ -1864,6 +2031,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
private pushEvent(event: AgentStreamEvent): void {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: this.provider,
|
||||
sessionId: this.sessionId,
|
||||
turnId: getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? undefined,
|
||||
event,
|
||||
},
|
||||
"provider.acp.event_emit",
|
||||
);
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentSession,
|
||||
type AgentStreamEvent,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
@@ -37,15 +41,14 @@ function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// turnId is optional on AgentStreamEvent — this narrows to events where it's present.
|
||||
type EventWithTurnId = AgentStreamEvent & { turnId: string };
|
||||
|
||||
function hasTurnId(event: AgentStreamEvent): event is EventWithTurnId {
|
||||
return "turnId" in event && typeof (event as Record<string, unknown>).turnId === "string";
|
||||
return getAgentStreamEventTurnId(event) !== undefined;
|
||||
}
|
||||
|
||||
function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamEvent[] {
|
||||
return events.filter((e) => hasTurnId(e) && e.turnId === turnId);
|
||||
return events.filter((e) => getAgentStreamEventTurnId(e) === turnId);
|
||||
}
|
||||
|
||||
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
|
||||
@@ -157,7 +160,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
|
||||
// Invariant 2: Every turn_started has exactly one matching terminal
|
||||
const turnStartedIds = events
|
||||
.filter((e) => e.type === "turn_started" && hasTurnId(e))
|
||||
.map((e) => (e as EventWithTurnId).turnId);
|
||||
.map((e) => e.turnId);
|
||||
|
||||
for (const turnId of turnStartedIds) {
|
||||
const terminals = eventsForTurn(events, turnId).filter(isTerminalEvent);
|
||||
@@ -277,7 +280,7 @@ test("Test 3: Lifecycle doesn't get stuck in running", async () => {
|
||||
|
||||
// Any turn_started after terminal must have a different turnId
|
||||
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
|
||||
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
|
||||
expect(ts.turnId).not.toBe(turnId);
|
||||
}
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
@@ -313,8 +316,9 @@ test("Test 4: Autonomous run", async () => {
|
||||
|
||||
// Autonomous turn_started with a different turnId
|
||||
const autoStarts = afterForeground.filter(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
|
||||
) as EventWithTurnId[];
|
||||
(e): e is EventWithTurnId =>
|
||||
e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
|
||||
);
|
||||
if (autoStarts.length === 0) {
|
||||
assertInvariants(events, [fgTurnId]);
|
||||
return;
|
||||
|
||||
@@ -42,34 +42,35 @@ import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../prov
|
||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
||||
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentLaunchContext,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionRequestKind,
|
||||
AgentPermissionResponse,
|
||||
AgentPermissionUpdate,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
AgentRuntimeInfo,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPermissionAction,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionRequestKind,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPermissionUpdate,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type ListModelsOptions,
|
||||
type ListPersistedAgentsOptions,
|
||||
type McpServerConfig,
|
||||
type PersistedAgentDescriptor,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import {
|
||||
createProviderEnv,
|
||||
@@ -250,6 +251,7 @@ interface ClaudeAgentSessionOptions {
|
||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
handle?: AgentPersistenceHandle;
|
||||
agentId?: string;
|
||||
launchEnv?: Record<string, string>;
|
||||
persistSession?: boolean;
|
||||
logger: Logger;
|
||||
@@ -1152,8 +1154,6 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
||||
};
|
||||
}
|
||||
|
||||
const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1";
|
||||
|
||||
export class ClaudeAgentClient implements AgentClient {
|
||||
readonly provider = "claude" as const;
|
||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||
@@ -1181,6 +1181,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
return new ClaudeAgentSession(claudeConfig, {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
persistSession: options?.persistSession,
|
||||
logger: this.logger,
|
||||
@@ -1209,6 +1210,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
handle,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
logger: this.logger,
|
||||
queryFactory: this.queryFactory,
|
||||
@@ -1477,6 +1479,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
private readonly config: ClaudeAgentConfig;
|
||||
private readonly launchEnv?: Record<string, string>;
|
||||
private readonly agentId?: string;
|
||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly persistSession?: boolean;
|
||||
@@ -1526,10 +1529,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
|
||||
this.config = config;
|
||||
this.launchEnv = options.launchEnv;
|
||||
this.agentId = options.agentId;
|
||||
this.defaults = options.defaults;
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.persistSession = options.persistSession;
|
||||
this.logger = options.logger;
|
||||
this.logger = options.logger.child({ agentId: this.agentId });
|
||||
this.queryFactory = options.queryFactory;
|
||||
this.resolveBinary = options.resolveBinary;
|
||||
const handle = options.handle;
|
||||
@@ -1869,13 +1873,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
async close(): Promise<void> {
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
turnState: this.turnState,
|
||||
hasQuery: Boolean(this.query),
|
||||
hasInput: Boolean(this.input),
|
||||
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
|
||||
},
|
||||
"Claude session close: start",
|
||||
"provider.claude.session_close.start",
|
||||
);
|
||||
this.closed = true;
|
||||
this.rejectAllPendingPermissions(new Error("Claude session closed"));
|
||||
@@ -1910,8 +1917,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
this.logger.trace(
|
||||
{ claudeSessionId: this.claudeSessionId, turnState: this.turnState },
|
||||
"Claude session close: completed",
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnState: this.turnState,
|
||||
},
|
||||
"provider.claude.session_close.complete",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2188,16 +2200,41 @@ class ClaudeAgentSession implements AgentSession {
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
if (!promise) {
|
||||
this.logger.trace({ label }, "Claude query operation skipped (no promise)");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
label,
|
||||
},
|
||||
"provider.claude.query_operation.skip",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
this.logger.trace({ label }, "Claude query operation wait start");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
label,
|
||||
},
|
||||
"provider.claude.query_operation.start",
|
||||
);
|
||||
try {
|
||||
await withTimeout(promise, 3_000, "timeout");
|
||||
this.logger.trace(
|
||||
{ label, durationMs: Date.now() - startedAt },
|
||||
"Claude query operation settled",
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
label,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
"provider.claude.query_operation.settled",
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly");
|
||||
@@ -2593,7 +2630,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const pump = this.runQueryPump().catch((error) => {
|
||||
this.logger.trace({ err: error }, "Claude query pump exited unexpectedly");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
err: error,
|
||||
},
|
||||
"provider.claude.query_pump.exit_unexpected",
|
||||
);
|
||||
});
|
||||
|
||||
this.queryPumpPromise = pump;
|
||||
@@ -2609,24 +2655,34 @@ class ClaudeAgentSession implements AgentSession {
|
||||
try {
|
||||
activeQuery = await this.ensureQuery();
|
||||
} catch (error) {
|
||||
this.logger.trace({ err: error }, "Failed to initialize Claude query pump");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
err: error,
|
||||
},
|
||||
"provider.claude.query_pump.init_failed",
|
||||
);
|
||||
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
|
||||
return;
|
||||
}
|
||||
|
||||
let consecutiveInterruptAbortRecoveries = 0;
|
||||
const logRawMessage = (message: SDKMessage): void => {
|
||||
if (!claudeDebug) {
|
||||
return;
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
messageType: message.type,
|
||||
messageSubtype: "subtype" in message ? message.subtype : undefined,
|
||||
messageUuid: "uuid" in message ? message.uuid : undefined,
|
||||
rawEvent: message,
|
||||
},
|
||||
"Claude query pump: raw SDK message",
|
||||
"provider.claude.raw_event",
|
||||
);
|
||||
};
|
||||
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
|
||||
@@ -2739,16 +2795,18 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
||||
const identifiers = readEventIdentifiers(message);
|
||||
|
||||
if (claudeDebug) {
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
messageType: message.type,
|
||||
turnId,
|
||||
},
|
||||
"Claude query pump: SDK message",
|
||||
);
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: turnId ?? undefined,
|
||||
messageType: message.type,
|
||||
identifiers,
|
||||
rawEvent: message,
|
||||
},
|
||||
"provider.claude.parsed_event",
|
||||
);
|
||||
|
||||
const messageEvents = this.translateMessageToEvents(message, {
|
||||
suppressAssistantText: true,
|
||||
@@ -2816,7 +2874,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
this.logger.warn(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
error: staleResumeError,
|
||||
},
|
||||
"Claude resumed session no longer exists; invalidating persisted session",
|
||||
@@ -2846,7 +2903,15 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private async interruptActiveTurn(): Promise<void> {
|
||||
const queryToInterrupt = this.query;
|
||||
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
|
||||
this.logger.trace("interruptActiveTurn: no query to interrupt");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
},
|
||||
"provider.claude.interrupt.no_query",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.pendingInterruptAbort = true;
|
||||
@@ -3455,6 +3520,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
|
||||
const tagged = turnId ? { ...event, turnId } : event;
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: getAgentStreamEventTurnId(tagged),
|
||||
event: tagged,
|
||||
},
|
||||
"provider.claude.event_emit",
|
||||
);
|
||||
for (const callback of this.subscribers) {
|
||||
try {
|
||||
callback(tagged);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "./codex/test-utils/fake-app-server.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
|
||||
import { buildProviderRegistry } from "../provider-registry.js";
|
||||
|
||||
interface CollaborationModeRecord {
|
||||
name: string;
|
||||
@@ -34,6 +35,7 @@ interface CollaborationModeRecord {
|
||||
}
|
||||
|
||||
interface CodexSessionTestAccess {
|
||||
ensureThreadLoaded(): Promise<void>;
|
||||
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
||||
handleNotification(method: string, params: unknown): void;
|
||||
loadPersistedHistory(): Promise<void>;
|
||||
@@ -102,6 +104,100 @@ function markdownImageSource(markdown: string): string {
|
||||
return match[1].replace(/\\\)/g, ")");
|
||||
}
|
||||
|
||||
type CapturedFakeCodexRecord = Record<string, unknown>;
|
||||
|
||||
async function runCustomCodexProviderTurn(
|
||||
providerId: string,
|
||||
baseUrl: string,
|
||||
): Promise<CapturedFakeCodexRecord[]> {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-custom-provider-"));
|
||||
const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs");
|
||||
const capturedRequestsPath = path.join(tempDir, "requests.jsonl");
|
||||
writeFileSync(
|
||||
fakeAppServerPath,
|
||||
`
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE;
|
||||
let buffer = "";
|
||||
|
||||
fs.appendFileSync(capturePath, JSON.stringify({
|
||||
kind: "env",
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
}) + "\\n");
|
||||
|
||||
function record(method, params) {
|
||||
fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method, params }) + "\\n");
|
||||
}
|
||||
|
||||
function resultFor(method) {
|
||||
if (method === "initialize") return {};
|
||||
if (method === "collaborationMode/list") return { data: [] };
|
||||
if (method === "skills/list") return { data: [] };
|
||||
if (method === "config/read") return { config: {} };
|
||||
if (method === "getUserSavedConfig") return { config: {} };
|
||||
if (method === "model/list") return { data: [{ id: "custom-model", isDefault: true }] };
|
||||
if (method === "thread/start") return { thread: { id: "thread-1" } };
|
||||
if (method === "turn/start") return {};
|
||||
return {};
|
||||
}
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
for (;;) {
|
||||
const newlineIndex = buffer.indexOf("\\n");
|
||||
if (newlineIndex === -1) break;
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!line) continue;
|
||||
const message = JSON.parse(line);
|
||||
record(message.method, message.params);
|
||||
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
|
||||
}
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const registry = buildProviderRegistry(createTestLogger(), {
|
||||
providerOverrides: {
|
||||
[providerId]: {
|
||||
extends: "codex",
|
||||
label: "Custom Codex",
|
||||
command: [process.execPath, fakeAppServerPath],
|
||||
env: {
|
||||
OPENAI_API_KEY: "sk-custom",
|
||||
OPENAI_BASE_URL: baseUrl,
|
||||
PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const session = await registry[providerId].createClient(createTestLogger()).createSession({
|
||||
provider: providerId,
|
||||
cwd: "/workspace/project",
|
||||
modeId: "auto",
|
||||
model: "custom-model",
|
||||
});
|
||||
|
||||
try {
|
||||
await session.startTurn("use the custom endpoint");
|
||||
return readFileSync(capturedRequestsPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as CapturedFakeCodexRecord);
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown {
|
||||
const threadStart = records.find((record) => record.method === "thread/start");
|
||||
const params = threadStart?.params as Record<string, unknown> | undefined;
|
||||
return params?.config;
|
||||
}
|
||||
|
||||
describe("Codex app-server provider", () => {
|
||||
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
@@ -242,6 +338,121 @@ describe("Codex app-server provider", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("configures Codex app-server to use a custom provider base URL", async () => {
|
||||
const capturedRequests = await runCustomCodexProviderTurn(
|
||||
"codex-iisb",
|
||||
"https://custom-relay.example.com",
|
||||
);
|
||||
|
||||
expect(capturedRequests[0]).toEqual({
|
||||
kind: "env",
|
||||
OPENAI_API_KEY: "sk-custom",
|
||||
OPENAI_BASE_URL: "https://custom-relay.example.com",
|
||||
});
|
||||
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
|
||||
model_provider: "codex-iisb",
|
||||
model_providers: {
|
||||
"codex-iisb": {
|
||||
name: "Custom Codex",
|
||||
base_url: "https://custom-relay.example.com/v1",
|
||||
env_key: "OPENAI_API_KEY",
|
||||
requires_openai_auth: false,
|
||||
wire_api: "responses",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("does not append v1 twice for custom Codex provider base URLs", async () => {
|
||||
const capturedRequests = await runCustomCodexProviderTurn(
|
||||
"codex-custom",
|
||||
"https://custom-relay.example.com/v1/",
|
||||
);
|
||||
|
||||
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
|
||||
model_provider: "codex-custom",
|
||||
model_providers: {
|
||||
"codex-custom": expect.objectContaining({
|
||||
base_url: "https://custom-relay.example.com/v1",
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => {
|
||||
const threadRequests: string[] = [];
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/loaded/list": () => {
|
||||
threadRequests.push("thread/loaded/list");
|
||||
return { data: [] };
|
||||
},
|
||||
"thread/resume": () => {
|
||||
threadRequests.push("thread/resume");
|
||||
return Promise.reject(new Error("no rollout found for thread id archived-thread-id"));
|
||||
},
|
||||
"thread/start": () => {
|
||||
threadRequests.push("thread/start");
|
||||
return { thread: { id: "replacement-empty-thread-id" } };
|
||||
},
|
||||
"thread/read": () => {
|
||||
threadRequests.push("thread/read");
|
||||
return { thread: { turns: [] } };
|
||||
},
|
||||
getUserSavedConfig: () => {
|
||||
threadRequests.push("getUserSavedConfig");
|
||||
return { config: {} };
|
||||
},
|
||||
"config/read": () => {
|
||||
threadRequests.push("config/read");
|
||||
return { config: {} };
|
||||
},
|
||||
"model/list": () => {
|
||||
threadRequests.push("model/list");
|
||||
return {
|
||||
data: [{ id: "gpt-5.4", isDefault: true, defaultReasoningEffort: "medium" }],
|
||||
};
|
||||
},
|
||||
});
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
castInternals<{ goalsEnabledPromise: Promise<boolean> | null }>(provider).goalsEnabledPromise =
|
||||
Promise.resolve(false);
|
||||
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
|
||||
provider,
|
||||
).spawnAppServer = async () => appServer.child;
|
||||
|
||||
const outcome = await Promise.race([
|
||||
provider
|
||||
.resumeSession({
|
||||
sessionId: "archived-thread-id",
|
||||
metadata: {
|
||||
cwd: "/tmp/codex-question-test",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
})
|
||||
.then(
|
||||
() => "resolved" as const,
|
||||
(error) => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toContain(
|
||||
"no rollout found for thread id archived-thread-id",
|
||||
);
|
||||
return "rejected" as const;
|
||||
},
|
||||
),
|
||||
new Promise<"timed_out">((resolve) => setTimeout(() => resolve("timed_out"), 500)),
|
||||
]);
|
||||
|
||||
if (outcome === "timed_out") {
|
||||
appServer.child.kill("SIGTERM");
|
||||
throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`);
|
||||
}
|
||||
|
||||
expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume"]);
|
||||
expect(outcome).toBe("rejected");
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
|
||||
const cwd = path.join(tempDir, "repo", "packages", "app");
|
||||
@@ -998,6 +1209,37 @@ describe("Codex app-server provider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not replace a persisted Codex thread when app-server resume fails", async () => {
|
||||
const session = createSession({ thinkingOptionId: "medium" });
|
||||
session.currentThreadId = "archived-thread-id";
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
session.client = {
|
||||
request: vi.fn(async (method: string, params: unknown) => {
|
||||
requests.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: [] };
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
throw new Error("no rollout found for thread id archived-thread-id");
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return { thread: { id: "replacement-empty-thread-id" } };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(asInternals(session).ensureThreadLoaded()).rejects.toThrow(
|
||||
"no rollout found for thread id archived-thread-id",
|
||||
);
|
||||
|
||||
expect(session.currentThreadId).toBe("archived-thread-id");
|
||||
expect(requests).toEqual([
|
||||
{ method: "thread/loaded/list", params: {} },
|
||||
{ method: "thread/resume", params: { threadId: "archived-thread-id" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("appends blank-line spacing to /goal status messages", async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const session = createSession({}, { goalsEnabled: true });
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
McpServerConfig,
|
||||
AgentPersistenceHandle,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPermissionResult,
|
||||
AgentPromptContentBlock,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
ToolCallTimelineItem,
|
||||
AgentUsage,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPermissionAction,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type McpServerConfig,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPermissionResult,
|
||||
type AgentPromptContentBlock,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type ToolCallTimelineItem,
|
||||
type AgentUsage,
|
||||
type ListModelsOptions,
|
||||
type ListPersistedAgentsOptions,
|
||||
type PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { Logger } from "pino";
|
||||
import { homedir } from "node:os";
|
||||
@@ -55,7 +56,10 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||
import {
|
||||
CodexAppServerClient,
|
||||
type CodexAppServerTraceContext,
|
||||
} from "./codex/app-server-transport.js";
|
||||
import {
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
type ProviderImageOutput,
|
||||
@@ -174,9 +178,16 @@ interface CodexAppServerClientLike {
|
||||
|
||||
interface CodexAppServerAgentDeps {
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
customProvider?: {
|
||||
id: string;
|
||||
label: string;
|
||||
extends: string;
|
||||
};
|
||||
customCodexConfig?: Record<string, unknown> | null;
|
||||
_createCodexClient?: (
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
logger: Logger,
|
||||
getTraceContext: () => CodexAppServerTraceContext,
|
||||
) => CodexAppServerClientLike;
|
||||
}
|
||||
|
||||
@@ -2525,6 +2536,50 @@ function buildCodexAppServerInitializeParams(): {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOpenAICompatibleBaseUrl(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const withoutTrailingSlashes = trimmed.replace(/\/+$/u, "");
|
||||
if (withoutTrailingSlashes.endsWith("/v1")) {
|
||||
return withoutTrailingSlashes;
|
||||
}
|
||||
return `${withoutTrailingSlashes}/v1`;
|
||||
}
|
||||
|
||||
function buildCodexCustomProviderConfig(
|
||||
runtimeSettings: ProviderRuntimeSettings | undefined,
|
||||
customProvider: CodexAppServerAgentDeps["customProvider"],
|
||||
): Record<string, unknown> | null {
|
||||
if (customProvider?.extends !== CODEX_PROVIDER) {
|
||||
return null;
|
||||
}
|
||||
const baseUrl = runtimeSettings?.env?.OPENAI_BASE_URL;
|
||||
if (typeof baseUrl !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalizedBaseUrl = normalizeOpenAICompatibleBaseUrl(baseUrl);
|
||||
if (!normalizedBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
const providerConfig: Record<string, unknown> = {
|
||||
name: customProvider.label,
|
||||
base_url: normalizedBaseUrl,
|
||||
wire_api: "responses",
|
||||
};
|
||||
if (runtimeSettings?.env?.OPENAI_API_KEY?.trim()) {
|
||||
providerConfig.env_key = "OPENAI_API_KEY";
|
||||
providerConfig.requires_openai_auth = false;
|
||||
}
|
||||
return {
|
||||
model_provider: customProvider.id,
|
||||
model_providers: {
|
||||
[customProvider.id]: providerConfig,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface CodexSubAgentCallState {
|
||||
callId: string;
|
||||
toolCall: ToolCallTimelineItem;
|
||||
@@ -2603,8 +2658,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private readonly deps: CodexAppServerAgentDeps = {},
|
||||
private readonly ephemeral: boolean = false,
|
||||
private readonly goalsEnabled: boolean = false,
|
||||
private readonly agentId?: string,
|
||||
) {
|
||||
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
|
||||
this.logger = logger.child({
|
||||
module: "agent",
|
||||
provider: CODEX_PROVIDER,
|
||||
agentId: this.agentId,
|
||||
});
|
||||
if (config.modeId === undefined) {
|
||||
throw new Error("Codex agent requires modeId to be specified");
|
||||
}
|
||||
@@ -2641,7 +2701,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected) return;
|
||||
const child = await this.spawnAppServer();
|
||||
this.client = new CodexAppServerClient(child, this.logger);
|
||||
this.client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
|
||||
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
|
||||
this.registerRequestHandlers();
|
||||
|
||||
@@ -2659,6 +2719,14 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
private traceContext(): CodexAppServerTraceContext {
|
||||
return {
|
||||
agentId: this.agentId,
|
||||
sessionId: this.currentThreadId ?? undefined,
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadCollaborationModes(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
try {
|
||||
@@ -2679,7 +2747,16 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.trace({ error }, "Failed to load collaboration modes");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: this.currentThreadId,
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
error,
|
||||
},
|
||||
"provider.codex.metadata.collaboration_modes_failed",
|
||||
);
|
||||
this.collaborationModes = [];
|
||||
}
|
||||
this.refreshResolvedCollaborationMode();
|
||||
@@ -2711,7 +2788,16 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
this.cachedSkills = skills;
|
||||
} catch (error) {
|
||||
this.logger.trace({ error }, "Failed to load skills list");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: this.currentThreadId,
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
error,
|
||||
},
|
||||
"provider.codex.metadata.skills_failed",
|
||||
);
|
||||
this.cachedSkills = [];
|
||||
}
|
||||
}
|
||||
@@ -2901,9 +2987,10 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
await this.client.request("thread/resume", params);
|
||||
} catch (error) {
|
||||
this.logger.warn({ error }, "Failed to resume Codex thread, starting new thread");
|
||||
this.currentThreadId = null;
|
||||
await this.ensureThread();
|
||||
const threadId = this.currentThreadId;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
|
||||
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3587,6 +3674,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
if (this.config.extra?.codex) {
|
||||
Object.assign(innerConfig, this.config.extra.codex);
|
||||
}
|
||||
if (this.deps.customCodexConfig) {
|
||||
Object.assign(innerConfig, this.deps.customCodexConfig);
|
||||
}
|
||||
return Object.keys(innerConfig).length > 0 ? innerConfig : null;
|
||||
}
|
||||
|
||||
@@ -3604,6 +3694,16 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId = this.activeForegroundTurnId;
|
||||
const tagged = turnId ? { ...event, turnId } : event;
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: this.currentThreadId,
|
||||
turnId: getAgentStreamEventTurnId(tagged),
|
||||
event: tagged,
|
||||
},
|
||||
"provider.codex.event_emit",
|
||||
);
|
||||
for (const callback of this.subscribers) {
|
||||
try {
|
||||
callback(tagged);
|
||||
@@ -3619,6 +3719,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private handleNotification(method: string, params: unknown): void {
|
||||
const parsed = CodexNotificationSchema.parse({ method, params });
|
||||
this.traceParsedNotification(method, params, parsed);
|
||||
switch (parsed.kind) {
|
||||
case "thread_started":
|
||||
this.handleThreadStartedNotification(parsed);
|
||||
@@ -3675,6 +3776,25 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private traceParsedNotification(
|
||||
method: string,
|
||||
params: unknown,
|
||||
parsed: z.infer<typeof CodexNotificationSchema>,
|
||||
): void {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: this.currentThreadId,
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
method,
|
||||
params,
|
||||
parsed,
|
||||
},
|
||||
"provider.codex.parsed_event",
|
||||
);
|
||||
}
|
||||
|
||||
private getSubAgentCallIdForThread(threadId: string | null | undefined): string | null {
|
||||
if (!threadId || threadId === this.currentThreadId) {
|
||||
return null;
|
||||
@@ -4267,7 +4387,17 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.warnedUnknownNotificationMethods.add(method);
|
||||
this.logger.trace({ method, params }, "Unhandled Codex app-server notification method");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: this.currentThreadId,
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
method,
|
||||
params,
|
||||
},
|
||||
"provider.codex.event_unhandled",
|
||||
);
|
||||
}
|
||||
|
||||
private warnInvalidNotificationPayload(method: string, params: unknown): void {
|
||||
@@ -4528,6 +4658,16 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
private readonly deps: CodexAppServerAgentDeps = {},
|
||||
) {}
|
||||
|
||||
private sessionDeps(): CodexAppServerAgentDeps {
|
||||
return {
|
||||
...this.deps,
|
||||
customCodexConfig: buildCodexCustomProviderConfig(
|
||||
this.runtimeSettings,
|
||||
this.deps.customProvider,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveGoalsEnabled(): Promise<boolean> {
|
||||
if (!this.goalsEnabledPromise) {
|
||||
this.goalsEnabledPromise = (async () => {
|
||||
@@ -4535,7 +4675,14 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||
const versionOutput = await resolveBinaryVersion(launchPrefix.command);
|
||||
const enabled = codexVersionAtLeast(versionOutput, CODEX_GOALS_MIN_VERSION);
|
||||
this.logger.trace({ versionOutput, enabled }, "Resolved codex goals feature gate");
|
||||
this.logger.trace(
|
||||
{
|
||||
provider: CODEX_PROVIDER,
|
||||
versionOutput,
|
||||
enabled,
|
||||
},
|
||||
"provider.codex.config.goals_resolved",
|
||||
);
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to probe codex version for goals gate");
|
||||
@@ -4548,7 +4695,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
|
||||
private async spawnAppServer(
|
||||
launchEnv?: Record<string, string>,
|
||||
options?: { goalsEnabled?: boolean },
|
||||
options?: { goalsEnabled?: boolean; agentId?: string },
|
||||
): Promise<ChildProcessWithoutNullStreams> {
|
||||
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||
const args = [...launchPrefix.args, "app-server"];
|
||||
@@ -4557,10 +4704,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: options?.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
launchPrefix,
|
||||
goalsEnabled: options?.goalsEnabled === true,
|
||||
},
|
||||
"Spawning Codex app server",
|
||||
"provider.codex.spawn",
|
||||
);
|
||||
const child = spawnProcess(launchPrefix.command, args, {
|
||||
detached: process.platform !== "win32",
|
||||
@@ -4592,10 +4741,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
sessionConfig,
|
||||
null,
|
||||
this.logger,
|
||||
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
|
||||
this.deps,
|
||||
() =>
|
||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
||||
this.sessionDeps(),
|
||||
options?.persistSession === false,
|
||||
goalsEnabled,
|
||||
launchContext?.agentId,
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
@@ -4618,10 +4769,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
merged,
|
||||
handle,
|
||||
this.logger,
|
||||
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
|
||||
this.deps,
|
||||
() =>
|
||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
||||
this.sessionDeps(),
|
||||
false,
|
||||
goalsEnabled,
|
||||
launchContext?.agentId,
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
@@ -4632,7 +4785,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
const child = await this.spawnAppServer();
|
||||
const client =
|
||||
this.deps._createCodexClient?.(child, this.logger) ??
|
||||
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
|
||||
new CodexAppServerClient(child, this.logger);
|
||||
|
||||
try {
|
||||
|
||||
@@ -35,6 +35,12 @@ interface PendingRequest {
|
||||
type RequestHandler = (params: unknown) => unknown;
|
||||
type NotificationHandler = (method: string, params: unknown) => void;
|
||||
|
||||
export interface CodexAppServerTraceContext {
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
turnId?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -54,6 +60,24 @@ function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
|
||||
return typeof msg.method === "string" && msg.id === undefined;
|
||||
}
|
||||
|
||||
function readProviderSessionId(params: unknown): string | undefined {
|
||||
if (!isRecord(params)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof params.threadId === "string" ? params.threadId : undefined;
|
||||
}
|
||||
|
||||
function readProviderTurnId(params: unknown): string | undefined {
|
||||
if (!isRecord(params)) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof params.turnId === "string") {
|
||||
return params.turnId;
|
||||
}
|
||||
const turn = params.turn;
|
||||
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
|
||||
}
|
||||
|
||||
export class CodexAppServerClient {
|
||||
private readonly rl: readline.Interface;
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
@@ -66,6 +90,7 @@ export class CodexAppServerClient {
|
||||
constructor(
|
||||
private readonly child: ChildProcessWithoutNullStreams,
|
||||
private readonly logger: Logger,
|
||||
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
|
||||
) {
|
||||
this.rl = readline.createInterface({ input: child.stdout });
|
||||
this.rl.on("line", (line) => {
|
||||
@@ -224,6 +249,19 @@ export class CodexAppServerClient {
|
||||
}
|
||||
|
||||
if (isJsonRpcNotification(raw)) {
|
||||
const traceContext = this.getTraceContext();
|
||||
this.logger.trace(
|
||||
{
|
||||
provider: "codex",
|
||||
agentId: traceContext.agentId,
|
||||
sessionId: traceContext.sessionId ?? readProviderSessionId(raw.params),
|
||||
turnId: traceContext.turnId ?? readProviderTurnId(raw.params),
|
||||
method: raw.method,
|
||||
params: raw.params,
|
||||
rawEvent: raw,
|
||||
},
|
||||
"provider.codex.raw_event",
|
||||
);
|
||||
this.notificationHandler?.(raw.method, raw.params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,39 @@ describe("codex tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("unwraps pwsh wrapper strings for commandExecution on Windows", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "commandExecution",
|
||||
id: "codex-call-wrapper-pwsh-string",
|
||||
status: "running",
|
||||
command:
|
||||
'"C:\\Users\\example\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe" -NoLogo -NoProfile -Command "echo hello"',
|
||||
cwd: "C:\\repo",
|
||||
});
|
||||
|
||||
expect(item?.detail).toEqual({
|
||||
type: "shell",
|
||||
command: "echo hello",
|
||||
cwd: "C:\\repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("unwraps cmd wrapper arrays for commandExecution on Windows", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "commandExecution",
|
||||
id: "codex-call-wrapper-cmd-array",
|
||||
status: "running",
|
||||
command: ["cmd.exe", "/c", "echo hello"],
|
||||
cwd: "C:\\repo",
|
||||
});
|
||||
|
||||
expect(item?.detail).toEqual({
|
||||
type: "shell",
|
||||
command: "echo hello",
|
||||
cwd: "C:\\repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps only command output body when commandExecution output is wrapped in shell envelope", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "commandExecution",
|
||||
|
||||
@@ -200,21 +200,48 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
|
||||
|
||||
function maybeUnwrapShellWrapperCommand(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
|
||||
if (!wrapperMatch) {
|
||||
const unixWrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
|
||||
if (unixWrapperMatch) {
|
||||
const candidate = unixWrapperMatch[1]?.trim() ?? "";
|
||||
if (!candidate) {
|
||||
return trimmed;
|
||||
}
|
||||
return stripMatchingEdgeQuotes(candidate);
|
||||
}
|
||||
const windowsWrapperMatch = trimmed.match(
|
||||
/^(?:"[^"]*\\)?(?:pwsh|powershell|cmd)(?:\.exe)?"?\s+((?:-[A-Za-z]+(?:\s+[^-\s][^\s]*)?\s+)*)((?:-Command|-c|\/c)\s+[\s\S]+)$/i,
|
||||
);
|
||||
if (!windowsWrapperMatch) {
|
||||
return trimmed;
|
||||
}
|
||||
const candidate = wrapperMatch[1]?.trim() ?? "";
|
||||
const wrappedCommand = windowsWrapperMatch[2]?.trim() ?? "";
|
||||
if (!wrappedCommand) {
|
||||
return trimmed;
|
||||
}
|
||||
const commandMatch = wrappedCommand.match(/^(?:-Command|-c|\/c)\s+([\s\S]+)$/i);
|
||||
if (!commandMatch) {
|
||||
return trimmed;
|
||||
}
|
||||
const candidate = commandMatch[1]?.trim() ?? "";
|
||||
if (!candidate) {
|
||||
return trimmed;
|
||||
}
|
||||
return stripMatchingEdgeQuotes(candidate);
|
||||
}
|
||||
|
||||
function stripMatchingEdgeQuotes(value: string): string {
|
||||
if (
|
||||
(candidate.startsWith('"') && candidate.endsWith('"')) ||
|
||||
(candidate.startsWith("'") && candidate.endsWith("'"))
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return candidate.slice(1, -1);
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return candidate;
|
||||
return value;
|
||||
}
|
||||
|
||||
function isWindowsShellCommand(command: string): boolean {
|
||||
const normalized = command.replace(/^["']|["']$/g, "");
|
||||
return /(?:^|\\)(?:pwsh|powershell|cmd)(?:\.exe)?$/i.test(normalized);
|
||||
}
|
||||
|
||||
function normalizeCommandExecutionCommand(value: unknown): string | undefined {
|
||||
@@ -236,6 +263,14 @@ function normalizeCommandExecutionCommand(value: unknown): string | undefined {
|
||||
const unwrapped = parts[2]?.trim();
|
||||
return unwrapped && unwrapped.length > 0 ? unwrapped : undefined;
|
||||
}
|
||||
if (
|
||||
parts.length >= 3 &&
|
||||
isWindowsShellCommand(parts[0] ?? "") &&
|
||||
/^(-command|-c|\/c)$/i.test(parts[1] ?? "")
|
||||
) {
|
||||
const unwrapped = parts.slice(2).join(" ").trim();
|
||||
return unwrapped.length > 0 ? stripMatchingEdgeQuotes(unwrapped) : undefined;
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { Logger } from "pino";
|
||||
import { homedir } from "node:os";
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
|
||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
import { ACPAgentClient } from "./acp-agent.js";
|
||||
import {
|
||||
ACPAgentClient,
|
||||
type ACPBeforeModeWriteResult,
|
||||
type ACPProviderModeWriteResult,
|
||||
type ACPProviderModeWriterContext,
|
||||
type SessionStateResponse,
|
||||
} from "./acp-agent.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
@@ -22,21 +29,31 @@ const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const COPILOT_MODES: AgentMode[] = [
|
||||
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
|
||||
const COPILOT_PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan";
|
||||
const COPILOT_AUTOPILOT_MODE_ID =
|
||||
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
|
||||
export const COPILOT_ALLOW_ALL_MODE_ID = "allow-all";
|
||||
const COPILOT_ALLOW_ALL_CONFIG_ID = "allow_all";
|
||||
const COPILOT_ALLOW_ALL_ON = "on";
|
||||
const COPILOT_ALLOW_ALL_OFF = "off";
|
||||
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
|
||||
|
||||
export const COPILOT_MODES: AgentMode[] = [
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
id: COPILOT_AGENT_MODE_ID,
|
||||
label: "Agent",
|
||||
description: "Default agent mode for conversational interactions",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
|
||||
id: COPILOT_PLAN_MODE_ID,
|
||||
label: "Plan",
|
||||
description: "Plan mode for creating and executing multi-step plans",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
label: "Autopilot",
|
||||
description: "Autonomous mode that runs until task completion without user interaction",
|
||||
id: COPILOT_ALLOW_ALL_MODE_ID,
|
||||
label: "Allow All",
|
||||
description: "Automatically approves all Copilot tool, path, and URL requests.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -53,6 +70,11 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
defaultCommand: ["copilot", "--acp"],
|
||||
defaultModes: COPILOT_MODES,
|
||||
sessionResponseTransformer: transformCopilotSessionResponse,
|
||||
configOptionsTransformer: transformCopilotConfigOptions,
|
||||
modeIdTransformer: transformCopilotModeId,
|
||||
providerModeWriter: writeCopilotProviderMode,
|
||||
beforeModeWriter: beforeCopilotModeWriter,
|
||||
capabilities: COPILOT_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
@@ -113,3 +135,126 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function transformCopilotSessionResponse(
|
||||
response: SessionStateResponse,
|
||||
): SessionStateResponse {
|
||||
if (!response.modes) {
|
||||
return response;
|
||||
}
|
||||
const allowAllEnabled = isCopilotAllowAllEnabled(response.configOptions ?? []);
|
||||
return {
|
||||
...response,
|
||||
modes: {
|
||||
...response.modes,
|
||||
availableModes: response.modes.availableModes
|
||||
?.filter(
|
||||
(mode) => mode.id !== COPILOT_AUTOPILOT_MODE_ID && mode.id !== COPILOT_ALLOW_ALL_MODE_ID,
|
||||
)
|
||||
.concat({
|
||||
id: COPILOT_ALLOW_ALL_MODE_ID,
|
||||
name: "Allow All",
|
||||
description: "Automatically approves all Copilot tool, path, and URL requests.",
|
||||
}),
|
||||
currentModeId: allowAllEnabled
|
||||
? COPILOT_ALLOW_ALL_MODE_ID
|
||||
: (transformCopilotModeId(response.modes.currentModeId ?? COPILOT_AGENT_MODE_ID) ??
|
||||
COPILOT_AGENT_MODE_ID),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function transformCopilotConfigOptions(
|
||||
configOptions: SessionConfigOption[],
|
||||
): SessionConfigOption[] {
|
||||
const allowAllEnabled = isCopilotAllowAllEnabled(configOptions);
|
||||
return configOptions.map((option) => {
|
||||
if (option.type !== "select" || option.category !== "mode") {
|
||||
return option;
|
||||
}
|
||||
// Trust Copilot's allow_all config value as the source of truth when it changes in-process.
|
||||
const options = flattenCopilotModeOptions(option.options)
|
||||
.filter(
|
||||
(choice) =>
|
||||
choice.value !== COPILOT_AUTOPILOT_MODE_ID && choice.value !== COPILOT_ALLOW_ALL_MODE_ID,
|
||||
)
|
||||
.concat({
|
||||
value: COPILOT_ALLOW_ALL_MODE_ID,
|
||||
name: "Allow All",
|
||||
description: "Automatically approves all Copilot tool, path, and URL requests.",
|
||||
});
|
||||
return {
|
||||
...option,
|
||||
currentValue: allowAllEnabled
|
||||
? COPILOT_ALLOW_ALL_MODE_ID
|
||||
: (transformCopilotModeId(option.currentValue) ?? COPILOT_AGENT_MODE_ID),
|
||||
options,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function flattenCopilotModeOptions(
|
||||
options: SelectConfigOption["options"],
|
||||
): Array<{ value: string; name: string; description?: string | null }> {
|
||||
const flattened: Array<{ value: string; name: string; description?: string | null }> = [];
|
||||
for (const option of options) {
|
||||
if ("value" in option) {
|
||||
flattened.push(option);
|
||||
continue;
|
||||
}
|
||||
flattened.push(...option.options);
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
export function transformCopilotModeId(modeId: string): string | null {
|
||||
return modeId === COPILOT_AUTOPILOT_MODE_ID ? COPILOT_AGENT_MODE_ID : modeId;
|
||||
}
|
||||
|
||||
export async function writeCopilotProviderMode(
|
||||
context: ACPProviderModeWriterContext,
|
||||
): Promise<ACPProviderModeWriteResult> {
|
||||
// COMPAT(copilotAutopilotMode): added in v0.1.75, remove after 2026-11-12 once old clients no longer send Copilot's old ACP autopilot mode ID.
|
||||
const requestsAllowAll =
|
||||
context.requestedModeId === COPILOT_ALLOW_ALL_MODE_ID ||
|
||||
context.requestedModeId === COPILOT_AUTOPILOT_MODE_ID;
|
||||
if (!requestsAllowAll) {
|
||||
return { handled: false };
|
||||
}
|
||||
const response = await context.connection.setSessionConfigOption({
|
||||
sessionId: context.sessionId,
|
||||
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
|
||||
value: COPILOT_ALLOW_ALL_ON,
|
||||
});
|
||||
return {
|
||||
handled: true,
|
||||
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
|
||||
configOptions: response.configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function beforeCopilotModeWriter(
|
||||
context: ACPProviderModeWriterContext,
|
||||
): Promise<ACPBeforeModeWriteResult> {
|
||||
if (
|
||||
context.currentModeId !== COPILOT_ALLOW_ALL_MODE_ID ||
|
||||
context.requestedModeId === COPILOT_ALLOW_ALL_MODE_ID
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const response = await context.connection.setSessionConfigOption({
|
||||
sessionId: context.sessionId,
|
||||
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
|
||||
value: COPILOT_ALLOW_ALL_OFF,
|
||||
});
|
||||
return { configOptions: response.configOptions };
|
||||
}
|
||||
|
||||
function isCopilotAllowAllEnabled(configOptions: SessionConfigOption[]): boolean {
|
||||
return configOptions.some(
|
||||
(option) =>
|
||||
option.type === "select" &&
|
||||
option.id === COPILOT_ALLOW_ALL_CONFIG_ID &&
|
||||
option.currentValue === COPILOT_ALLOW_ALL_ON,
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ export class TestOpenCodeClient {
|
||||
appAgents: [] as unknown[],
|
||||
commandList: [] as unknown[],
|
||||
eventSubscribe: [] as unknown[],
|
||||
globalEvent: [] as unknown[],
|
||||
permissionReply: [] as unknown[],
|
||||
providerList: [] as unknown[],
|
||||
questionReject: [] as unknown[],
|
||||
@@ -99,6 +100,12 @@ export class TestOpenCodeClient {
|
||||
return { stream: this.eventStream };
|
||||
},
|
||||
},
|
||||
global: {
|
||||
event: async (options: unknown) => {
|
||||
this.calls.globalEvent.push(options);
|
||||
return { stream: this.eventStream };
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
add: async () => ({}),
|
||||
connect: async () => ({}),
|
||||
|
||||
@@ -30,29 +30,30 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListModesOptions,
|
||||
ListModelsOptions,
|
||||
ToolCallDetail,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type ListModesOptions,
|
||||
type ListModelsOptions,
|
||||
type ToolCallDetail,
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
@@ -738,21 +739,6 @@ function parsePersistenceMetadata(metadata: AgentMetadata | undefined): PiPersis
|
||||
return {};
|
||||
}
|
||||
|
||||
function getStreamEventTurnId(event: AgentStreamEvent): string | undefined {
|
||||
switch (event.type) {
|
||||
case "turn_started":
|
||||
case "turn_completed":
|
||||
case "turn_failed":
|
||||
case "turn_canceled":
|
||||
case "timeline":
|
||||
case "permission_requested":
|
||||
case "permission_resolved":
|
||||
return event.turnId;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isPiRequestAbortError(error: unknown): boolean {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return true;
|
||||
@@ -1045,7 +1031,7 @@ export class PiDirectAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventTurnId = getStreamEventTurnId(event);
|
||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
||||
if (turnId && eventTurnId && eventTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type {
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
export type ProviderFinalTextReducer = (params: {
|
||||
@@ -44,7 +45,7 @@ export async function runProviderTurn({
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
const eventTurnId = "turnId" in event ? event.turnId : undefined;
|
||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
||||
if (turnId && eventTurnId && eventTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type {
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentSession,
|
||||
type AgentStreamEvent,
|
||||
} from "../../agent-sdk-types.js";
|
||||
|
||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
@@ -29,7 +30,7 @@ export async function* streamSession(
|
||||
};
|
||||
|
||||
const matchesTurn = (event: AgentStreamEvent): boolean => {
|
||||
const eventTurnId = (event as { turnId?: string }).turnId;
|
||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
||||
return turnId == null || eventTurnId == null || eventTurnId === turnId;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import pino from "pino";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import { STTManager } from "./stt-manager.js";
|
||||
import { PersistedConfigSchema } from "../persisted-config.js";
|
||||
import { resolveSpeechConfig } from "../speech/speech-config-resolver.js";
|
||||
import type {
|
||||
SpeechToTextProvider,
|
||||
StreamingTranscriptionSession,
|
||||
@@ -16,9 +18,11 @@ type StreamingOnHandler = Parameters<StreamingOn>[1];
|
||||
|
||||
class FakeStt implements SpeechToTextProvider {
|
||||
public readonly id = "fake";
|
||||
public lastLanguage?: string;
|
||||
constructor(private readonly result: TranscriptionResult) {}
|
||||
|
||||
createSession(_params: SessionParams): StreamingTranscriptionSession {
|
||||
createSession(params: SessionParams): StreamingTranscriptionSession {
|
||||
this.lastLanguage = params.language;
|
||||
const emitter = new EventEmitter();
|
||||
const result = this.result;
|
||||
let segmentId = "seg-1";
|
||||
@@ -92,6 +96,116 @@ class SequencedFakeStt implements SpeechToTextProvider {
|
||||
}
|
||||
|
||||
describe("STTManager", () => {
|
||||
function resolveVoiceLanguage(params: { env?: NodeJS.ProcessEnv; persisted?: unknown }): string {
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: params.env ?? ({} as NodeJS.ProcessEnv),
|
||||
persisted: PersistedConfigSchema.parse(params.persisted ?? {}),
|
||||
});
|
||||
return result.speech.sttLanguages.voice;
|
||||
}
|
||||
|
||||
async function transcribeWithResolvedVoiceLanguage(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
persisted?: unknown;
|
||||
}): Promise<FakeStt> {
|
||||
const fakeStt = new FakeStt({ text: "hi", isLowConfidence: false });
|
||||
const manager = new STTManager("s1", pino({ level: "silent" }), fakeStt, {
|
||||
language: resolveVoiceLanguage(params),
|
||||
});
|
||||
await manager.transcribe(Buffer.alloc(2), "audio/pcm;rate=24000");
|
||||
return fakeStt;
|
||||
}
|
||||
|
||||
it("defaults to English when no voice language config is set", async () => {
|
||||
const fakeStt = await transcribeWithResolvedVoiceLanguage({});
|
||||
|
||||
expect(fakeStt.lastLanguage).toBe("en");
|
||||
});
|
||||
|
||||
it("uses PASEO_VOICE_LANGUAGE over PASEO_DICTATION_LANGUAGE", async () => {
|
||||
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||
env: {
|
||||
PASEO_VOICE_LANGUAGE: "pt",
|
||||
PASEO_DICTATION_LANGUAGE: "es",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(fakeStt.lastLanguage).toBe("pt");
|
||||
});
|
||||
|
||||
it("uses PASEO_DICTATION_LANGUAGE when PASEO_VOICE_LANGUAGE is unset", async () => {
|
||||
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||
env: {
|
||||
PASEO_DICTATION_LANGUAGE: "pt",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(fakeStt.lastLanguage).toBe("pt");
|
||||
});
|
||||
|
||||
it("treats empty voice language env vars as unset", async () => {
|
||||
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||
env: {
|
||||
PASEO_VOICE_LANGUAGE: "",
|
||||
PASEO_DICTATION_LANGUAGE: " ",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(fakeStt.lastLanguage).toBe("en");
|
||||
});
|
||||
|
||||
it("uses settings voice STT language when no env var is set", async () => {
|
||||
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||
persisted: {
|
||||
features: {
|
||||
voiceMode: {
|
||||
stt: {
|
||||
language: "fr",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fakeStt.lastLanguage).toBe("fr");
|
||||
});
|
||||
|
||||
it("uses env voice language over settings voice STT language", async () => {
|
||||
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||
env: {
|
||||
PASEO_VOICE_LANGUAGE: "pt",
|
||||
} as NodeJS.ProcessEnv,
|
||||
persisted: {
|
||||
features: {
|
||||
voiceMode: {
|
||||
stt: {
|
||||
language: "fr",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fakeStt.lastLanguage).toBe("pt");
|
||||
});
|
||||
|
||||
it("falls back to settings dictation STT language when voice language is unset", async () => {
|
||||
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||
persisted: {
|
||||
features: {
|
||||
dictation: {
|
||||
stt: {
|
||||
language: "es",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fakeStt.lastLanguage).toBe("es");
|
||||
});
|
||||
|
||||
it("returns empty text for low-confidence transcriptions", async () => {
|
||||
const manager = new STTManager(
|
||||
"s1",
|
||||
|
||||
@@ -111,6 +111,10 @@ export interface SessionTranscriptionResult extends TranscriptionResult {
|
||||
format: string;
|
||||
}
|
||||
|
||||
export interface STTManagerOptions {
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session STT manager
|
||||
* Handles speech-to-text transcription
|
||||
@@ -119,15 +123,18 @@ export class STTManager {
|
||||
private readonly sessionId: string;
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly resolveStt: () => SpeechToTextProvider | null;
|
||||
private readonly language: string;
|
||||
|
||||
constructor(
|
||||
sessionId: string,
|
||||
logger: pino.Logger,
|
||||
stt: Resolvable<SpeechToTextProvider | null>,
|
||||
options?: STTManagerOptions,
|
||||
) {
|
||||
this.sessionId = sessionId;
|
||||
this.logger = logger.child({ module: "agent", component: "stt-manager", sessionId });
|
||||
this.resolveStt = toResolver(stt);
|
||||
this.language = options?.language ?? "en";
|
||||
}
|
||||
|
||||
public getProvider(): SpeechToTextProvider | null {
|
||||
@@ -171,7 +178,7 @@ export class STTManager {
|
||||
|
||||
const session = stt.createSession({
|
||||
logger: this.logger.child({ component: "stt-session" }),
|
||||
language: "en",
|
||||
language: this.language,
|
||||
});
|
||||
|
||||
const pcmForModel = preparePcmForModel(audio, format, session.requiredSampleRate);
|
||||
|
||||
@@ -195,8 +195,14 @@ function summarizeAgentMcpDebugBody(body: unknown): Record<string, unknown> {
|
||||
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
|
||||
export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig;
|
||||
|
||||
export interface PaseoSpeechSttLanguages {
|
||||
dictation: string;
|
||||
voice: string;
|
||||
}
|
||||
|
||||
export interface PaseoSpeechConfig {
|
||||
providers: RequestedSpeechProviders;
|
||||
sttLanguages?: PaseoSpeechSttLanguages;
|
||||
local?: PaseoLocalSpeechConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ function waitForSignal<T>(
|
||||
|
||||
class NonPersistentReloadSession implements AgentSession {
|
||||
readonly provider = "claude" as const;
|
||||
readonly id = null;
|
||||
readonly id: string | null;
|
||||
readonly capabilities = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: true,
|
||||
@@ -161,7 +161,12 @@ class NonPersistentReloadSession implements AgentSession {
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
constructor(private readonly onClose: () => void) {}
|
||||
constructor(
|
||||
private readonly onClose: () => void,
|
||||
id: string | null = null,
|
||||
) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return {
|
||||
@@ -259,6 +264,37 @@ class NonPersistentReloadClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
class FailingResumeSession extends NonPersistentReloadSession {
|
||||
constructor(onClose: () => void) {
|
||||
super(onClose, "failing-resume-session");
|
||||
}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle | null {
|
||||
return {
|
||||
provider: "claude",
|
||||
sessionId: this.id,
|
||||
metadata: { cwd: process.cwd() },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class FailingResumeClient extends NonPersistentReloadClient {
|
||||
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.createSessionCalls += 1;
|
||||
return new FailingResumeSession(() => {
|
||||
this.closeCalls += 1;
|
||||
});
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
_overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
this.resumeSessionCalls += 1;
|
||||
throw new Error("resume exploded");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSpeechConfig() {
|
||||
if (hasLocalSpeech) {
|
||||
return {
|
||||
@@ -482,6 +518,36 @@ test("refresh_agent rebuilds a live agent even when it has no persistence handle
|
||||
}
|
||||
});
|
||||
|
||||
test("refresh_agent rejects when persisted session resume fails", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new FailingResumeClient();
|
||||
const localCtx = await createDaemonTestContext({
|
||||
agentClients: {
|
||||
claude: client,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const created = await localCtx.client.createAgent({
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
await localCtx.client.archiveAgent(created.id);
|
||||
|
||||
await expect(localCtx.client.refreshAgent(created.id)).rejects.toMatchObject({
|
||||
name: "DaemonRpcError",
|
||||
code: "agent_refresh_failed",
|
||||
requestType: "refresh_agent_request",
|
||||
});
|
||||
expect(client.resumeSessionCalls).toBe(1);
|
||||
} finally {
|
||||
await localCtx.cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resume_agent auto-unarchives archived agents", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
|
||||
@@ -44,7 +44,7 @@ export const agentConfigs = {
|
||||
provider: "copilot",
|
||||
model: "claude-haiku-4.5",
|
||||
modes: {
|
||||
full: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
full: "allow-all",
|
||||
ask: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,6 +9,8 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { isProviderAvailable } from "./agent-configs.js";
|
||||
|
||||
const OPENCODE_REAL_TEST_MODEL = "opencode/big-pickle";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-init-prompt-"));
|
||||
}
|
||||
@@ -47,13 +49,13 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
|
||||
|
||||
try {
|
||||
const models = await client.listProviderModels("opencode");
|
||||
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
|
||||
expect(models.models.some((model) => model.id === OPENCODE_REAL_TEST_MODEL)).toBe(true);
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode initial prompt wait regression",
|
||||
model: "opencode/big-pickle",
|
||||
model: OPENCODE_REAL_TEST_MODEL,
|
||||
initialPrompt: "Reply with exactly: BIG_PICKLE_OK",
|
||||
});
|
||||
|
||||
@@ -83,35 +85,4 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test("waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const { client, daemon } = await createHarness();
|
||||
|
||||
try {
|
||||
const models = await client.listProviderModels("opencode");
|
||||
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode zai fatal retry regression",
|
||||
model: "zai/glm-5.1",
|
||||
initialPrompt: "Reply with exactly: GLM_51_OK",
|
||||
});
|
||||
|
||||
const finish = await client.waitForFinish(agent.id, 60_000);
|
||||
expect(finish.status).toBe("error");
|
||||
expect((finish.error ?? "").toLowerCase()).toMatch(
|
||||
/insufficient balance|resource package|recharge/,
|
||||
);
|
||||
|
||||
const snapshot = await client.fetchAgent(agent.id);
|
||||
expect(snapshot.agent?.status).toBe("error");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { EventEmitter } from "node:events";
|
||||
import pino from "pino";
|
||||
|
||||
import { DictationStreamManager } from "./dictation-stream-manager.js";
|
||||
import { PersistedConfigSchema } from "../persisted-config.js";
|
||||
import { resolveSpeechConfig } from "../speech/speech-config-resolver.js";
|
||||
import type {
|
||||
SpeechToTextProvider,
|
||||
StreamingTranscriptionSession,
|
||||
@@ -51,10 +53,12 @@ class FakeRealtimeSession extends EventEmitter implements StreamingTranscription
|
||||
|
||||
class FakeSttProvider implements SpeechToTextProvider {
|
||||
public readonly id = "fake";
|
||||
public lastLanguage?: string;
|
||||
constructor(private readonly session: FakeRealtimeSession) {}
|
||||
createSession(
|
||||
_params: Parameters<SpeechToTextProvider["createSession"]>[0],
|
||||
params: Parameters<SpeechToTextProvider["createSession"]>[0],
|
||||
): StreamingTranscriptionSession {
|
||||
this.lastLanguage = params.language;
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
@@ -123,6 +127,97 @@ describe("DictationStreamManager (finish buffer-too-small tolerance)", () => {
|
||||
});
|
||||
|
||||
describe("DictationStreamManager (provider-agnostic provider)", () => {
|
||||
function resolveDictationLanguage(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
persisted?: unknown;
|
||||
}): string {
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: params.env ?? ({} as NodeJS.ProcessEnv),
|
||||
persisted: PersistedConfigSchema.parse(params.persisted ?? {}),
|
||||
});
|
||||
return result.speech.sttLanguages.dictation;
|
||||
}
|
||||
|
||||
async function startWithResolvedDictationLanguage(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
persisted?: unknown;
|
||||
}): Promise<FakeSttProvider> {
|
||||
const session = new FakeRealtimeSession();
|
||||
const sttProvider = new FakeSttProvider(session);
|
||||
const manager = new DictationStreamManager({
|
||||
logger: pino({ level: "silent" }),
|
||||
emit: () => {},
|
||||
sessionId: "s1",
|
||||
stt: sttProvider,
|
||||
language: resolveDictationLanguage(params),
|
||||
});
|
||||
|
||||
await manager.handleStart("d-lang", "audio/pcm;rate=24000;bits=16");
|
||||
return sttProvider;
|
||||
}
|
||||
|
||||
it("defaults to English when dictation language config is unset", async () => {
|
||||
const sttProvider = await startWithResolvedDictationLanguage({});
|
||||
|
||||
expect(sttProvider.lastLanguage).toBe("en");
|
||||
});
|
||||
|
||||
it("uses PASEO_DICTATION_LANGUAGE when set", async () => {
|
||||
const sttProvider = await startWithResolvedDictationLanguage({
|
||||
env: {
|
||||
PASEO_DICTATION_LANGUAGE: "pt",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(sttProvider.lastLanguage).toBe("pt");
|
||||
});
|
||||
|
||||
it("treats empty PASEO_DICTATION_LANGUAGE as unset", async () => {
|
||||
const sttProvider = await startWithResolvedDictationLanguage({
|
||||
env: {
|
||||
PASEO_DICTATION_LANGUAGE: " ",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(sttProvider.lastLanguage).toBe("en");
|
||||
});
|
||||
|
||||
it("uses settings dictation STT language when env var is unset", async () => {
|
||||
const sttProvider = await startWithResolvedDictationLanguage({
|
||||
persisted: {
|
||||
features: {
|
||||
dictation: {
|
||||
stt: {
|
||||
language: "fr",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(sttProvider.lastLanguage).toBe("fr");
|
||||
});
|
||||
|
||||
it("uses env dictation language over settings dictation STT language", async () => {
|
||||
const sttProvider = await startWithResolvedDictationLanguage({
|
||||
env: {
|
||||
PASEO_DICTATION_LANGUAGE: "pt",
|
||||
} as NodeJS.ProcessEnv,
|
||||
persisted: {
|
||||
features: {
|
||||
dictation: {
|
||||
stt: {
|
||||
language: "fr",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(sttProvider.lastLanguage).toBe("pt");
|
||||
});
|
||||
|
||||
it("does not require OPENAI_API_KEY", async () => {
|
||||
const original = process.env.OPENAI_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
|
||||
@@ -130,6 +130,7 @@ export class DictationStreamManager {
|
||||
private readonly emit: (msg: DictationStreamOutboundMessage) => void;
|
||||
private readonly sessionId: string;
|
||||
private readonly resolveStt: () => SpeechToTextProvider | null;
|
||||
private readonly language: string;
|
||||
private readonly finalTimeoutMs: number;
|
||||
private readonly autoCommitSeconds: number;
|
||||
private readonly streams = new Map<string, DictationStreamState>();
|
||||
@@ -139,6 +140,7 @@ export class DictationStreamManager {
|
||||
emit: (msg: DictationStreamOutboundMessage) => void;
|
||||
sessionId: string;
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
language?: string;
|
||||
finalTimeoutMs?: number;
|
||||
autoCommitSeconds?: number;
|
||||
}) {
|
||||
@@ -146,6 +148,7 @@ export class DictationStreamManager {
|
||||
this.emit = params.emit;
|
||||
this.sessionId = params.sessionId;
|
||||
this.resolveStt = toResolver(params.stt);
|
||||
this.language = params.language ?? "en";
|
||||
this.finalTimeoutMs = params.finalTimeoutMs ?? DEFAULT_DICTATION_FINAL_TIMEOUT_MS;
|
||||
this.autoCommitSeconds =
|
||||
params.autoCommitSeconds ??
|
||||
@@ -176,7 +179,7 @@ export class DictationStreamManager {
|
||||
try {
|
||||
stt = sttProvider.createSession({
|
||||
logger: this.logger.child({ dictationId }),
|
||||
language: "en",
|
||||
language: this.language,
|
||||
prompt: transcriptionPrompt,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -106,6 +106,31 @@ describe("resolveLogConfig", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults file output to info when log.file is present without a level", () => {
|
||||
const config: PersistedConfig = {
|
||||
log: {
|
||||
console: {
|
||||
level: "warn",
|
||||
},
|
||||
file: {
|
||||
path: "daemon.log",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveLogConfig(config, { paseoHome })).toEqual({
|
||||
level: "info",
|
||||
console: {
|
||||
level: "warn",
|
||||
format: "json",
|
||||
},
|
||||
file: {
|
||||
level: "info",
|
||||
path: path.resolve(paseoHome, "daemon.log"),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfig logger config", () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
|
||||
|
||||
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
|
||||
const DEFAULT_CONSOLE_FORMAT: LogFormat = "json";
|
||||
const DEFAULT_FILE_LEVEL: LogLevel = "debug";
|
||||
const DEFAULT_FILE_LEVEL: LogLevel = "info";
|
||||
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const REDACT_PATHS = [
|
||||
"authorization",
|
||||
|
||||
@@ -483,6 +483,26 @@ describe("PersistedConfigSchema voice mode config", () => {
|
||||
|
||||
expect(parsed.features?.voiceMode?.turnDetection?.provider).toBe("local");
|
||||
});
|
||||
|
||||
test("accepts trimmed STT language fields", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
dictation: {
|
||||
stt: {
|
||||
language: " fr ",
|
||||
},
|
||||
},
|
||||
voiceMode: {
|
||||
stt: {
|
||||
language: " de ",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.features?.dictation?.stt?.language).toBe("fr");
|
||||
expect(parsed.features?.voiceMode?.stt?.language).toBe("de");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
|
||||
|
||||
@@ -86,6 +86,7 @@ const FeatureDictationSchema = z
|
||||
.object({
|
||||
provider: SpeechProviderIdSchema.optional(),
|
||||
model: z.string().min(1).optional(),
|
||||
language: z.string().trim().min(1).optional(),
|
||||
confidenceThreshold: z.number().optional(),
|
||||
})
|
||||
.strict()
|
||||
@@ -107,6 +108,7 @@ const FeatureVoiceModeSchema = z
|
||||
.object({
|
||||
provider: SpeechProviderIdSchema.optional(),
|
||||
model: z.string().min(1).optional(),
|
||||
language: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -4,6 +4,22 @@ import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { AgentManager } from "../agent/agent-manager.js";
|
||||
import { AgentStorage } from "../agent/agent-storage.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
ListModelsOptions,
|
||||
} from "../agent/agent-sdk-types.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { ScheduleService } from "./service.js";
|
||||
@@ -13,6 +29,15 @@ interface ScheduleServiceInternals {
|
||||
executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult>;
|
||||
}
|
||||
|
||||
const SCHEDULE_TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
describe("ScheduleService", () => {
|
||||
let tempDir: string;
|
||||
let agentStorage: AgentStorage;
|
||||
@@ -179,6 +204,182 @@ describe("ScheduleService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("archives new-agent schedule sessions after the run finishes", async () => {
|
||||
class CountingScheduleSession implements AgentSession {
|
||||
readonly provider = "claude";
|
||||
readonly capabilities = SCHEDULE_TEST_CAPABILITIES;
|
||||
readonly id: string;
|
||||
closed = false;
|
||||
private turnCount = 0;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
|
||||
constructor(private readonly config: AgentSessionConfig) {
|
||||
this.id = "scheduled-session-1";
|
||||
}
|
||||
|
||||
async run(_prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
return {
|
||||
sessionId: this.id,
|
||||
finalText: "done",
|
||||
timeline: [{ type: "assistant_message", text: "done" }],
|
||||
};
|
||||
}
|
||||
|
||||
async startTurn(
|
||||
_prompt: AgentPromptInput,
|
||||
_options?: AgentRunOptions,
|
||||
): Promise<{ turnId: string }> {
|
||||
const turnId = `turn-${++this.turnCount}`;
|
||||
setImmediate(() => {
|
||||
this.emit({ type: "turn_started", provider: this.provider, turnId });
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
item: { type: "assistant_message", text: "done" },
|
||||
});
|
||||
this.emit({
|
||||
type: "turn_completed",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
});
|
||||
});
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: this.config.model ?? null,
|
||||
modeId: this.config.modeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes(): Promise<AgentMode[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<string | null> {
|
||||
return this.config.modeId ?? null;
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
this.config.modeId = modeId;
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
_requestId: string,
|
||||
_response: AgentPermissionResponse,
|
||||
): Promise<void> {}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
metadata: { ...this.config },
|
||||
};
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
private emit(event: AgentStreamEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CountingScheduleClient implements AgentClient {
|
||||
readonly provider = "claude";
|
||||
readonly capabilities = SCHEDULE_TEST_CAPABILITIES;
|
||||
readonly sessions: CountingScheduleSession[] = [];
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = new CountingScheduleSession(config);
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async resumeSession(handle: AgentPersistenceHandle): Promise<AgentSession> {
|
||||
const metadata = handle.metadata as Partial<AgentSessionConfig> | undefined;
|
||||
const session = new CountingScheduleSession({
|
||||
...metadata,
|
||||
provider: this.provider,
|
||||
cwd: metadata?.cwd ?? tempDir,
|
||||
});
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CountingScheduleClient();
|
||||
const manager = new AgentManager({
|
||||
logger: createTestLogger(),
|
||||
clients: { claude: client },
|
||||
registry: agentStorage,
|
||||
});
|
||||
const service = new ScheduleService({
|
||||
paseoHome: tempDir,
|
||||
logger: createTestLogger(),
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
const created = await service.create({
|
||||
prompt: "finish and stop",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: tempDir,
|
||||
approvalPolicy: "never",
|
||||
},
|
||||
},
|
||||
maxRuns: 1,
|
||||
});
|
||||
|
||||
now = new Date("2026-01-01T00:01:00.000Z");
|
||||
await service.tick();
|
||||
|
||||
const inspected = await service.inspect(created.id);
|
||||
const agentId = inspected.runs[0]?.agentId;
|
||||
expect(agentId).toBeTruthy();
|
||||
expect(client.sessions).toHaveLength(1);
|
||||
expect(client.sessions[0]?.closed).toBe(true);
|
||||
expect(manager.getAgent(agentId!)).toBeNull();
|
||||
const storedAgent = await agentStorage.get(agentId!);
|
||||
expect(storedAgent?.archivedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("defaults new-agent modeId to provider's unattended mode", async () => {
|
||||
const manager = new AgentManager({
|
||||
logger: createTestLogger(),
|
||||
@@ -213,8 +414,9 @@ describe("ScheduleService", () => {
|
||||
const inspected = await service.inspect(created.id);
|
||||
const agentId = inspected.runs[0]?.agentId;
|
||||
expect(agentId).toBeTruthy();
|
||||
const agent = manager.getAgent(agentId!);
|
||||
expect(agent?.currentModeId).toBe("bypassPermissions");
|
||||
const agent = await agentStorage.get(agentId!);
|
||||
expect(agent?.lastModeId).toBe("bypassPermissions");
|
||||
expect(agent?.archivedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("advances stale nextRunAt on daemon restart", async () => {
|
||||
|
||||
@@ -545,7 +545,22 @@ export class ScheduleService {
|
||||
"paseo.schedule-run": runId,
|
||||
};
|
||||
const agent = await this.agentManager.createAgent(config, undefined, { labels });
|
||||
const result = await this.agentManager.runAgent(agent.id, wrappedPrompt);
|
||||
let result;
|
||||
try {
|
||||
result = await this.agentManager.runAgent(agent.id, wrappedPrompt);
|
||||
} catch (error) {
|
||||
try {
|
||||
await this.agentManager.archiveAgent(agent.id);
|
||||
} catch (archiveError) {
|
||||
this.logger.warn(
|
||||
{ err: archiveError, agentId: agent.id, scheduleId: schedule.id, runId },
|
||||
"Failed to archive scheduled agent after failed run",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.agentManager.archiveAgent(agent.id);
|
||||
const timelineText = curateAgentActivity(result.timeline);
|
||||
return {
|
||||
agentId: agent.id,
|
||||
|
||||
@@ -119,16 +119,17 @@ import {
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentPermissionResponse,
|
||||
AgentProvider,
|
||||
AgentPromptContentBlock,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
ProviderSnapshotEntry,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPermissionResponse,
|
||||
type AgentProvider,
|
||||
type AgentPromptContentBlock,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentSessionConfig,
|
||||
type AgentStreamEvent,
|
||||
type ProviderSnapshotEntry,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
@@ -545,6 +546,7 @@ export interface SessionOptions {
|
||||
daemonConfigStore: DaemonConfigStore;
|
||||
mcpBaseUrl?: string | null;
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
sttLanguage?: string;
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
terminalManager: TerminalManager | null;
|
||||
providerSnapshotManager?: ProviderSnapshotManager;
|
||||
@@ -571,6 +573,7 @@ export interface SessionOptions {
|
||||
dictation?: {
|
||||
finalTimeoutMs?: number;
|
||||
stt?: Resolvable<SpeechToTextProvider | null>;
|
||||
sttLanguage?: string;
|
||||
getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||
};
|
||||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||
@@ -782,6 +785,7 @@ export class Session {
|
||||
private registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
|
||||
private unregisterVoiceCallerContext?: (agentId: string) => void;
|
||||
private getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||
private readonly sttLanguage: string;
|
||||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
|
||||
private readonly isDev: boolean;
|
||||
@@ -813,6 +817,7 @@ export class Session {
|
||||
daemonConfigStore,
|
||||
mcpBaseUrl,
|
||||
stt,
|
||||
sttLanguage,
|
||||
tts,
|
||||
terminalManager,
|
||||
providerSnapshotManager,
|
||||
@@ -874,6 +879,7 @@ export class Session {
|
||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
||||
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
||||
this.sttLanguage = sttLanguage ?? "en";
|
||||
this.subscribeToOptionalManagers();
|
||||
this.bindVoiceBridges({ voice, voiceBridge, dictation });
|
||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||
@@ -889,13 +895,13 @@ export class Session {
|
||||
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
|
||||
});
|
||||
|
||||
this.initializePerSessionManagers({ tts, stt, dictation });
|
||||
this.initializePerSessionManagers({ tts, stt, sttLanguage, dictation });
|
||||
|
||||
// Initialize agent MCP client asynchronously
|
||||
void this.initializeAgentMcp();
|
||||
this.subscribeToAgentEvents();
|
||||
|
||||
this.sessionLogger.trace("Session created");
|
||||
this.sessionLogger.trace({}, "agent.session.lifecycle.created");
|
||||
}
|
||||
|
||||
updateAppVersion(appVersion: string | null): void {
|
||||
@@ -1017,15 +1023,20 @@ export class Session {
|
||||
private async interruptAgentIfRunning(agentId: string): Promise<void> {
|
||||
const snapshot = this.agentManager.getAgent(agentId);
|
||||
if (!snapshot) {
|
||||
this.sessionLogger.trace({ agentId }, "interruptAgentIfRunning: agent not found");
|
||||
this.sessionLogger.trace({ agentId }, "agent.session.interrupt.not_found");
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const hasInFlightRun = this.agentManager.hasInFlightRun(agentId);
|
||||
if (!hasInFlightRun) {
|
||||
this.sessionLogger.trace(
|
||||
{ agentId, lifecycle: snapshot.lifecycle, hasInFlightRun },
|
||||
"interruptAgentIfRunning: skipping because agent is not running",
|
||||
{
|
||||
agentId,
|
||||
provider: snapshot.provider,
|
||||
lifecycle: snapshot.lifecycle,
|
||||
hasInFlightRun,
|
||||
},
|
||||
"agent.session.interrupt.skip_not_running",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1070,7 +1081,7 @@ export class Session {
|
||||
promptType: typeof prompt === "string" ? "string" : "structured",
|
||||
hasRunOptions: Boolean(runOptions),
|
||||
},
|
||||
"startAgentStream: requested",
|
||||
"agent.session.start_stream.request",
|
||||
);
|
||||
let iterator: AsyncGenerator<AgentStreamEvent>;
|
||||
try {
|
||||
@@ -1080,7 +1091,7 @@ export class Session {
|
||||
: this.agentManager.streamAgent(agentId, prompt, runOptions);
|
||||
this.sessionLogger.trace(
|
||||
{ agentId, shouldReplace },
|
||||
"startAgentStream: agent iterator returned",
|
||||
"agent.session.start_stream.iterator_returned",
|
||||
);
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(agentId, error, "Failed to start agent run");
|
||||
@@ -1092,9 +1103,9 @@ export class Session {
|
||||
for await (const _ of iterator) {
|
||||
// Events are forwarded via the session's AgentManager subscription.
|
||||
}
|
||||
this.sessionLogger.trace({ agentId }, "startAgentStream: iterator drained");
|
||||
this.sessionLogger.trace({ agentId }, "agent.session.iterator.drained");
|
||||
} catch (error) {
|
||||
this.sessionLogger.trace({ agentId, err: error }, "startAgentStream: iterator threw");
|
||||
this.sessionLogger.trace({ agentId, err: error }, "agent.session.iterator.error");
|
||||
this.handleAgentRunError(agentId, error, "Agent stream failed");
|
||||
}
|
||||
})();
|
||||
@@ -1135,10 +1146,7 @@ export class Session {
|
||||
|
||||
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
|
||||
const agentToolCount = Object.keys(this.agentTools ?? {}).length;
|
||||
this.sessionLogger.trace(
|
||||
{ agentToolCount },
|
||||
`Agent MCP initialized with ${agentToolCount} tools`,
|
||||
);
|
||||
this.sessionLogger.trace({ agentToolCount }, "agent.session.mcp_init");
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, "Failed to initialize Agent MCP");
|
||||
}
|
||||
@@ -1188,16 +1196,20 @@ export class Session {
|
||||
private initializePerSessionManagers(params: {
|
||||
tts: SessionOptions["tts"];
|
||||
stt: SessionOptions["stt"];
|
||||
sttLanguage: SessionOptions["sttLanguage"];
|
||||
dictation: SessionOptions["dictation"];
|
||||
}): void {
|
||||
const { tts, stt, dictation } = params;
|
||||
const { tts, stt, sttLanguage, dictation } = params;
|
||||
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts);
|
||||
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt);
|
||||
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt, {
|
||||
language: sttLanguage,
|
||||
});
|
||||
this.dictationStreamManager = new DictationStreamManager({
|
||||
logger: this.sessionLogger,
|
||||
sessionId: this.sessionId,
|
||||
emit: (msg) => this.handleDictationManagerMessage(msg),
|
||||
stt: dictation?.stt ?? null,
|
||||
language: dictation?.sttLanguage,
|
||||
finalTimeoutMs: dictation?.finalTimeoutMs,
|
||||
});
|
||||
}
|
||||
@@ -1210,6 +1222,16 @@ export class Session {
|
||||
this.unsubscribeAgentEvents = this.agentManager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state") {
|
||||
this.sessionLogger.trace(
|
||||
{
|
||||
agentId: event.agent.id,
|
||||
provider: event.agent.provider,
|
||||
providerSessionId: event.agent.persistence?.sessionId ?? undefined,
|
||||
turnId: event.agent.activeForegroundTurnId ?? undefined,
|
||||
lifecycle: event.agent.lifecycle,
|
||||
},
|
||||
"agent.session.forward_update",
|
||||
);
|
||||
void this.forwardAgentUpdate(event.agent);
|
||||
return;
|
||||
}
|
||||
@@ -1260,6 +1282,17 @@ export class Session {
|
||||
if (!serializedEvent) {
|
||||
return;
|
||||
}
|
||||
this.sessionLogger.trace(
|
||||
{
|
||||
agentId: event.agentId,
|
||||
provider: event.event.provider,
|
||||
turnId: getAgentStreamEventTurnId(event.event),
|
||||
seq: event.seq,
|
||||
epoch: event.epoch,
|
||||
event: event.event,
|
||||
},
|
||||
"agent.session.forward_stream",
|
||||
);
|
||||
|
||||
const payload = {
|
||||
agentId: event.agentId,
|
||||
@@ -1612,8 +1645,11 @@ export class Session {
|
||||
}
|
||||
try {
|
||||
this.sessionLogger.trace(
|
||||
{ messageType: msg.type, payloadBytes: JSON.stringify(msg).length },
|
||||
"inbound message",
|
||||
{
|
||||
messageType: msg.type,
|
||||
payloadBytes: JSON.stringify(msg).length,
|
||||
},
|
||||
"agent.session.inbound",
|
||||
);
|
||||
try {
|
||||
await this.dispatchInboundMessage(msg);
|
||||
@@ -2738,6 +2774,7 @@ export class Session {
|
||||
logger: this.sessionLogger.child({ component: "voice-turn-controller" }),
|
||||
turnDetection,
|
||||
stt,
|
||||
sttLanguage: this.sttLanguage,
|
||||
callbacks: {
|
||||
onSpeechStarted: async () => {
|
||||
this.sessionLogger.debug("Voice VAD speech_started");
|
||||
@@ -3048,6 +3085,17 @@ export class Session {
|
||||
const { handle, overrides, requestId } = msg;
|
||||
if (!handle) {
|
||||
this.sessionLogger.warn("Resume request missing persistence handle");
|
||||
if (requestId) {
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: msg.type,
|
||||
error: "Unable to resume agent: missing persistence handle",
|
||||
code: "agent_resume_failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
@@ -3084,14 +3132,26 @@ export class Session {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.sessionLogger.error({ err: error }, "Failed to resume agent");
|
||||
if (requestId) {
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: msg.type,
|
||||
error: message,
|
||||
code: "agent_resume_failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Failed to resume agent: ${getErrorMessage(error)}`,
|
||||
content: `Failed to resume agent: ${message}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3212,14 +3272,26 @@ export class Session {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.sessionLogger.error({ err: error, agentId }, `Failed to refresh agent ${agentId}`);
|
||||
if (requestId) {
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: msg.type,
|
||||
error: message,
|
||||
code: "agent_refresh_failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Failed to refresh agent: ${getErrorMessage(error)}`,
|
||||
content: `Failed to refresh agent: ${message}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7180,8 +7252,12 @@ export class Session {
|
||||
|
||||
const prompt = this.buildAgentPrompt(msg.text, msg.images, msg.attachments);
|
||||
this.sessionLogger.trace(
|
||||
{ agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) },
|
||||
"send_agent_message_request: dispatching shared sendPromptToAgent",
|
||||
{
|
||||
agentId,
|
||||
messageId: msg.messageId,
|
||||
textPrefix: msg.text.slice(0, 80),
|
||||
},
|
||||
"agent.session.send_agent_message",
|
||||
);
|
||||
let dispatchResult: { outOfBand: boolean };
|
||||
try {
|
||||
@@ -7966,8 +8042,11 @@ export class Session {
|
||||
*/
|
||||
private emit(msg: SessionOutboundMessage): void {
|
||||
this.sessionLogger.trace(
|
||||
{ messageType: msg.type, payloadBytes: JSON.stringify(msg).length },
|
||||
"outbound message",
|
||||
{
|
||||
messageType: msg.type,
|
||||
payloadBytes: JSON.stringify(msg).length,
|
||||
},
|
||||
"agent.session.outbound",
|
||||
);
|
||||
if (
|
||||
msg.type === "audio_output" &&
|
||||
@@ -8035,7 +8114,7 @@ export class Session {
|
||||
* Clean up session resources
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
this.sessionLogger.trace("Cleaning up");
|
||||
this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup");
|
||||
|
||||
if (this.unsubscribeAgentEvents) {
|
||||
this.unsubscribeAgentEvents();
|
||||
|
||||
@@ -29,13 +29,21 @@ export interface LocalSpeechProviderConfig {
|
||||
|
||||
export interface ResolvedLocalSpeechConfig {
|
||||
local: LocalSpeechProviderConfig | undefined;
|
||||
sttLanguages: LocalSpeechSttLanguageConfig;
|
||||
}
|
||||
|
||||
export type { LocalSpeechModelId, LocalSttModelId, LocalTtsModelId };
|
||||
|
||||
const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech");
|
||||
const DEFAULT_STT_LANGUAGE = "en";
|
||||
|
||||
export interface LocalSpeechSttLanguageConfig {
|
||||
dictation: string;
|
||||
voice: string;
|
||||
}
|
||||
|
||||
const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]);
|
||||
const LanguageSchema = z.string().trim().min(1).default(DEFAULT_STT_LANGUAGE);
|
||||
|
||||
const OptionalFiniteNumberSchema = NumberLikeSchema.pipe(z.coerce.number().finite()).optional();
|
||||
|
||||
@@ -47,6 +55,8 @@ const LocalSpeechResolutionSchema = z.object({
|
||||
dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
||||
voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
||||
voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL),
|
||||
dictationLanguage: LanguageSchema,
|
||||
voiceLanguage: LanguageSchema,
|
||||
voiceLocalTtsSpeakerId: OptionalIntegerSchema,
|
||||
voiceLocalTtsSpeed: OptionalFiniteNumberSchema,
|
||||
});
|
||||
@@ -90,6 +100,37 @@ function firstDefinedValue<T>(values: Array<T | null | undefined>): T | undefine
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstNonEmptyString(values: Array<string | null | undefined>): string | undefined {
|
||||
for (const value of values) {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildLocalSpeechLanguageResolutionInput(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
persisted: PersistedConfig;
|
||||
}): Record<string, unknown> {
|
||||
const { env, persisted } = params;
|
||||
return {
|
||||
dictationLanguage: firstNonEmptyString([
|
||||
env.PASEO_DICTATION_LANGUAGE,
|
||||
persisted.features?.dictation?.stt?.language,
|
||||
DEFAULT_STT_LANGUAGE,
|
||||
]),
|
||||
voiceLanguage: firstNonEmptyString([
|
||||
env.PASEO_VOICE_LANGUAGE,
|
||||
env.PASEO_DICTATION_LANGUAGE,
|
||||
persisted.features?.voiceMode?.stt?.language,
|
||||
persisted.features?.dictation?.stt?.language,
|
||||
DEFAULT_STT_LANGUAGE,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function buildLocalSpeechResolutionInput(params: {
|
||||
paseoHome: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
@@ -132,6 +173,7 @@ function buildLocalSpeechResolutionInput(params: {
|
||||
),
|
||||
DEFAULT_LOCAL_TTS_MODEL,
|
||||
]),
|
||||
...buildLocalSpeechLanguageResolutionInput({ env, persisted }),
|
||||
voiceLocalTtsSpeakerId: firstDefinedValue<string | number>([
|
||||
env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID,
|
||||
persisted.features?.voiceMode?.tts?.speakerId,
|
||||
@@ -159,6 +201,10 @@ export function resolveLocalSpeechConfig(params: {
|
||||
(parsed.voiceLocalTtsModel === "kokoro-en-v0_19" ? 0 : undefined);
|
||||
|
||||
return {
|
||||
sttLanguages: {
|
||||
dictation: parsed.dictationLanguage,
|
||||
voice: parsed.voiceLanguage,
|
||||
},
|
||||
local: parsed.includeProviderConfig
|
||||
? {
|
||||
modelsDir: parsed.modelsDir,
|
||||
|
||||
@@ -51,6 +51,10 @@ describe("resolveSpeechConfig", () => {
|
||||
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v2-int8");
|
||||
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
|
||||
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0);
|
||||
expect(result.speech.sttLanguages).toEqual({
|
||||
dictation: "en",
|
||||
voice: "en",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves feature-scoped local model env vars", () => {
|
||||
@@ -71,6 +75,8 @@ describe("resolveSpeechConfig", () => {
|
||||
PASEO_VOICE_LOCAL_TTS_MODEL: "kitten",
|
||||
PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5",
|
||||
PASEO_VOICE_LOCAL_TTS_SPEED: "1.35",
|
||||
PASEO_DICTATION_LANGUAGE: "es",
|
||||
PASEO_VOICE_LANGUAGE: "pt",
|
||||
PASEO_LOCAL_MODELS_DIR: "/tmp/models",
|
||||
OPENAI_API_KEY: "env-key",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
@@ -119,10 +125,45 @@ describe("resolveSpeechConfig", () => {
|
||||
expect(result.speech.local?.models.voiceTts).toBe("kitten-nano-en-v0_1-fp16");
|
||||
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(5);
|
||||
expect(result.speech.local?.models.voiceTtsSpeed).toBe(1.35);
|
||||
expect(result.speech.sttLanguages).toEqual({
|
||||
dictation: "es",
|
||||
voice: "pt",
|
||||
});
|
||||
expect(result.openai?.apiKey).toBe("env-key");
|
||||
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
|
||||
});
|
||||
|
||||
test("resolves STT language from env, settings, and voice-to-dictation fallback", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
dictation: {
|
||||
stt: {
|
||||
language: "fr",
|
||||
},
|
||||
},
|
||||
voiceMode: {
|
||||
stt: {
|
||||
language: "de",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: {
|
||||
PASEO_DICTATION_LANGUAGE: "es",
|
||||
PASEO_VOICE_LANGUAGE: " ",
|
||||
} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
});
|
||||
|
||||
expect(result.speech.sttLanguages).toEqual({
|
||||
dictation: "es",
|
||||
voice: "es",
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores deprecated shared local model env vars", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
|
||||
@@ -173,6 +173,7 @@ export function resolveSpeechConfig(params: {
|
||||
openai,
|
||||
speech: {
|
||||
providers,
|
||||
sttLanguages: local.sttLanguages,
|
||||
...(local.local ? { local: local.local } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -67,7 +67,13 @@ function createStubTurnDetection(id: string): TurnDetectionProvider {
|
||||
}
|
||||
|
||||
function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpeechConfig {
|
||||
return { providers };
|
||||
return {
|
||||
providers,
|
||||
sttLanguages: {
|
||||
dictation: "en",
|
||||
voice: "en",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createSpeechService readiness", () => {
|
||||
|
||||
@@ -342,9 +342,11 @@ function resolveEffectiveProviderIds(params: {
|
||||
|
||||
export interface SpeechService {
|
||||
resolveStt: () => SpeechToTextProvider | null;
|
||||
resolveSttLanguage: () => string;
|
||||
resolveTts: () => TextToSpeechProvider | null;
|
||||
resolveTurnDetection: () => TurnDetectionProvider | null;
|
||||
resolveDictationStt: () => SpeechToTextProvider | null;
|
||||
resolveDictationSttLanguage: () => string;
|
||||
getReadiness: () => SpeechReadinessSnapshot;
|
||||
onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
|
||||
start: () => void;
|
||||
@@ -709,8 +711,10 @@ export function createSpeechService(params: {
|
||||
return {
|
||||
resolveTurnDetection: () => turnDetectionService,
|
||||
resolveStt: () => sttService,
|
||||
resolveSttLanguage: () => speechConfig?.sttLanguages?.voice ?? "en",
|
||||
resolveTts: () => ttsService,
|
||||
resolveDictationStt: () => dictationSttService,
|
||||
resolveDictationSttLanguage: () => speechConfig?.sttLanguages?.dictation ?? "en",
|
||||
getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
|
||||
onReadinessChange: subscribeSpeechReadiness,
|
||||
start,
|
||||
|
||||
@@ -81,10 +81,14 @@ function createFakeTurnDetectionProvider(session: FakeTurnDetectionSession): Tur
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeSttProvider(sessions: FakeSttSession[]): SpeechToTextProvider {
|
||||
function createFakeSttProvider(
|
||||
sessions: FakeSttSession[],
|
||||
captureLanguage?: (language: string | undefined) => void,
|
||||
): SpeechToTextProvider {
|
||||
return {
|
||||
id: "local",
|
||||
createSession() {
|
||||
createSession(params) {
|
||||
captureLanguage?.(params.language);
|
||||
const session = new FakeSttSession();
|
||||
sessions.push(session);
|
||||
return session;
|
||||
@@ -98,10 +102,13 @@ async function settleSerialQueue(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function createControllerHarness() {
|
||||
function createControllerHarness(options?: { sttLanguage?: string }) {
|
||||
const detector = new FakeTurnDetectionSession();
|
||||
const sttSessions: FakeSttSession[] = [];
|
||||
const stt = createFakeSttProvider(sttSessions);
|
||||
let lastSttLanguage: string | undefined;
|
||||
const stt = createFakeSttProvider(sttSessions, (language) => {
|
||||
lastSttLanguage = language;
|
||||
});
|
||||
const onSpeechStarted = vi.fn(async () => {});
|
||||
const onSpeechStopped = vi.fn(async () => {});
|
||||
const onPartialTranscript = vi.fn(
|
||||
@@ -123,6 +130,7 @@ function createControllerHarness() {
|
||||
logger: pino({ level: "silent" }),
|
||||
turnDetection: createFakeTurnDetectionProvider(detector),
|
||||
stt,
|
||||
sttLanguage: options?.sttLanguage,
|
||||
callbacks: {
|
||||
onSpeechStarted,
|
||||
onSpeechStopped,
|
||||
@@ -136,6 +144,7 @@ function createControllerHarness() {
|
||||
controller,
|
||||
detector,
|
||||
sttSessions,
|
||||
getLastSttLanguage: () => lastSttLanguage,
|
||||
onSpeechStarted,
|
||||
onSpeechStopped,
|
||||
onPartialTranscript,
|
||||
@@ -145,6 +154,16 @@ function createControllerHarness() {
|
||||
}
|
||||
|
||||
describe("voice turn controller", () => {
|
||||
it("passes configured language to streaming STT", async () => {
|
||||
const harness = createControllerHarness({ sttLanguage: "pt" });
|
||||
|
||||
await harness.controller.start();
|
||||
harness.detector.emit("speech_started");
|
||||
await settleSerialQueue();
|
||||
|
||||
expect(harness.getLastSttLanguage()).toBe("pt");
|
||||
});
|
||||
|
||||
it("forwards audio to the detector and streaming STT without submitting buffered utterances", async () => {
|
||||
const harness = createControllerHarness();
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ export function createVoiceTurnController(params: {
|
||||
logger: Logger;
|
||||
turnDetection: TurnDetectionProvider;
|
||||
stt: SpeechToTextProvider;
|
||||
sttLanguage?: string;
|
||||
callbacks: VoiceTurnControllerCallbacks;
|
||||
}): VoiceTurnController {
|
||||
const detector = params.turnDetection.createSession({
|
||||
@@ -319,7 +320,7 @@ export function createVoiceTurnController(params: {
|
||||
function createSttSession(): StreamingTranscriptionSession {
|
||||
const session = params.stt.createSession({
|
||||
logger: params.logger.child({ component: "stt" }),
|
||||
language: "en",
|
||||
language: params.sttLanguage ?? "en",
|
||||
});
|
||||
session.on("transcript", handleSttTranscript);
|
||||
session.on("committed", ({ segmentId }) => {
|
||||
|
||||
@@ -215,8 +215,17 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
|
||||
undefined,
|
||||
speechReadiness
|
||||
? {
|
||||
resolveStt: () => null,
|
||||
resolveSttLanguage: () => "en",
|
||||
resolveTts: () => null,
|
||||
resolveTurnDetection: () => null,
|
||||
resolveDictationStt: () => null,
|
||||
resolveDictationSttLanguage: () => "en",
|
||||
getReadiness: () => speechReadiness,
|
||||
onReadinessChange: vi.fn(() => () => {}),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
ready: Promise.resolve(),
|
||||
}
|
||||
: undefined,
|
||||
undefined,
|
||||
|
||||
@@ -878,6 +878,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
daemonConfigStore: this.daemonConfigStore,
|
||||
mcpBaseUrl: this.mcpBaseUrl,
|
||||
stt: () => this.speech?.resolveStt() ?? null,
|
||||
sttLanguage: this.speech?.resolveSttLanguage() ?? "en",
|
||||
tts: () => this.speech?.resolveTts() ?? null,
|
||||
terminalManager: this.terminalManager,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
@@ -910,6 +911,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
? {
|
||||
finalTimeoutMs: this.dictation?.finalTimeoutMs,
|
||||
stt: () => this.speech?.resolveDictationStt() ?? null,
|
||||
sttLanguage: this.speech?.resolveDictationSttLanguage() ?? "en",
|
||||
getSpeechReadiness: () => this.speech!.getReadiness(),
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.75",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -161,6 +161,7 @@ In the mobile app, enter the password in the direct connection setup screen.
|
||||
- `PASEO_LOCAL_MODELS_DIR`, control local model directory
|
||||
- `PASEO_DICTATION_LOCAL_STT_MODEL`, override local dictation STT model
|
||||
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, override local voice STT/TTS models
|
||||
- `PASEO_DICTATION_LANGUAGE`, `PASEO_VOICE_LANGUAGE`, override dictation and voice STT language
|
||||
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
|
||||
|
||||
## Schema
|
||||
|
||||
@@ -24,7 +24,7 @@ This keeps credentials and execution in your environment and avoids introducing
|
||||
|
||||
## Local Speech
|
||||
|
||||
Local speech defaults to model IDs `parakeet-tdt-0.6b-v3-int8` (STT) and `kokoro-en-v0_19` (TTS, speaker 0 / voice 00).
|
||||
Local speech defaults to model IDs `parakeet-tdt-0.6b-v3-int8` (STT) and `kokoro-en-v0_19` (TTS, speaker 0 / voice 00). STT language defaults to `en`.
|
||||
|
||||
Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files.
|
||||
|
||||
@@ -32,10 +32,12 @@ Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-s
|
||||
{
|
||||
"version": 1,
|
||||
"features": {
|
||||
"dictation": { "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8" } },
|
||||
"dictation": {
|
||||
"stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8", "language": "en" }
|
||||
},
|
||||
"voiceMode": {
|
||||
"llm": { "provider": "claude", "model": "haiku" },
|
||||
"stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8" },
|
||||
"stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8", "language": "en" },
|
||||
"tts": { "provider": "local", "model": "kokoro-en-v0_19", "speakerId": 0 }
|
||||
}
|
||||
},
|
||||
@@ -47,6 +49,8 @@ Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-s
|
||||
}
|
||||
```
|
||||
|
||||
Set `features.dictation.stt.language` for dictation and `features.voiceMode.stt.language` for realtime voice. If voice language is omitted, Paseo uses the dictation language before falling back to `en`.
|
||||
|
||||
## OpenAI Speech Option
|
||||
|
||||
You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider fields to `openai` and providing `OPENAI_API_KEY`.
|
||||
@@ -74,6 +78,8 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
|
||||
- `PASEO_LOCAL_MODELS_DIR`, local model storage directory
|
||||
- `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID
|
||||
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs
|
||||
- `PASEO_DICTATION_LANGUAGE`, dictation STT language
|
||||
- `PASEO_VOICE_LANGUAGE`, realtime voice STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset
|
||||
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
|
||||
|
||||
## Operational Notes
|
||||
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LOCK_FILE="$ROOT_DIR/package-lock.json"
|
||||
PACKAGE_NIX="$ROOT_DIR/nix/package.nix"
|
||||
HASH_FILE="$ROOT_DIR/nix/npm-deps.hash"
|
||||
|
||||
CHECK_MODE=false
|
||||
if [[ "${1:-}" == "--check" ]]; then
|
||||
@@ -42,8 +42,8 @@ if ! NEW_HASH="$(nix shell "${NIXPKGS_URL}#prefetch-npm-deps" -c prefetch-npm-de
|
||||
fi
|
||||
echo "Computed hash: $NEW_HASH"
|
||||
|
||||
# 3. Read current hash
|
||||
CURRENT_HASH="$(grep 'npmDepsHash' "$PACKAGE_NIX" | sed 's/.*"\(.*\)".*/\1/')"
|
||||
# 3. Read current hash from the sidecar file
|
||||
CURRENT_HASH="$(tr -d '[:space:]' < "$HASH_FILE")"
|
||||
|
||||
if [[ "$NEW_HASH" == "$CURRENT_HASH" ]]; then
|
||||
echo "Hash is already up to date."
|
||||
@@ -56,8 +56,7 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating npmDepsHash in nix/package.nix..."
|
||||
sed -i.bak "s|npmDepsHash = \".*\"|npmDepsHash = \"$NEW_HASH\"|" "$PACKAGE_NIX"
|
||||
rm -f "$PACKAGE_NIX.bak"
|
||||
echo "Updating nix/npm-deps.hash..."
|
||||
printf '%s\n' "$NEW_HASH" > "$HASH_FILE"
|
||||
echo "Updated: $CURRENT_HASH -> $NEW_HASH"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user