mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f6677fc7a | ||
|
|
972895d855 | ||
|
|
50acf2dd44 | ||
|
|
cb2e4aacfe | ||
|
|
117bbfe2f4 | ||
|
|
8bdd083512 | ||
|
|
f79468b793 | ||
|
|
018edef3bb | ||
|
|
44c32fb8fb | ||
|
|
e542854ae0 | ||
|
|
fba7c73c8b | ||
|
|
f55d0cd25f | ||
|
|
3319c2d40c | ||
|
|
bb93fc2a61 | ||
|
|
0d0f2826d9 | ||
|
|
d682c74469 | ||
|
|
f55e010a19 | ||
|
|
5f864a4dd7 | ||
|
|
6053ee7c34 | ||
|
|
3a303d2095 | ||
|
|
84079bfc36 |
17
.github/workflows/desktop-release.yml
vendored
17
.github/workflows/desktop-release.yml
vendored
@@ -20,6 +20,7 @@ jobs:
|
||||
publish-tauri:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
@@ -44,7 +45,7 @@ jobs:
|
||||
targets: aarch64-apple-darwin
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm install --workspace=@getpaseo/app --workspace=@getpaseo/desktop --include-workspace-root
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -66,28 +67,28 @@ jobs:
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /(\"version\"\\s*:\\s*\")([^\"]+)(\")/;
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) {
|
||||
throw new Error(`Failed to find version field in ${tauriConfPath}`);
|
||||
}
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\\r?\\n/);
|
||||
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false;
|
||||
let updated = false;
|
||||
const nextLines = cargoLines.map((line) => {
|
||||
if (/^\\[package\\]\\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\\[/.test(line)) inPackage = false;
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
|
||||
if (inPackage && /^version\\s*=\\s*\".*\"\\s*$/.test(line)) {
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = \"${version}\"`;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\\n')}\\n`);
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build and publish Tauri release
|
||||
|
||||
19
CLAUDE.md
19
CLAUDE.md
@@ -148,6 +148,25 @@ Use the Playwright MCP to test the app in Metro web. Navigate to `http://localho
|
||||
|
||||
Run `npx expo-doctor` to diagnose version mismatches and native module issues.
|
||||
|
||||
## Release playbook
|
||||
|
||||
Use the scripted release flow from repo root. Avoid manual version bumps or publish commands unless debugging.
|
||||
|
||||
```bash
|
||||
# 1) bump all workspaces and refresh workspace links
|
||||
npm run version:all:patch
|
||||
|
||||
# 2) run release gate checks (typecheck, build, pack dry-run)
|
||||
npm run release:check
|
||||
|
||||
# 3) publish relay/server/cli packages
|
||||
npm run release:publish
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `release:prepare` is part of the flow and refreshes workspace `node_modules` links to prevent stale local package types during release checks.
|
||||
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
|
||||
|
||||
## Orchestrator Mode
|
||||
|
||||
- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout.
|
||||
|
||||
573
package-lock.json
generated
573
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -56,6 +56,22 @@
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai": {
|
||||
"version": "2.0.52",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.52.tgz",
|
||||
"integrity": "sha512-n1arAo4+63e6/FFE6z/1ZsZbiOl4cfsoZ3F4i2X7LPIEea786Y2yd7Qdr7AdB4HTLVo3OSb1PHVIcQmvYIhmEA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider": {
|
||||
"version": "2.0.0",
|
||||
"license": "Apache-2.0",
|
||||
@@ -1385,6 +1401,27 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz",
|
||||
"integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.0.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz",
|
||||
"integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "1.0.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/kv-asset-handler": {
|
||||
"version": "0.4.1",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -3679,6 +3716,20 @@
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"dev": true,
|
||||
@@ -4157,6 +4208,446 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.5",
|
||||
"eventsource": "^3.0.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"hono": "^4.11.4",
|
||||
"jose": "^6.1.3",
|
||||
"json-schema-typed": "^8.0.2",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.25 || ^4.0",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cfworker/json-schema": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
"negotiator": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
"content-disposition": "^1.0.0",
|
||||
"content-type": "^1.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"cookie-signature": "^1.2.1",
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"finalhandler": "^2.1.0",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"merge-descriptors": "^2.0.0",
|
||||
"mime-types": "^3.0.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"once": "^1.4.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"qs": "^6.14.0",
|
||||
"range-parser": "^1.2.1",
|
||||
"router": "^2.2.0",
|
||||
"send": "^1.1.0",
|
||||
"serve-static": "^2.2.0",
|
||||
"statuses": "^2.0.1",
|
||||
"type-is": "^2.0.1",
|
||||
"vary": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"parseurl": "^1.3.3",
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"ms": "^2.1.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
"statuses": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"parseurl": "^1.3.3",
|
||||
"send": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"dev": true,
|
||||
@@ -11796,6 +12287,17 @@
|
||||
"version": "16.13.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
|
||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "7.0.2",
|
||||
"license": "ISC",
|
||||
@@ -12028,6 +12530,17 @@
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"license": "MIT",
|
||||
@@ -12784,6 +13297,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
||||
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"license": "MIT",
|
||||
@@ -12851,6 +13375,14 @@
|
||||
"version": "0.4.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-typed": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
@@ -19788,7 +20320,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -19796,7 +20328,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.2",
|
||||
"@getpaseo/server": "0.1.3",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -19897,10 +20429,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/relay": "0.1.2",
|
||||
"@getpaseo/server": "0.1.2",
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.3",
|
||||
"@getpaseo/server": "0.1.3",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -19950,14 +20483,14 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -19973,12 +20506,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/relay": "0.1.2",
|
||||
"@getpaseo/relay": "0.1.3",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
@@ -20026,22 +20559,6 @@
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@ai-sdk/openai": {
|
||||
"version": "2.0.52",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.52.tgz",
|
||||
"integrity": "sha512-n1arAo4+63e6/FFE6z/1ZsZbiOl4cfsoZ3F4i2X7LPIEea786Y2yd7Qdr7AdB4HTLVo3OSb1PHVIcQmvYIhmEA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.20.1",
|
||||
"license": "MIT",
|
||||
@@ -20345,7 +20862,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
@@ -33,10 +33,11 @@
|
||||
"build:desktop": "npm run build --workspace=@getpaseo/desktop",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"version:all:patch": "npm version patch --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
|
||||
"version:all:minor": "npm version minor --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
|
||||
"version:all:major": "npm version major --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
|
||||
"release:check": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||
"version:all:patch": "npm version patch --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare",
|
||||
"version:all:minor": "npm version minor --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare",
|
||||
"version:all:major": "npm version major --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -30,7 +30,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.2",
|
||||
"@getpaseo/server": "0.1.3",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
|
||||
@@ -343,7 +343,8 @@ export function ComboSelect({
|
||||
const anchorRef = useRef<View>(null);
|
||||
|
||||
const selectedOption = options.find((opt) => opt.id === value);
|
||||
const displayValue = selectedOption?.label ?? (value || "");
|
||||
const displayValue = selectedOption?.label ?? "";
|
||||
const isEmpty = options.length === 0;
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||
@@ -355,7 +356,7 @@ export function ComboSelect({
|
||||
value={displayValue}
|
||||
placeholder={placeholder}
|
||||
onPress={handleOpen}
|
||||
disabled={disabled}
|
||||
disabled={disabled || isEmpty}
|
||||
isLoading={isLoading}
|
||||
controlRef={anchorRef}
|
||||
icon={icon}
|
||||
@@ -567,8 +568,8 @@ export function AgentConfigRow({
|
||||
title="Select provider"
|
||||
value={selectedProvider}
|
||||
options={providerOptions}
|
||||
placeholder="Select..."
|
||||
disabled={disabled}
|
||||
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
|
||||
disabled={disabled || providerOptions.length === 0}
|
||||
onSelect={onSelectProvider}
|
||||
icon={<Bot size={16} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
|
||||
@@ -17,6 +17,14 @@ interface AgentStatusBarProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized || normalized.toLowerCase() === "default") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
@@ -61,14 +69,16 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRuntimeModelId = normalizeModelId(agent.runtimeInfo?.model);
|
||||
const normalizedConfiguredModelId = normalizeModelId(agent.model);
|
||||
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
|
||||
const selectedModel = useMemo(() => {
|
||||
if (!models || !agent.model) return null;
|
||||
return models.find((m) => m.id === agent.model) ?? null;
|
||||
}, [models, agent.model]);
|
||||
if (!models || !preferredModelId) return null;
|
||||
return models.find((m) => m.id === preferredModelId) ?? null;
|
||||
}, [models, preferredModelId]);
|
||||
|
||||
const displayModel = selectedModel
|
||||
? selectedModel.label
|
||||
: agent.model ?? "default";
|
||||
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
||||
const displayModel = selectedModel ? selectedModel.label : preferredModelId ?? "Auto";
|
||||
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||
const explicitThinkingId =
|
||||
@@ -156,7 +166,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
testID="agent-model-menu"
|
||||
>
|
||||
{models?.map((model) => {
|
||||
const isActive = model.id === agent.model;
|
||||
const isActive = model.id === activeModelId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
@@ -297,7 +307,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{models?.map((model) => {
|
||||
const isActive = model.id === agent.model;
|
||||
const isActive = model.id === activeModelId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
|
||||
@@ -262,26 +262,35 @@ function SidebarContent({
|
||||
const { theme } = useUnistyles();
|
||||
const { status } = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const isGit = status?.isGit ?? false;
|
||||
const hasResolvedCheckoutStatus = status !== null;
|
||||
|
||||
// Switch to Files tab if Changes tab is hidden and user was on it
|
||||
useEffect(() => {
|
||||
if (hasResolvedCheckoutStatus && !isGit && activeTab === "changes") {
|
||||
onTabPress("files");
|
||||
}
|
||||
}, [hasResolvedCheckoutStatus, isGit, activeTab, onTabPress]);
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header with tabs and close button */}
|
||||
<View style={styles.header} testID="explorer-header">
|
||||
<View style={styles.tabsContainer}>
|
||||
<Pressable
|
||||
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
|
||||
onPress={() => onTabPress("changes")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "changes" && styles.tabTextActive,
|
||||
!isGit && styles.tabTextMuted,
|
||||
]}
|
||||
{isGit && (
|
||||
<Pressable
|
||||
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
|
||||
onPress={() => onTabPress("changes")}
|
||||
>
|
||||
Changes
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "changes" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Changes
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
style={[styles.tab, activeTab === "files" && styles.tabActive]}
|
||||
onPress={() => onTabPress("files")}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -13,9 +14,13 @@ import {
|
||||
import { ScrollView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -33,6 +38,7 @@ import {
|
||||
FolderOpen,
|
||||
Image as ImageIcon,
|
||||
MoreVertical,
|
||||
RotateCw,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import type {
|
||||
@@ -263,6 +269,78 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
setSortOption(SORT_OPTIONS[nextIndex].value);
|
||||
}, [sortOption, setSortOption]);
|
||||
|
||||
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
|
||||
queryKey: ["fileExplorerRefresh", serverId, agentId],
|
||||
queryFn: async () => {
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directoryPaths = Array.from(expandedPaths);
|
||||
if (!directoryPaths.includes(".")) {
|
||||
directoryPaths.unshift(".");
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...directoryPaths.map((path) =>
|
||||
requestDirectoryListing(agentId, path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
})
|
||||
),
|
||||
...(selectedEntryPath ? [requestFilePreview(agentId, selectedEntryPath)] : []),
|
||||
]);
|
||||
return null;
|
||||
},
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetchExplorer();
|
||||
}, [refetchExplorer]);
|
||||
const refreshIconRotation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRefreshFetching) {
|
||||
refreshIconRotation.value = 0;
|
||||
refreshIconRotation.value = withRepeat(
|
||||
withTiming(360, {
|
||||
duration: 700,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimation(refreshIconRotation);
|
||||
const remainder = refreshIconRotation.value % 360;
|
||||
if (Math.abs(remainder) < 0.001) {
|
||||
refreshIconRotation.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = 360 - remainder;
|
||||
const duration = Math.max(80, Math.round((remaining / 360) * 700));
|
||||
refreshIconRotation.value = withTiming(
|
||||
360,
|
||||
{
|
||||
duration,
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished) {
|
||||
refreshIconRotation.value = 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [isRefreshFetching, refreshIconRotation]);
|
||||
|
||||
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
|
||||
}));
|
||||
|
||||
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
||||
|
||||
const treeRows = useMemo(() => {
|
||||
@@ -603,9 +681,27 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
</GestureDetector>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<View style={styles.paneHeaderLeft} />
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.paneHeaderRight}>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<FlatList
|
||||
style={styles.treeList}
|
||||
@@ -622,9 +718,27 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<View style={styles.paneHeaderLeft} />
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.paneHeaderRight}>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<FlatList
|
||||
style={styles.treeList}
|
||||
@@ -1023,6 +1137,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
paneHeaderRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
previewHeaderRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -1166,6 +1286,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButtonPressed: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 0.96 }],
|
||||
},
|
||||
refreshIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
previewContent: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -506,6 +506,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
});
|
||||
const {
|
||||
status: prStatus,
|
||||
githubFeaturesEnabled,
|
||||
payloadError: prPayloadError,
|
||||
refresh: refreshPrStatus,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
@@ -840,7 +841,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const diffErrorMessage =
|
||||
diffPayloadError?.message ??
|
||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
||||
const prErrorMessage = prPayloadError?.message ?? null;
|
||||
const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null;
|
||||
const branchLabel =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||
? gitStatus.currentBranch
|
||||
@@ -993,7 +994,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
}
|
||||
|
||||
// View PR - when PR exists
|
||||
if (hasPullRequest && prStatus?.url) {
|
||||
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
|
||||
const prUrl = prStatus.url;
|
||||
allActions.set("view-pr", {
|
||||
id: "view-pr",
|
||||
@@ -1008,7 +1009,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
}
|
||||
|
||||
// Create PR - when ahead of base and no PR
|
||||
if (aheadCount > 0 && !hasPullRequest) {
|
||||
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
|
||||
allActions.set("create-pr", {
|
||||
id: "create-pr",
|
||||
label: "Create PR",
|
||||
@@ -1112,7 +1113,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
|
||||
return { primary, secondary, menu };
|
||||
}, [
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
|
||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||
|
||||
@@ -687,7 +687,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
agentItemUnselected: {
|
||||
opacity: 0.75,
|
||||
opacity: 1,
|
||||
},
|
||||
agentItemSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -177,7 +177,7 @@ export function ToolCallDetailsContent({
|
||||
if (plainInputText !== null) {
|
||||
sections.push(
|
||||
<View key="unknown-plain-text" style={styles.plainTextSection}>
|
||||
<Text selectable style={styles.scrollText}>{plainInputText}</Text>
|
||||
<Text selectable style={styles.plainText}>{plainInputText}</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
@@ -298,6 +298,13 @@ const styles = StyleSheet.create((theme) => {
|
||||
gap: theme.spacing[2],
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
plainText: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 22,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -322,7 +322,6 @@ export function SessionProvider({
|
||||
const setPendingPermissions = useSessionStore(
|
||||
(state) => state.setPendingPermissions
|
||||
);
|
||||
const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
|
||||
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
|
||||
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
@@ -666,22 +665,6 @@ export function SessionProvider({
|
||||
[serverId, setFileExplorer]
|
||||
);
|
||||
|
||||
const gitDiffMutation = useMutation({
|
||||
mutationFn: async ({ agentId }: { agentId: string }) => {
|
||||
if (!agentId) {
|
||||
throw new Error("Agent id is required");
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.getGitDiff(agentId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return { agentId: payload.agentId, diff: payload.diff ?? "" };
|
||||
},
|
||||
});
|
||||
|
||||
const refreshAgentMutation = useMutation({
|
||||
mutationFn: async ({ agentId }: { agentId: string }) => {
|
||||
if (!agentId) {
|
||||
@@ -1275,15 +1258,6 @@ export function SessionProvider({
|
||||
return next;
|
||||
});
|
||||
|
||||
setGitDiffs(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
|
||||
setFileExplorer(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
@@ -1343,7 +1317,6 @@ export function SessionProvider({
|
||||
setAgents,
|
||||
setAgentLastActivity,
|
||||
setPendingPermissions,
|
||||
setGitDiffs,
|
||||
setFileExplorer,
|
||||
setHasHydratedAgents,
|
||||
updateConnectionStatus,
|
||||
@@ -1635,24 +1608,6 @@ export function SessionProvider({
|
||||
[]
|
||||
);
|
||||
|
||||
const requestGitDiff = useCallback(
|
||||
(agentId: string) => {
|
||||
gitDiffMutation
|
||||
.mutateAsync({ agentId })
|
||||
.then((result) => {
|
||||
setGitDiffs(serverId, (prev) =>
|
||||
new Map(prev).set(result.agentId, result.diff)
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setGitDiffs(serverId, (prev) =>
|
||||
new Map(prev).set(agentId, `Error: ${error.message}`)
|
||||
);
|
||||
});
|
||||
},
|
||||
[serverId, gitDiffMutation, setGitDiffs]
|
||||
);
|
||||
|
||||
const requestDirectoryListing = useCallback(
|
||||
(agentId: string, path: string, options?: { recordHistory?: boolean }) => {
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
|
||||
@@ -64,38 +64,63 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
const voiceTransportReadyRef = useRef(false);
|
||||
const voiceResyncInFlightRef = useRef(false);
|
||||
const silenceGraceStartMsRef = useRef<number | null>(null);
|
||||
const speechInterruptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const speechInterruptStartMsRef = useRef<number | null>(null);
|
||||
const speechStartInterruptSentRef = useRef(false);
|
||||
const isVoiceModeRef = useRef(false);
|
||||
const vadStateRef = useRef<{ isDetecting: boolean; isSpeaking: boolean }>({
|
||||
isDetecting: false,
|
||||
isSpeaking: false,
|
||||
});
|
||||
|
||||
const clearSpeechStartInterruptTimer = useCallback((reason: string) => {
|
||||
const timer = speechInterruptTimerRef.current;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
speechInterruptTimerRef.current = null;
|
||||
const startedAt = speechInterruptStartMsRef.current;
|
||||
speechInterruptStartMsRef.current = null;
|
||||
if (startedAt !== null) {
|
||||
console.log("[Voice] Cleared speech-start interrupt timer", {
|
||||
reason,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
});
|
||||
} else {
|
||||
console.log("[Voice] Cleared speech-start interrupt timer", { reason });
|
||||
}
|
||||
return;
|
||||
}
|
||||
speechInterruptStartMsRef.current = null;
|
||||
}, []);
|
||||
|
||||
const interruptActiveVoiceTurn = useCallback((source: string) => {
|
||||
const session = realtimeSessionRef.current;
|
||||
const sessionAudioPlayer = session?.audioPlayer ?? null;
|
||||
const sessionClient = session?.client ?? null;
|
||||
const sessionIsPlayingAudio = session?.isPlayingAudio ?? false;
|
||||
|
||||
if (sessionIsPlayingAudio && sessionAudioPlayer) {
|
||||
if (bargeInPlaybackStopRef.current === null) {
|
||||
bargeInPlaybackStopRef.current = Date.now();
|
||||
}
|
||||
sessionAudioPlayer.stop();
|
||||
}
|
||||
|
||||
try {
|
||||
if (sessionClient) {
|
||||
void sessionClient.abortRequest().catch((error) => {
|
||||
console.error("[Voice] Failed to send abort_request:", error);
|
||||
});
|
||||
}
|
||||
console.log("[Voice] Sent abort_request before streaming audio", { source });
|
||||
} catch (error) {
|
||||
console.error("[Voice] Failed to send abort_request:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const realtimeAudio = useSpeechmaticsAudio({
|
||||
onSpeechStart: () => {
|
||||
console.log("[Voice] Segment started (speech confirmed)");
|
||||
// Stop audio playback if playing
|
||||
const session = realtimeSessionRef.current;
|
||||
const sessionAudioPlayer = session?.audioPlayer ?? null;
|
||||
const sessionClient = session?.client ?? null;
|
||||
const sessionIsPlayingAudio = session?.isPlayingAudio ?? false;
|
||||
|
||||
if (sessionIsPlayingAudio && sessionAudioPlayer) {
|
||||
if (bargeInPlaybackStopRef.current === null) {
|
||||
bargeInPlaybackStopRef.current = Date.now();
|
||||
}
|
||||
sessionAudioPlayer.stop();
|
||||
}
|
||||
|
||||
// Abort any in-flight orchestrator turn before the new speech segment streams
|
||||
try {
|
||||
if (sessionClient) {
|
||||
void sessionClient.abortRequest().catch((error) => {
|
||||
console.error("[Voice] Failed to send abort_request:", error);
|
||||
});
|
||||
}
|
||||
console.log("[Voice] Sent abort_request before streaming audio");
|
||||
} catch (error) {
|
||||
console.error("[Voice] Failed to send abort_request:", error);
|
||||
}
|
||||
},
|
||||
onSpeechEnd: () => {
|
||||
const silenceMs =
|
||||
@@ -108,6 +133,8 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
console.log("[Voice] Segment finalized", { silenceMs });
|
||||
}
|
||||
silenceGraceStartMsRef.current = null;
|
||||
clearSpeechStartInterruptTimer("speech ended");
|
||||
speechStartInterruptSentRef.current = false;
|
||||
},
|
||||
onAudioSegment: ({ audioData, isLast }) => {
|
||||
if (!voiceTransportReadyRef.current) {
|
||||
@@ -158,6 +185,58 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
realtimeSessionRef.current = activeSession;
|
||||
}, [activeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
isVoiceModeRef.current = isVoiceMode;
|
||||
}, [isVoiceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVoiceMode) {
|
||||
clearSpeechStartInterruptTimer("voice mode disabled");
|
||||
speechStartInterruptSentRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (realtimeAudio.isSpeaking) {
|
||||
if (
|
||||
speechStartInterruptSentRef.current ||
|
||||
speechInterruptTimerRef.current !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
speechInterruptStartMsRef.current = Date.now();
|
||||
speechInterruptTimerRef.current = setTimeout(() => {
|
||||
speechInterruptTimerRef.current = null;
|
||||
speechInterruptStartMsRef.current = null;
|
||||
if (
|
||||
!isVoiceModeRef.current ||
|
||||
!vadStateRef.current.isSpeaking ||
|
||||
speechStartInterruptSentRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
speechStartInterruptSentRef.current = true;
|
||||
console.log("[Voice] Speech persisted beyond grace; interrupting turn", {
|
||||
graceMs: REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs,
|
||||
});
|
||||
interruptActiveVoiceTurn("speech_start_grace_elapsed");
|
||||
}, REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs);
|
||||
return;
|
||||
}
|
||||
|
||||
clearSpeechStartInterruptTimer("speech stopped before grace");
|
||||
}, [
|
||||
clearSpeechStartInterruptTimer,
|
||||
interruptActiveVoiceTurn,
|
||||
isVoiceMode,
|
||||
realtimeAudio.isSpeaking,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSpeechStartInterruptTimer("voice provider unmounted");
|
||||
};
|
||||
}, [clearSpeechStartInterruptTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = {
|
||||
isDetecting: realtimeAudio.isDetecting,
|
||||
@@ -187,6 +266,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
// Fully idle (neither detecting nor speaking).
|
||||
if (!next.isDetecting && !next.isSpeaking) {
|
||||
silenceGraceStartMsRef.current = null;
|
||||
speechStartInterruptSentRef.current = false;
|
||||
}
|
||||
|
||||
vadStateRef.current = next;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-form-state";
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
describe("useAgentFormState", () => {
|
||||
describe("__private__.combineInitialValues", () => {
|
||||
@@ -146,5 +153,127 @@ describe("useAgentFormState", () => {
|
||||
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("normalizes legacy model id 'default' from initial values to auto", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
{ model: "default" },
|
||||
{ provider: "codex" },
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes legacy model id 'default' from provider preferences to auto", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("");
|
||||
});
|
||||
|
||||
it("resolves provider only from allowed provider map", () => {
|
||||
const allowedProviderMap = new Map<AgentProvider, AgentProviderDefinition>(
|
||||
AGENT_PROVIDER_DEFINITIONS
|
||||
.filter((definition) => definition.id === "claude")
|
||||
.map((definition) => [definition.id as AgentProvider, definition])
|
||||
);
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
allowedProviderMap
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("claude");
|
||||
});
|
||||
|
||||
it("does not force fallback provider when allowed provider map is empty", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
new Map<AgentProvider, AgentProviderDefinition>()
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("codex");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,15 +89,25 @@ type UseAgentFormStateResult = {
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
};
|
||||
|
||||
const providerDefinitions = AGENT_PROVIDER_DEFINITIONS;
|
||||
const providerDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
|
||||
providerDefinitions.map((definition) => [definition.id, definition])
|
||||
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS;
|
||||
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
|
||||
allProviderDefinitions.map((definition) => [definition.id, definition])
|
||||
);
|
||||
const fallbackDefinition = providerDefinitions[0];
|
||||
const fallbackDefinition = allProviderDefinitions[0];
|
||||
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
|
||||
fallbackDefinition?.defaultModeId ?? "";
|
||||
|
||||
function normalizeSelectedModelId(
|
||||
modelId: string | null | undefined
|
||||
): string {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized || normalized.toLowerCase() === "default") {
|
||||
return "";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveDefaultModel(
|
||||
availableModels: AgentModelDefinition[] | null
|
||||
): AgentModelDefinition | null {
|
||||
@@ -136,25 +146,33 @@ function resolveFormState(
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
userModified: UserModifiedFields,
|
||||
currentState: FormState,
|
||||
validServerIds: Set<string>
|
||||
validServerIds: Set<string>,
|
||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap
|
||||
): FormState {
|
||||
// Start with current state - we only update non-user-modified fields
|
||||
const result = { ...currentState };
|
||||
const fallbackProvider = allowedProviderMap.keys().next().value as
|
||||
| AgentProvider
|
||||
| undefined;
|
||||
|
||||
// 1. Resolve provider first (other fields depend on it)
|
||||
if (!userModified.provider) {
|
||||
if (initialValues?.provider && providerDefinitionMap.has(initialValues.provider)) {
|
||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||
result.provider = initialValues.provider;
|
||||
} else if (
|
||||
preferences?.provider &&
|
||||
providerDefinitionMap.has(preferences.provider as AgentProvider)
|
||||
allowedProviderMap.has(preferences.provider as AgentProvider)
|
||||
) {
|
||||
result.provider = preferences.provider as AgentProvider;
|
||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
||||
result.provider = fallbackProvider;
|
||||
}
|
||||
// else keep current (initialized to DEFAULT_PROVIDER)
|
||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
||||
result.provider = fallbackProvider;
|
||||
}
|
||||
|
||||
const providerDef = providerDefinitionMap.get(result.provider);
|
||||
const providerDef = allowedProviderMap.get(result.provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[result.provider];
|
||||
|
||||
// 2. Resolve modeId (depends on provider)
|
||||
@@ -181,25 +199,24 @@ function resolveFormState(
|
||||
if (!userModified.model) {
|
||||
const isValidModel = (m: string) =>
|
||||
availableModels?.some((am) => am.id === m) ?? false;
|
||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
|
||||
if (
|
||||
typeof initialValues?.model === "string" &&
|
||||
initialValues.model.length > 0
|
||||
) {
|
||||
if (initialModel) {
|
||||
// If models aren't loaded yet, trust the initial value
|
||||
// It will be validated once models load
|
||||
if (!availableModels || isValidModel(initialValues.model)) {
|
||||
result.model = initialValues.model;
|
||||
} else if (providerPrefs?.model && isValidModel(providerPrefs.model)) {
|
||||
result.model = providerPrefs.model;
|
||||
if (!availableModels || isValidModel(initialModel)) {
|
||||
result.model = initialModel;
|
||||
} else if (preferredModel && isValidModel(preferredModel)) {
|
||||
result.model = preferredModel;
|
||||
} else {
|
||||
result.model = "";
|
||||
}
|
||||
} else if (typeof providerPrefs?.model === "string" && providerPrefs.model.length > 0) {
|
||||
} else if (preferredModel) {
|
||||
// If models haven't loaded yet, optimistically apply the stored preference.
|
||||
// We'll validate once models load and clear it if it isn't available.
|
||||
if (!availableModels || isValidModel(providerPrefs.model)) {
|
||||
result.model = providerPrefs.model;
|
||||
if (!availableModels || isValidModel(preferredModel)) {
|
||||
result.model = preferredModel;
|
||||
} else {
|
||||
result.model = "";
|
||||
}
|
||||
@@ -353,6 +370,45 @@ export function useAgentFormState(
|
||||
const client = sessionState?.client ?? null;
|
||||
const isConnected = sessionState?.connection?.isConnected ?? false;
|
||||
|
||||
const availableProvidersQuery = useQuery({
|
||||
queryKey: ["availableProviders", formState.serverId],
|
||||
enabled: Boolean(
|
||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listAvailableProviders();
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.providers
|
||||
.filter((entry) => entry.available)
|
||||
.map((entry) => entry.provider);
|
||||
},
|
||||
});
|
||||
|
||||
const providerDefinitions = useMemo(() => {
|
||||
const availableProviders = availableProvidersQuery.data;
|
||||
if (!availableProviders) {
|
||||
return [];
|
||||
}
|
||||
const available = new Set(availableProviders);
|
||||
return allProviderDefinitions.filter((definition) =>
|
||||
available.has(definition.id as AgentProvider)
|
||||
);
|
||||
}, [availableProvidersQuery.data]);
|
||||
|
||||
const providerDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
providerDefinitions.map((definition) => [definition.id as AgentProvider, definition])
|
||||
),
|
||||
[providerDefinitions]
|
||||
);
|
||||
|
||||
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const trimmed = formState.workingDir.trim();
|
||||
@@ -363,7 +419,14 @@ export function useAgentFormState(
|
||||
|
||||
const providerModelsQuery = useQuery({
|
||||
queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd],
|
||||
enabled: Boolean(isVisible && isTargetDaemonReady && formState.serverId && client && isConnected),
|
||||
enabled: Boolean(
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected &&
|
||||
providerDefinitionMap.has(formState.provider)
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
@@ -403,7 +466,8 @@ export function useAgentFormState(
|
||||
availableModels,
|
||||
userModified,
|
||||
formStateRef.current,
|
||||
validServerIds
|
||||
validServerIds,
|
||||
providerDefinitionMap
|
||||
);
|
||||
|
||||
// Only update if something changed
|
||||
@@ -428,6 +492,7 @@ export function useAgentFormState(
|
||||
availableModels,
|
||||
userModified,
|
||||
validServerIds,
|
||||
providerDefinitionMap,
|
||||
]);
|
||||
|
||||
// Auto-select the first online host when:
|
||||
@@ -500,11 +565,11 @@ export function useAgentFormState(
|
||||
...prev,
|
||||
provider,
|
||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
||||
model: providerPrefs?.model ?? "",
|
||||
model: normalizeSelectedModelId(providerPrefs?.model),
|
||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
||||
}));
|
||||
},
|
||||
[preferences?.providerPreferences, updatePreferences]
|
||||
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences]
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
@@ -518,9 +583,10 @@ export function useAgentFormState(
|
||||
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
setFormState((prev) => ({ ...prev, model: modelId }));
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
setFormState((prev) => ({ ...prev, model: normalizedModelId }));
|
||||
setUserModified((prev) => ({ ...prev, model: true }));
|
||||
void updateProviderPreferences(formState.provider, { model: modelId });
|
||||
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
|
||||
},
|
||||
[formState.provider, updateProviderPreferences]
|
||||
);
|
||||
@@ -639,6 +705,8 @@ export function useAgentFormState(
|
||||
setThinkingOptionFromUser,
|
||||
setWorkingDir,
|
||||
setWorkingDirFromUser,
|
||||
providerDefinitions,
|
||||
providerDefinitionMap,
|
||||
agentDefinition,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
|
||||
@@ -43,6 +43,7 @@ export function useCheckoutPrStatusQuery({
|
||||
|
||||
return {
|
||||
status: query.data?.status ?? null,
|
||||
githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true,
|
||||
payloadError: query.data?.error ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useFileExplorerActions(serverId: string) {
|
||||
);
|
||||
|
||||
const requestDirectoryListing = useCallback(
|
||||
(
|
||||
async (
|
||||
agentId: string,
|
||||
path: string,
|
||||
options?: { recordHistory?: boolean; setCurrentPath?: boolean }
|
||||
@@ -77,42 +77,40 @@ export function useFileExplorerActions(serverId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
void client
|
||||
.exploreFileSystem(agentId, normalizedPath, "list")
|
||||
.then((payload) => {
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: payload.error ?? null,
|
||||
pendingRequest: null,
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!payload.error && payload.directory) {
|
||||
const directories = new Map(state.directories);
|
||||
directories.set(payload.directory.path, payload.directory);
|
||||
nextState.directories = directories;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
try {
|
||||
const payload = await client.exploreFileSystem(agentId, normalizedPath, "list");
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: error instanceof Error ? error.message : "Failed to list directory",
|
||||
lastError: payload.error ?? null,
|
||||
pendingRequest: null,
|
||||
}));
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!payload.error && payload.directory) {
|
||||
const directories = new Map(state.directories);
|
||||
directories.set(payload.directory.path, payload.directory);
|
||||
nextState.directories = directories;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
} catch (error) {
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: error instanceof Error ? error.message : "Failed to list directory",
|
||||
pendingRequest: null,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[client, updateExplorerState]
|
||||
);
|
||||
|
||||
const requestFilePreview = useCallback(
|
||||
(agentId: string, path: string) => {
|
||||
async (agentId: string, path: string) => {
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
...state,
|
||||
@@ -131,36 +129,34 @@ export function useFileExplorerActions(serverId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
void client
|
||||
.exploreFileSystem(agentId, normalizedPath, "file")
|
||||
.then((payload) => {
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
pendingRequest: null,
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!payload.error && payload.file) {
|
||||
const files = new Map(state.files);
|
||||
files.set(payload.file.path, payload.file);
|
||||
nextState.files = files;
|
||||
} else if (payload.error) {
|
||||
nextState.lastError = payload.error;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
try {
|
||||
const payload = await client.exploreFileSystem(agentId, normalizedPath, "file");
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
pendingRequest: null,
|
||||
}));
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!payload.error && payload.file) {
|
||||
const files = new Map(state.files);
|
||||
files.set(payload.file.path, payload.file);
|
||||
nextState.files = files;
|
||||
} else if (payload.error) {
|
||||
nextState.lastError = payload.error;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
} catch {
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
pendingRequest: null,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[client, updateExplorerState]
|
||||
);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
const GIT_DIFF_STALE_TIME = 30_000;
|
||||
|
||||
function gitDiffQueryKey(serverId: string, agentId: string) {
|
||||
return ["gitDiff", serverId, agentId] as const;
|
||||
}
|
||||
|
||||
interface UseGitDiffQueryOptions {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: gitDiffQueryKey(serverId, agentId),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const response = await client.getGitDiff(agentId);
|
||||
return response.diff;
|
||||
},
|
||||
enabled: !!client && isConnected && !!agentId,
|
||||
staleTime: GIT_DIFF_STALE_TIME,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
// Revalidate when sidebar opens with "changes" tab active
|
||||
useEffect(() => {
|
||||
if (!isOpen || explorerTab !== "changes" || !agentId) {
|
||||
return;
|
||||
}
|
||||
// Invalidate to trigger background refetch (shows stale data while fetching)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: gitDiffQueryKey(serverId, agentId),
|
||||
});
|
||||
}, [isOpen, explorerTab, serverId, agentId, queryClient]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
}, [query]);
|
||||
|
||||
return {
|
||||
diff: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -785,6 +785,13 @@ export function DraftAgentScreen({
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: "No host selected" });
|
||||
throw new Error("No host selected");
|
||||
}
|
||||
if (providerDefinitions.length === 0) {
|
||||
dispatch({
|
||||
type: "DRAFT_SET_ERROR",
|
||||
message: "No available providers on the selected host",
|
||||
});
|
||||
throw new Error("No available providers on the selected host");
|
||||
}
|
||||
if (gitBlockingError) {
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: gitBlockingError });
|
||||
throw new Error(gitBlockingError);
|
||||
@@ -894,6 +901,7 @@ export function DraftAgentScreen({
|
||||
isDirectoryNotExists,
|
||||
isNonGitDirectory,
|
||||
modeOptions,
|
||||
providerDefinitions,
|
||||
persistFormPreferences,
|
||||
router,
|
||||
selectedMode,
|
||||
|
||||
@@ -201,9 +201,6 @@ export interface SessionState {
|
||||
// Permissions
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
|
||||
// Git diffs
|
||||
gitDiffs: Map<string, string>;
|
||||
|
||||
// File explorer
|
||||
fileExplorer: Map<string, AgentFileExplorerState>;
|
||||
|
||||
@@ -258,9 +255,6 @@ interface SessionStoreActions {
|
||||
// Permissions
|
||||
setPendingPermissions: (serverId: string, perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
|
||||
|
||||
// Git diffs
|
||||
setGitDiffs: (serverId: string, diffs: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => void;
|
||||
|
||||
// File explorer
|
||||
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
|
||||
|
||||
@@ -332,7 +326,6 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
|
||||
initializingAgents: new Map(),
|
||||
agents: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
gitDiffs: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
queuedMessages: new Map(),
|
||||
};
|
||||
@@ -745,28 +738,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
// Git diffs
|
||||
setGitDiffs: (serverId, diffs) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextDiffs = typeof diffs === "function" ? diffs(session.gitDiffs) : diffs;
|
||||
if (session.gitDiffs === nextDiffs) {
|
||||
return prev;
|
||||
}
|
||||
logSessionStoreUpdate("setGitDiffs", serverId, { count: nextDiffs.size });
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, gitDiffs: nextDiffs },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// File explorer
|
||||
setFileExplorer: (serverId, state) => {
|
||||
set((prev) => {
|
||||
|
||||
@@ -19,4 +19,13 @@ describe("extractAgentModel", () => {
|
||||
|
||||
expect(extractAgentModel(agent)).toBe("gpt-5.1-codex");
|
||||
});
|
||||
|
||||
it("treats legacy 'default' model ids as unset", () => {
|
||||
const agent = {
|
||||
model: "default",
|
||||
runtimeInfo: { model: "default" },
|
||||
} as Partial<Agent> as Agent;
|
||||
|
||||
expect(extractAgentModel(agent)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,17 @@ export function extractAgentModel(agent?: Agent | null): string | null {
|
||||
if (!agent) return null;
|
||||
const runtimeModel = agent.runtimeInfo?.model;
|
||||
const fallbackModel = agent.model;
|
||||
if (typeof runtimeModel === "string" && runtimeModel.trim().length > 0) {
|
||||
return runtimeModel.trim();
|
||||
if (typeof runtimeModel === "string") {
|
||||
const normalized = runtimeModel.trim();
|
||||
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
if (typeof fallbackModel === "string" && fallbackModel.trim().length > 0) {
|
||||
return fallbackModel.trim();
|
||||
if (typeof fallbackModel === "string") {
|
||||
const normalized = fallbackModel.trim();
|
||||
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Bot, Brain, Eye, Pencil, Search, SquareTerminal, Wrench } from "lucide-react-native";
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
Eye,
|
||||
MicVocal,
|
||||
Pencil,
|
||||
Search,
|
||||
SquareTerminal,
|
||||
Wrench,
|
||||
} from "lucide-react-native";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>;
|
||||
@@ -20,6 +29,9 @@ export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail):
|
||||
if (lowerName === "thinking" && (!detail || detail.type === "unknown")) {
|
||||
return Brain;
|
||||
}
|
||||
if (lowerName === "speak") {
|
||||
return MicVocal;
|
||||
}
|
||||
|
||||
if (detail) {
|
||||
return TOOL_DETAIL_ICONS[detail.type];
|
||||
|
||||
@@ -9,4 +9,6 @@ export const REALTIME_VOICE_VAD_CONFIG = {
|
||||
silenceDurationMs: 2000,
|
||||
speechConfirmationMs: 120,
|
||||
detectionGracePeriodMs: 700,
|
||||
// Delay speech-start interrupts to ignore transient noise triggers.
|
||||
interruptGracePeriodMs: 1000,
|
||||
} as const;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -21,8 +21,9 @@
|
||||
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/relay": "0.1.2",
|
||||
"@getpaseo/server": "0.1.2",
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.3",
|
||||
"@getpaseo/server": "0.1.3",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -16,7 +16,9 @@ import { runSendCommand } from './commands/agent/send.js'
|
||||
import { runInspectCommand } from './commands/agent/inspect.js'
|
||||
import { runWaitCommand } from './commands/agent/wait.js'
|
||||
import { runAttachCommand } from './commands/agent/attach.js'
|
||||
import { runUpdateCommand } from './commands/agent/update.js'
|
||||
import { withOutput } from './output/index.js'
|
||||
import { onboardCommand } from './commands/onboard.js'
|
||||
|
||||
const VERSION = '0.1.0'
|
||||
|
||||
@@ -42,11 +44,10 @@ export function createCli(): Command {
|
||||
// Primary agent commands (top-level)
|
||||
program
|
||||
.command('ls')
|
||||
.description('List agents. By default shows background agents (without ui=true) in current directory.')
|
||||
.option('-a, --all', 'Include all statuses (not just running)')
|
||||
.option('-g, --global', 'Show agents from all directories (not just current)')
|
||||
.description('List agents. By default excludes archived agents.')
|
||||
.option('-a, --all', 'Include archived agents')
|
||||
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
|
||||
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
||||
.option('--ui', 'Show only UI agents (equivalent to --label ui=true)')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runLsCommand))
|
||||
@@ -66,6 +67,7 @@ export function createCli(): Command {
|
||||
.option('--cwd <path>', 'Working directory (default: current)')
|
||||
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
|
||||
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
|
||||
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runRunCommand))
|
||||
@@ -126,7 +128,23 @@ export function createCli(): Command {
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runWaitCommand))
|
||||
|
||||
program
|
||||
.command('update')
|
||||
.description('Update an agent (alias for "paseo agent update")')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.option('--name <name>', "Update the agent's display name")
|
||||
.option(
|
||||
'--label <label>',
|
||||
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
|
||||
collectMultiple,
|
||||
[]
|
||||
)
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runUpdateCommand))
|
||||
|
||||
// Top-level local daemon shortcuts
|
||||
program.addCommand(onboardCommand())
|
||||
program.addCommand(daemonStartCommand())
|
||||
|
||||
program
|
||||
|
||||
@@ -9,6 +9,7 @@ import { runSendCommand } from './send.js'
|
||||
import { runInspectCommand } from './inspect.js'
|
||||
import { runWaitCommand } from './wait.js'
|
||||
import { runAttachCommand } from './attach.js'
|
||||
import { runUpdateCommand } from './update.js'
|
||||
import { withOutput } from '../../output/index.js'
|
||||
|
||||
export function createAgentCommand(): Command {
|
||||
@@ -22,11 +23,10 @@ export function createAgentCommand(): Command {
|
||||
// Primary agent commands (same as top-level)
|
||||
agent
|
||||
.command('ls')
|
||||
.description('List agents. By default shows background agents (without ui=true) in current directory.')
|
||||
.option('-a, --all', 'Include all statuses (not just running)')
|
||||
.option('-g, --global', 'Show agents from all directories (not just current)')
|
||||
.description('List agents. By default excludes archived agents.')
|
||||
.option('-a, --all', 'Include archived agents')
|
||||
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
|
||||
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
||||
.option('--ui', 'Show only UI agents (equivalent to --label ui=true)')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runLsCommand))
|
||||
@@ -43,6 +43,7 @@ export function createAgentCommand(): Command {
|
||||
.option('--cwd <path>', 'Working directory (default: current)')
|
||||
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
|
||||
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
|
||||
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runRunCommand))
|
||||
@@ -121,5 +122,20 @@ export function createAgentCommand(): Command {
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runArchiveCommand))
|
||||
|
||||
agent
|
||||
.command('update')
|
||||
.description("Update an agent's metadata")
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.option('--name <name>', "Update the agent's display name")
|
||||
.option(
|
||||
'--label <label>',
|
||||
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
|
||||
collectMultiple,
|
||||
[]
|
||||
)
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runUpdateCommand))
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
@@ -74,26 +74,22 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
export type AgentLsResult = ListResult<AgentListItem>
|
||||
|
||||
export interface AgentLsOptions extends CommandOptions {
|
||||
/** -a: Include all statuses (not just running/idle) */
|
||||
/** -a: Include archived agents */
|
||||
all?: boolean
|
||||
/** -g: Show agents globally (not just current directory) */
|
||||
/** Legacy flag retained for CLI compatibility */
|
||||
global?: boolean
|
||||
/** Filter by specific status */
|
||||
status?: string
|
||||
/** Filter by specific cwd (overrides default cwd filtering) */
|
||||
/** Filter by specific cwd */
|
||||
cwd?: string
|
||||
/** Filter by labels (key=value format) */
|
||||
label?: string[]
|
||||
/** Filter to UI agents only (equivalent to --label ui=true) */
|
||||
ui?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent ls command with correct semantics from design doc:
|
||||
* - `paseo agent ls` → running/idle agents in current directory
|
||||
* - `paseo agent ls -a` → all statuses in current directory
|
||||
* - `paseo agent ls -g` → running/idle agents globally
|
||||
* - `paseo agent ls -ag` → everything everywhere
|
||||
* Agent ls command semantics:
|
||||
* - `paseo agent ls` → all non-archived agents
|
||||
* - `paseo agent ls -a` → include archived agents
|
||||
*/
|
||||
export async function runLsCommand(
|
||||
options: AgentLsOptions,
|
||||
@@ -117,39 +113,26 @@ export async function runLsCommand(
|
||||
try {
|
||||
let agents = await client.fetchAgents()
|
||||
|
||||
// Status filtering:
|
||||
// By default, only show running/idle agents (not error, archived, etc.)
|
||||
// With -a flag, show all statuses
|
||||
// By default, exclude archived agents. `-a` includes them.
|
||||
if (!options.all) {
|
||||
agents = agents.filter((a) => {
|
||||
// Show running and idle agents, exclude archived
|
||||
return (a.status === 'running' || a.status === 'idle') && !a.archivedAt
|
||||
})
|
||||
agents = agents.filter((a) => !a.archivedAt)
|
||||
}
|
||||
|
||||
// If explicit status filter is provided, use it
|
||||
// If explicit status filter is provided, apply it.
|
||||
if (options.status) {
|
||||
agents = agents.filter((a) => a.status === options.status)
|
||||
}
|
||||
|
||||
// Directory filtering:
|
||||
// By default, only show agents in current working directory
|
||||
// With -g flag, show agents globally (all directories)
|
||||
if (!options.global) {
|
||||
const currentCwd = options.cwd ?? process.cwd()
|
||||
// Optional cwd filter.
|
||||
if (options.cwd) {
|
||||
const targetCwd = options.cwd.replace(/\/$/, '')
|
||||
agents = agents.filter((a) => {
|
||||
// Normalize paths for comparison
|
||||
const agentCwd = a.cwd.replace(/\/$/, '')
|
||||
const targetCwd = currentCwd.replace(/\/$/, '')
|
||||
// Match exact cwd or subdirectories
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
|
||||
})
|
||||
}
|
||||
|
||||
// Label filtering:
|
||||
// Parse --label flags and --ui flag
|
||||
// --ui is equivalent to --label ui=true
|
||||
// By default (no --ui flag), show background agents (those WITHOUT ui=true)
|
||||
// Parse --label filters (key=value).
|
||||
const labelFilters: Record<string, string> = {}
|
||||
if (options.label) {
|
||||
for (const labelStr of options.label) {
|
||||
@@ -161,14 +144,9 @@ export async function runLsCommand(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add ui=true filter if --ui flag is set
|
||||
if (options.ui) {
|
||||
labelFilters['ui'] = 'true'
|
||||
}
|
||||
|
||||
// Apply label filtering
|
||||
// Apply label filtering only when explicitly requested.
|
||||
if (Object.keys(labelFilters).length > 0) {
|
||||
// Filter to agents that have ALL specified labels (AND semantics)
|
||||
agents = agents.filter((a) => {
|
||||
const agentLabels = a.labels
|
||||
for (const [key, value] of Object.entries(labelFilters)) {
|
||||
@@ -178,11 +156,6 @@ export async function runLsCommand(
|
||||
}
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
// Default: show background agents only (those without ui=true)
|
||||
agents = agents.filter((a) => {
|
||||
return a.labels['ui'] !== 'true'
|
||||
})
|
||||
}
|
||||
|
||||
await client.close()
|
||||
|
||||
@@ -9,7 +9,7 @@ import { lookup } from 'mime-types'
|
||||
/** Result type for agent run command */
|
||||
export interface AgentRunResult {
|
||||
agentId: string
|
||||
status: 'created' | 'running'
|
||||
status: 'created' | 'running' | 'completed' | 'timeout' | 'permission' | 'error'
|
||||
provider: string
|
||||
cwd: string
|
||||
title: string | null
|
||||
@@ -39,24 +39,178 @@ export interface AgentRunOptions extends CommandOptions {
|
||||
cwd?: string
|
||||
label?: string[]
|
||||
ui?: boolean
|
||||
outputSchema?: string
|
||||
}
|
||||
|
||||
function toRunResult(agent: AgentSnapshotPayload): AgentRunResult {
|
||||
function toRunResult(
|
||||
agent: AgentSnapshotPayload,
|
||||
statusOverride?: AgentRunResult['status']
|
||||
): AgentRunResult {
|
||||
return {
|
||||
agentId: agent.id,
|
||||
status: agent.status === 'running' ? 'running' : 'created',
|
||||
status: statusOverride ?? (agent.status === 'running' ? 'running' : 'created'),
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
title: agent.title,
|
||||
}
|
||||
}
|
||||
|
||||
function loadOutputSchema(value: string): Record<string, unknown> {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OUTPUT_SCHEMA',
|
||||
message: '--output-schema cannot be empty',
|
||||
details: 'Provide a JSON schema file path or inline JSON object',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let source = trimmed
|
||||
if (!trimmed.startsWith('{')) {
|
||||
try {
|
||||
source = readFileSync(resolve(trimmed), 'utf8')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OUTPUT_SCHEMA',
|
||||
message: `Failed to read output schema file: ${trimmed}`,
|
||||
details: message,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(source)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OUTPUT_SCHEMA',
|
||||
message: 'Failed to parse output schema JSON',
|
||||
details: message,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OUTPUT_SCHEMA',
|
||||
message: 'Output schema must be a JSON object',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
function extractFirstJsonObject(text: string): string | null {
|
||||
const source = text.trim()
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startIndexes: number[] = []
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
if (source[i] === '{') {
|
||||
startIndexes.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
for (const start of startIndexes) {
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i]!
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '{') {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (ch === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
const candidate = source.slice(start, i + 1).trim()
|
||||
try {
|
||||
JSON.parse(candidate)
|
||||
return candidate
|
||||
} catch {
|
||||
// Keep scanning.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseStructuredOutput(lastMessage: string): Record<string, unknown> {
|
||||
const trimmed = lastMessage.trim()
|
||||
const fenced = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
|
||||
const jsonText = fenced?.[1]?.trim() ?? extractFirstJsonObject(trimmed) ?? trimmed
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(jsonText)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent response is not valid JSON',
|
||||
details: message,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent response JSON must be an object',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
|
||||
return {
|
||||
...agentRunSchema,
|
||||
serialize: () => output,
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRunCommand(
|
||||
prompt: string,
|
||||
options: AgentRunOptions,
|
||||
_command: Command
|
||||
): Promise<SingleResult<AgentRunResult>> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined
|
||||
|
||||
// Validate prompt is provided
|
||||
if (!prompt || prompt.trim().length === 0) {
|
||||
@@ -78,6 +232,16 @@ export async function runRunCommand(
|
||||
throw error
|
||||
}
|
||||
|
||||
// --output-schema always runs in attached/wait mode
|
||||
if (outputSchema && options.detach) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OPTIONS',
|
||||
message: '--output-schema cannot be used with --detach',
|
||||
details: 'Structured output requires waiting for the agent to finish',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let client
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
@@ -162,12 +326,75 @@ export async function runRunCommand(
|
||||
modeId: options.mode,
|
||||
model: options.model,
|
||||
initialPrompt: prompt,
|
||||
outputSchema,
|
||||
images,
|
||||
git,
|
||||
worktreeName: options.worktree,
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
})
|
||||
|
||||
if (outputSchema) {
|
||||
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
|
||||
|
||||
if (state.status === 'timeout') {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Timed out waiting for structured output',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (state.status === 'permission') {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent is waiting for permission before producing structured output',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: state.error ?? 'Agent failed before producing structured output',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const lastMessage = state.lastMessage?.trim()
|
||||
if (!lastMessage) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent finished without a structured output message',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const output = parseStructuredOutput(lastMessage)
|
||||
await client.close()
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
data: toRunResult(agent, 'completed'),
|
||||
schema: structuredRunSchema(output),
|
||||
}
|
||||
}
|
||||
|
||||
// Default run behavior is foreground: wait for completion unless --detach is set.
|
||||
if (!options.detach) {
|
||||
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
|
||||
await client.close()
|
||||
|
||||
const finalAgent = state.final ?? agent
|
||||
const status: AgentRunResult['status'] =
|
||||
state.status === 'idle' ? 'completed' : state.status
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
data: toRunResult(finalAgent, status),
|
||||
schema: agentRunSchema,
|
||||
}
|
||||
}
|
||||
|
||||
await client.close()
|
||||
|
||||
return {
|
||||
@@ -177,6 +404,11 @@ export async function runRunCommand(
|
||||
}
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_CREATE_FAILED',
|
||||
|
||||
177
packages/cli/src/commands/agent/update.ts
Normal file
177
packages/cli/src/commands/agent/update.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
|
||||
/** Result type for agent update command */
|
||||
export interface AgentUpdateResult {
|
||||
agentId: string
|
||||
name: string | null
|
||||
labels: string
|
||||
}
|
||||
|
||||
/** Schema for update command output */
|
||||
export const updateSchema: OutputSchema<AgentUpdateResult> = {
|
||||
idField: 'agentId',
|
||||
columns: [
|
||||
{ header: 'AGENT ID', field: 'agentId' },
|
||||
{ header: 'NAME', field: 'name' },
|
||||
{ header: 'LABELS', field: 'labels' },
|
||||
],
|
||||
}
|
||||
|
||||
export interface AgentUpdateOptions extends CommandOptions {
|
||||
name?: string
|
||||
label?: string[]
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>
|
||||
|
||||
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
|
||||
const parsed: Record<string, string> = {}
|
||||
if (!labels) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
for (const rawLabel of labels) {
|
||||
for (const segment of rawLabel.split(',')) {
|
||||
const label = segment.trim()
|
||||
if (!label) {
|
||||
continue
|
||||
}
|
||||
|
||||
const eqIndex = label.indexOf('=')
|
||||
if (eqIndex === -1) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_LABEL',
|
||||
message: `Invalid label format: ${label}`,
|
||||
details: 'Labels must be in key=value format',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const key = label.slice(0, eqIndex).trim()
|
||||
const value = label.slice(eqIndex + 1)
|
||||
if (!key) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_LABEL',
|
||||
message: `Invalid label format: ${label}`,
|
||||
details: 'Labels must include a non-empty key in key=value format',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
parsed[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function formatLabels(labels: Record<string, string>): string {
|
||||
const entries = Object.entries(labels)
|
||||
if (entries.length === 0) {
|
||||
return '-'
|
||||
}
|
||||
return entries.map(([key, value]) => `${key}=${value}`).join(',')
|
||||
}
|
||||
|
||||
export async function runUpdateCommand(
|
||||
agentIdArg: string,
|
||||
options: AgentUpdateOptions,
|
||||
_command: Command
|
||||
): Promise<AgentUpdateCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_AGENT_ID',
|
||||
message: 'Agent ID is required',
|
||||
details: 'Usage: paseo agent update <id> [--name <name>] [--label <key=value>]',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const name = options.name?.trim()
|
||||
if (options.name !== undefined && !name) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_NAME',
|
||||
message: 'Name cannot be empty',
|
||||
details: 'Use --name <name> with a non-empty value',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const labels = parseLabelOptions(options.label)
|
||||
if (!name && Object.keys(labels).length === 0) {
|
||||
const error: CommandError = {
|
||||
code: 'NO_CHANGES_PROVIDED',
|
||||
message: 'Nothing to update',
|
||||
details: 'Provide at least one of: --name <name>, --label <key=value>',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let client
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await client.fetchAgent(agentIdArg)
|
||||
if (!agent) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
message: `Agent not found: ${agentIdArg}`,
|
||||
details: 'Use "paseo ls" to list available agents',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const agentId = agent.id
|
||||
|
||||
await client.updateAgent(agentId, {
|
||||
...(name ? { name } : {}),
|
||||
...(Object.keys(labels).length > 0 ? { labels } : {}),
|
||||
})
|
||||
|
||||
const updated = await client.fetchAgent(agentId)
|
||||
if (!updated) {
|
||||
throw new Error(`Agent not found after update: ${agentId}`)
|
||||
}
|
||||
|
||||
await client.close()
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
data: {
|
||||
agentId,
|
||||
name: updated.title,
|
||||
labels: formatLabels(updated.labels),
|
||||
},
|
||||
schema: updateSchema,
|
||||
}
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
|
||||
// Re-throw CommandError as-is
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error: CommandError = {
|
||||
code: 'UPDATE_FAILED',
|
||||
message: `Failed to update agent: ${message}`,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
694
packages/cli/src/commands/onboard.ts
Normal file
694
packages/cli/src/commands/onboard.ts
Normal file
@@ -0,0 +1,694 @@
|
||||
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts'
|
||||
import { Command } from 'commander'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
ensureLocalSpeechModels,
|
||||
generateLocalPairingOffer,
|
||||
loadConfig,
|
||||
loadPersistedConfig,
|
||||
type LocalSpeechModelId,
|
||||
type CliConfigOverrides,
|
||||
type PersistedConfig,
|
||||
} from '@getpaseo/server'
|
||||
import {
|
||||
resolveLocalPaseoHome,
|
||||
resolveLocalDaemonState,
|
||||
resolveTcpHostFromListen,
|
||||
startLocalDaemonDetached,
|
||||
tailDaemonLog,
|
||||
type DaemonStartOptions,
|
||||
} from './daemon/local-daemon.js'
|
||||
import { tryConnectToDaemon } from '../utils/client.js'
|
||||
|
||||
interface OnboardOptions extends DaemonStartOptions {
|
||||
timeout?: string
|
||||
voice?: 'ask' | 'enable' | 'disable'
|
||||
}
|
||||
|
||||
type OnboardPersistedConfig = PersistedConfig & {
|
||||
providers?: PersistedConfig['providers'] & {
|
||||
local?: PersistedConfig['providers'] extends { local?: infer T } ? T : { autoDownload?: boolean }
|
||||
}
|
||||
features?: PersistedConfig['features'] & {
|
||||
dictation?: PersistedConfig['features'] extends { dictation?: infer T }
|
||||
? T & { enabled?: boolean }
|
||||
: { enabled?: boolean }
|
||||
voiceMode?: PersistedConfig['features'] extends { voiceMode?: infer T }
|
||||
? T & { enabled?: boolean }
|
||||
: { enabled?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
class OnboardCancelledError extends Error {}
|
||||
|
||||
const plainNoteFormat = (line: string): string => line
|
||||
|
||||
function renderNote(message: string, title: string): void {
|
||||
note(message, title, { format: plainNoteFormat })
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
function parseTimeoutMs(raw: string | undefined): number {
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
return DEFAULT_READY_TIMEOUT_MS
|
||||
}
|
||||
|
||||
const seconds = Number(raw)
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
throw new Error(`Invalid timeout value: ${raw}`)
|
||||
}
|
||||
|
||||
return Math.ceil(seconds * 1000)
|
||||
}
|
||||
|
||||
function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
|
||||
const cliOverrides: CliConfigOverrides = {}
|
||||
|
||||
if (options.listen) {
|
||||
cliOverrides.listen = options.listen
|
||||
} else if (options.port) {
|
||||
cliOverrides.listen = `127.0.0.1:${options.port}`
|
||||
}
|
||||
|
||||
if (options.relay === false) {
|
||||
cliOverrides.relayEnabled = false
|
||||
}
|
||||
|
||||
if (options.allowedHosts) {
|
||||
const raw = options.allowedHosts.trim()
|
||||
cliOverrides.allowedHosts =
|
||||
raw.toLowerCase() === 'true'
|
||||
? true
|
||||
: raw.split(',').map(host => host.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
if (options.mcp === false) {
|
||||
cliOverrides.mcpEnabled = false
|
||||
}
|
||||
|
||||
return cliOverrides
|
||||
}
|
||||
|
||||
function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void {
|
||||
const configPath = path.join(paseoHome, 'config.json')
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
|
||||
return {
|
||||
...config,
|
||||
providers: {
|
||||
...config.providers,
|
||||
local: {
|
||||
...config.providers?.local,
|
||||
autoDownload: enabled,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
...config.features,
|
||||
dictation: {
|
||||
...config.features?.dictation,
|
||||
enabled,
|
||||
},
|
||||
voiceMode: {
|
||||
...config.features?.voiceMode,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePersistedVoiceSelection(config: OnboardPersistedConfig): boolean | null {
|
||||
const voiceModeEnabled = config.features?.voiceMode?.enabled
|
||||
if (typeof voiceModeEnabled === 'boolean') {
|
||||
return voiceModeEnabled
|
||||
}
|
||||
|
||||
const dictationEnabled = config.features?.dictation?.enabled
|
||||
if (typeof dictationEnabled === 'boolean') {
|
||||
return dictationEnabled
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveVoiceSelection(mode: OnboardOptions['voice']): Promise<boolean> {
|
||||
if (mode === 'enable') {
|
||||
return true
|
||||
}
|
||||
if (mode === 'disable') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
log.message('Non-interactive terminal detected; voice setup defaults to disabled.')
|
||||
return false
|
||||
}
|
||||
|
||||
const answer = await confirm({
|
||||
message: 'Enable voice features? (downloads local STT/TTS models now)',
|
||||
active: 'Yes',
|
||||
inactive: 'No',
|
||||
initialValue: false,
|
||||
})
|
||||
|
||||
if (isCancel(answer)) {
|
||||
throw new OnboardCancelledError('Onboarding cancelled by user.')
|
||||
}
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
type DownloadProgress = {
|
||||
modelId: string | null
|
||||
pct: number | null
|
||||
}
|
||||
|
||||
type LocalModelDownloadProgress = {
|
||||
modelId: string | null
|
||||
pct: number | null
|
||||
}
|
||||
|
||||
type LocalSpeechDownloadLogger = {
|
||||
child: (_bindings: Record<string, unknown>) => LocalSpeechDownloadLogger
|
||||
info: (obj?: unknown, msg?: string) => void
|
||||
error: (_obj?: unknown, _msg?: string) => void
|
||||
}
|
||||
|
||||
type LocalSpeechDownloadEvent =
|
||||
| {
|
||||
type: 'progress'
|
||||
progress: LocalModelDownloadProgress
|
||||
}
|
||||
| {
|
||||
type: 'phase'
|
||||
phase: 'extracting' | 'verifying' | 'finalizing' | 'completed'
|
||||
}
|
||||
|
||||
function resolveRequiredLocalModelIds(config: ReturnType<typeof loadConfig>): LocalSpeechModelId[] {
|
||||
const providers = config.speech?.providers
|
||||
const local = config.speech?.local
|
||||
|
||||
if (!providers || !local) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ids = new Set<LocalSpeechModelId>()
|
||||
|
||||
if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === 'local') {
|
||||
ids.add(local.models.dictationStt)
|
||||
}
|
||||
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === 'local') {
|
||||
ids.add(local.models.voiceStt)
|
||||
}
|
||||
if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === 'local') {
|
||||
ids.add(local.models.voiceTts)
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
}
|
||||
|
||||
function parseLocalModelDownloadProgress(payload: unknown): LocalModelDownloadProgress | null {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = payload as Record<string, unknown>
|
||||
const modelId = typeof value.modelId === 'string' ? value.modelId : null
|
||||
const pctRaw = value.pct
|
||||
const pct = typeof pctRaw === 'number' && Number.isFinite(pctRaw) ? Math.max(0, Math.min(100, Math.floor(pctRaw))) : null
|
||||
|
||||
return {
|
||||
modelId,
|
||||
pct,
|
||||
}
|
||||
}
|
||||
|
||||
function renderLocalModelProgress(params: {
|
||||
modelId: LocalSpeechModelId
|
||||
modelIndex: number
|
||||
modelCount: number
|
||||
pct: number | null
|
||||
}): string {
|
||||
const prefix = `Downloading speech model ${params.modelIndex}/${params.modelCount}: ${params.modelId}`
|
||||
if (params.pct === null) {
|
||||
return `${prefix}...`
|
||||
}
|
||||
return `${prefix} (${params.pct}%)`
|
||||
}
|
||||
|
||||
function createLocalSpeechDownloadLogger(
|
||||
onEvent: (event: LocalSpeechDownloadEvent) => void
|
||||
): LocalSpeechDownloadLogger {
|
||||
const logger: LocalSpeechDownloadLogger = {
|
||||
child: () => logger,
|
||||
info: (obj?: unknown, msg?: string) => {
|
||||
if (msg === 'Downloading model artifact') {
|
||||
const progress = parseLocalModelDownloadProgress(obj)
|
||||
if (!progress) {
|
||||
return
|
||||
}
|
||||
onEvent({
|
||||
type: 'progress',
|
||||
progress,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (msg === 'Extracting model archive') {
|
||||
onEvent({ type: 'phase', phase: 'extracting' })
|
||||
return
|
||||
}
|
||||
if (msg === 'Verifying downloaded model files') {
|
||||
onEvent({ type: 'phase', phase: 'verifying' })
|
||||
return
|
||||
}
|
||||
if (msg === 'Finalizing model artifacts') {
|
||||
onEvent({ type: 'phase', phase: 'finalizing' })
|
||||
return
|
||||
}
|
||||
if (msg === 'Model download completed') {
|
||||
onEvent({ type: 'phase', phase: 'completed' })
|
||||
return
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// no-op: onboarding handles surfaced errors from ensureLocalSpeechModels.
|
||||
},
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
async function prepareLocalSpeechModelsBeforeStart(args: {
|
||||
config: ReturnType<typeof loadConfig>
|
||||
richUi: boolean
|
||||
}): Promise<void> {
|
||||
const local = args.config.speech?.local
|
||||
const modelIds = resolveRequiredLocalModelIds(args.config)
|
||||
|
||||
if (!local || modelIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (local.autoDownload === false) {
|
||||
log.warn('Local speech model auto-download is disabled. Voice may be unavailable until models are installed.')
|
||||
return
|
||||
}
|
||||
|
||||
const modelList = modelIds.join(', ')
|
||||
const modelCount = modelIds.length
|
||||
const downloadSpinner = args.richUi ? spinner() : null
|
||||
let lastPlainStatus = ''
|
||||
|
||||
const emitStatus = (status: string): void => {
|
||||
if (downloadSpinner) {
|
||||
downloadSpinner.message(status)
|
||||
return
|
||||
}
|
||||
if (status === lastPlainStatus) {
|
||||
return
|
||||
}
|
||||
console.log(status)
|
||||
lastPlainStatus = status
|
||||
}
|
||||
|
||||
if (downloadSpinner) {
|
||||
downloadSpinner.start(`Preparing local speech models (${modelCount})...`)
|
||||
} else {
|
||||
log.message(`Preparing local speech models (${modelCount}): ${modelList}`)
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [index, modelId] of modelIds.entries()) {
|
||||
const modelIndex = index + 1
|
||||
emitStatus(`Checking speech model ${modelIndex}/${modelCount}: ${modelId}`)
|
||||
|
||||
const perModelLogger = createLocalSpeechDownloadLogger((event) => {
|
||||
if (event.type === 'progress') {
|
||||
const progress = event.progress
|
||||
if (progress.modelId && progress.modelId !== modelId) {
|
||||
return
|
||||
}
|
||||
emitStatus(
|
||||
renderLocalModelProgress({
|
||||
modelId,
|
||||
modelIndex,
|
||||
modelCount,
|
||||
pct: progress.pct,
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.phase === 'extracting') {
|
||||
emitStatus(`Extracting speech model ${modelIndex}/${modelCount}: ${modelId}`)
|
||||
return
|
||||
}
|
||||
if (event.phase === 'verifying') {
|
||||
emitStatus(`Verifying speech model ${modelIndex}/${modelCount}: ${modelId}`)
|
||||
return
|
||||
}
|
||||
if (event.phase === 'finalizing') {
|
||||
emitStatus(`Finalizing speech model ${modelIndex}/${modelCount}: ${modelId}`)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
await ensureLocalSpeechModels({
|
||||
modelsDir: local.modelsDir,
|
||||
modelIds: [modelId],
|
||||
autoDownload: true,
|
||||
logger: perModelLogger as any,
|
||||
})
|
||||
|
||||
emitStatus(`Speech model ready ${modelIndex}/${modelCount}: ${modelId}`)
|
||||
}
|
||||
|
||||
if (downloadSpinner) {
|
||||
downloadSpinner.stop(`Local speech models ready (${modelCount})`)
|
||||
} else {
|
||||
log.message(`Local speech models ready (${modelCount}): ${modelList}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (downloadSpinner) {
|
||||
downloadSpinner.error(`Failed to prepare local speech models: ${message}`)
|
||||
} else {
|
||||
log.error(`Failed to prepare local speech models: ${message}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function parseDownloadProgress(logTail: string): DownloadProgress | null {
|
||||
const lines = logTail.split('\n').filter(Boolean)
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index]
|
||||
if (!line || !line.includes('Downloading model artifact')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/)
|
||||
const modelMatch = line.match(
|
||||
/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/
|
||||
)
|
||||
|
||||
return {
|
||||
modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null,
|
||||
pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function renderProgressLine(progress: DownloadProgress): string {
|
||||
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : ''
|
||||
if (progress.pct === null) {
|
||||
return `Downloading speech model${modelSuffix}...`
|
||||
}
|
||||
return `Downloading speech model${modelSuffix}: ${progress.pct}%`
|
||||
}
|
||||
|
||||
async function waitForDaemonReady(args: {
|
||||
home: string
|
||||
timeoutMs: number
|
||||
onStatus?: (message: string) => void
|
||||
}): Promise<{ listen: string; host: string | null }> {
|
||||
const deadline = Date.now() + args.timeoutMs
|
||||
let lastStatus = ''
|
||||
let lastPrintedAt = 0
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const state = resolveLocalDaemonState({ home: args.home })
|
||||
const host = resolveTcpHostFromListen(state.listen)
|
||||
|
||||
if (state.running && host) {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1200 })
|
||||
if (client) {
|
||||
try {
|
||||
await client.fetchAgents()
|
||||
return { listen: state.listen, host }
|
||||
} catch {
|
||||
// Daemon process is alive but not API-ready yet.
|
||||
} finally {
|
||||
await client.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
} else if (state.running && !host) {
|
||||
return { listen: state.listen, host: null }
|
||||
}
|
||||
|
||||
const progress = parseDownloadProgress(tailDaemonLog(args.home, 120) ?? '')
|
||||
const progressLine = progress ? renderProgressLine(progress) : null
|
||||
const statusMessage = progressLine ?? 'Waiting for daemon to become ready...'
|
||||
|
||||
if (statusMessage !== lastStatus) {
|
||||
args.onStatus?.(statusMessage)
|
||||
lastStatus = statusMessage
|
||||
lastPrintedAt = Date.now()
|
||||
} else if (!args.onStatus && Date.now() - lastPrintedAt >= 3000) {
|
||||
console.log(statusMessage)
|
||||
lastPrintedAt = Date.now()
|
||||
}
|
||||
|
||||
await sleep(200)
|
||||
}
|
||||
|
||||
const recentLogs = tailDaemonLog(args.home, 60)
|
||||
throw new Error(
|
||||
[
|
||||
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
|
||||
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
)
|
||||
}
|
||||
|
||||
function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void {
|
||||
const daemonLogPath = path.join(paseoHome, 'daemon.log')
|
||||
const nextStepsLines = [
|
||||
pairingUrl
|
||||
? '1. Open Paseo and scan the QR code above, or paste the pairing link.'
|
||||
: '1. Open Paseo and connect to your daemon.',
|
||||
'2. Web app: https://app.paseo.sh',
|
||||
'3. Desktop app: https://github.com/getpaseo/paseo/releases/latest',
|
||||
'4. Docs: https://paseo.sh/docs',
|
||||
'5. Example: paseo run --output-schema schema.json "extract fields"',
|
||||
]
|
||||
const quickReferenceLines = [
|
||||
'1. paseo --help',
|
||||
'2. paseo ls',
|
||||
'3. paseo run "your prompt"',
|
||||
'4. paseo status',
|
||||
`5. Daemon logs: ${daemonLogPath}`,
|
||||
]
|
||||
|
||||
if (!richUi) {
|
||||
console.log('')
|
||||
console.log('Next steps:')
|
||||
for (const line of nextStepsLines) {
|
||||
console.log(line)
|
||||
}
|
||||
console.log('')
|
||||
console.log('CLI quick reference:')
|
||||
for (const line of quickReferenceLines) {
|
||||
console.log(line)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
renderNote(nextStepsLines.join('\n'), 'Next steps')
|
||||
renderNote(quickReferenceLines.join('\n'), 'CLI quick reference')
|
||||
}
|
||||
|
||||
export function onboardCommand(): Command {
|
||||
return new Command('onboard')
|
||||
.description('Run first-time setup, start daemon, and print pairing instructions')
|
||||
.option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)')
|
||||
.option('--port <port>', 'Port to listen on (default: 6767)')
|
||||
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||
.option('--no-relay', 'Disable relay connection')
|
||||
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
|
||||
.option(
|
||||
'--allowed-hosts <hosts>',
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
|
||||
)
|
||||
.option('--timeout <seconds>', 'Max time to wait for daemon readiness (default: 600)')
|
||||
.option('--voice <mode>', 'Voice setup mode: ask, enable, disable', 'ask')
|
||||
.action(async (options: OnboardOptions) => {
|
||||
await runOnboard(options)
|
||||
})
|
||||
}
|
||||
|
||||
export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||
const richUi = process.stdin.isTTY && process.stdout.isTTY
|
||||
if (richUi) {
|
||||
intro('Welcome to Paseo')
|
||||
}
|
||||
|
||||
if (options.listen && options.port) {
|
||||
cancel('Cannot use --listen and --port together')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let timeoutMs = DEFAULT_READY_TIMEOUT_MS
|
||||
try {
|
||||
timeoutMs = parseTimeoutMs(options.timeout)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
cancel(message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const paseoHome = resolveLocalPaseoHome(options.home)
|
||||
if (richUi) {
|
||||
renderNote(paseoHome, 'Paseo home')
|
||||
}
|
||||
|
||||
let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig
|
||||
const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted)
|
||||
const shouldPrompt = options.voice === 'ask' || options.voice === undefined
|
||||
let voiceEnabled: boolean
|
||||
try {
|
||||
voiceEnabled =
|
||||
shouldPrompt && persistedVoiceSelection !== null
|
||||
? persistedVoiceSelection
|
||||
: await resolveVoiceSelection(options.voice)
|
||||
} catch (error) {
|
||||
if (error instanceof OnboardCancelledError) {
|
||||
cancel('Onboarding cancelled.')
|
||||
process.exit(0)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (shouldPrompt && persistedVoiceSelection !== null) {
|
||||
log.message(`Using saved voice setup from config (${voiceEnabled ? 'enabled' : 'disabled'}).`)
|
||||
}
|
||||
|
||||
persisted = applyVoiceSelection(persisted, voiceEnabled)
|
||||
savePersistedConfig(paseoHome, persisted)
|
||||
|
||||
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
|
||||
|
||||
const voiceStatus = voiceEnabled
|
||||
? 'Voice features enabled. Local speech models will be downloaded if missing.'
|
||||
: 'Voice features disabled. Local speech models will not be downloaded now.'
|
||||
log.message(voiceStatus)
|
||||
|
||||
try {
|
||||
await prepareLocalSpeechModelsBeforeStart({
|
||||
config,
|
||||
richUi,
|
||||
})
|
||||
} catch {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const stateBeforeStart = resolveLocalDaemonState({ home: options.home })
|
||||
const startSpinner = richUi ? spinner() : null
|
||||
|
||||
if (!stateBeforeStart.running) {
|
||||
try {
|
||||
if (startSpinner) {
|
||||
startSpinner.start('Starting daemon...')
|
||||
} else {
|
||||
log.message('Starting daemon...')
|
||||
}
|
||||
const startup = await startLocalDaemonDetached(options)
|
||||
if (startSpinner) {
|
||||
startSpinner.stop(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
|
||||
} else {
|
||||
log.message(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
|
||||
}
|
||||
log.message(`Logs: ${startup.logPath}`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (startSpinner) {
|
||||
startSpinner.error(message)
|
||||
} else {
|
||||
log.error(message)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? 'unknown'}).`)
|
||||
}
|
||||
|
||||
let readyState: { listen: string; host: string | null }
|
||||
const readySpinner = richUi ? spinner() : null
|
||||
try {
|
||||
if (readySpinner) {
|
||||
readySpinner.start('Waiting for daemon to become ready...')
|
||||
} else {
|
||||
log.message('Waiting for daemon to become ready...')
|
||||
}
|
||||
readyState = await waitForDaemonReady({
|
||||
home: options.home ?? paseoHome,
|
||||
timeoutMs,
|
||||
onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined,
|
||||
})
|
||||
if (readySpinner) {
|
||||
readySpinner.stop(`Daemon ready on ${readyState.listen}`)
|
||||
} else {
|
||||
log.message(`Daemon ready on ${readyState.listen}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (readySpinner) {
|
||||
readySpinner.error(message)
|
||||
} else {
|
||||
log.error(message)
|
||||
}
|
||||
process.exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (config.relayEnabled === false) {
|
||||
log.warn('Relay is disabled; pairing offer is unavailable for this daemon.')
|
||||
printNextSteps(null, paseoHome, richUi)
|
||||
if (richUi) {
|
||||
outro('Paseo daemon is running.')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
paseoHome,
|
||||
relayEnabled: config.relayEnabled,
|
||||
relayEndpoint: config.relayEndpoint,
|
||||
relayPublicEndpoint: config.relayPublicEndpoint,
|
||||
appBaseUrl: config.appBaseUrl,
|
||||
includeQr: true,
|
||||
})
|
||||
|
||||
if (!pairing.url) {
|
||||
log.warn('Relay pairing URL is unavailable for this daemon configuration.')
|
||||
printNextSteps(null, paseoHome, richUi)
|
||||
if (richUi) {
|
||||
outro('Paseo daemon is running.')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
renderNote(
|
||||
pairing.qr ?? 'QR is unavailable in this terminal. Use the pairing link below.',
|
||||
'Scan to pair'
|
||||
)
|
||||
renderNote(pairing.url, 'Pairing link')
|
||||
printNextSteps(pairing.url, paseoHome, richUi)
|
||||
if (richUi) {
|
||||
outro('Paseo is ready!')
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,6 @@ import { createCli } from './cli.js'
|
||||
|
||||
const program = createCli()
|
||||
if (process.argv.length <= 2) {
|
||||
process.argv.push('start')
|
||||
process.argv.push('onboard')
|
||||
}
|
||||
program.parse()
|
||||
|
||||
@@ -31,6 +31,18 @@ function normalizeFormat(raw: unknown): OutputOptions['format'] {
|
||||
|
||||
/** Extract output options from command options */
|
||||
function extractOutputOptions(options: CommandOptions): OutputOptions {
|
||||
const hasStructuredOutputSchema =
|
||||
typeof options.outputSchema === 'string' && options.outputSchema.trim().length > 0
|
||||
|
||||
if (hasStructuredOutputSchema) {
|
||||
return {
|
||||
format: 'json',
|
||||
quiet: false,
|
||||
noHeaders: options.headers === false,
|
||||
noColor: options.color === false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
format: options.json ? 'json' : normalizeFormat(options.format ?? defaultOutputOptions.format),
|
||||
quiet: options.quiet ?? defaultOutputOptions.quiet,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* - paseo ls --json returns valid JSON (or error)
|
||||
* - paseo ls -a flag is accepted
|
||||
* - paseo ls -g flag is accepted
|
||||
* - paseo ls does not support --ui
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
@@ -52,6 +53,7 @@ try {
|
||||
assert(result.stdout.includes('-g'), 'help should mention -g flag')
|
||||
assert(result.stdout.includes('--global'), 'help should mention --global flag')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(!result.stdout.includes('--ui'), 'help should not mention --ui')
|
||||
console.log('✓ paseo ls --help shows options\n')
|
||||
}
|
||||
|
||||
@@ -138,6 +140,17 @@ try {
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted\n')
|
||||
}
|
||||
|
||||
// Test 9: paseo ls --ui is rejected (flag removed)
|
||||
{
|
||||
console.log('Test 9: paseo ls --ui is rejected')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls --ui`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail for removed --ui flag')
|
||||
const output = result.stdout + result.stderr
|
||||
assert(output.includes('unknown option'), 'should report unknown option for --ui')
|
||||
console.log('✓ paseo ls --ui is rejected\n')
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
@@ -33,6 +33,18 @@ console.log('=== Run Command Tests ===\n')
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const schemaPath = join(paseoHome, 'run-output-schema.json')
|
||||
await writeFile(
|
||||
schemaPath,
|
||||
JSON.stringify({
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
},
|
||||
required: ['status'],
|
||||
additionalProperties: false,
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
// Test 1: run --help shows options
|
||||
@@ -46,6 +58,7 @@ try {
|
||||
assert(result.stdout.includes('--provider'), 'help should mention --provider option')
|
||||
assert(result.stdout.includes('--mode'), 'help should mention --mode option')
|
||||
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
|
||||
assert(result.stdout.includes('--output-schema'), 'help should mention --output-schema option')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<prompt>'), 'help should mention prompt argument')
|
||||
console.log('✓ run --help shows options\n')
|
||||
@@ -138,9 +151,34 @@ try {
|
||||
console.log('✓ run --cwd flag is accepted\n')
|
||||
}
|
||||
|
||||
// Test 9: -q (quiet) flag is accepted with run
|
||||
// Test 9: run --output-schema flag is accepted
|
||||
{
|
||||
console.log('Test 9: -q (quiet) flag is accepted with run')
|
||||
console.log('Test 9: run --output-schema flag is accepted')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --output-schema ${schemaPath} "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --output-schema flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ run --output-schema flag is accepted\n')
|
||||
}
|
||||
|
||||
// Test 10: run --output-schema cannot be used with --detach
|
||||
{
|
||||
console.log('Test 10: run --output-schema cannot be used with --detach')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d --output-schema ${schemaPath} "test prompt"`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail with --detach and --output-schema')
|
||||
const output = result.stdout + result.stderr
|
||||
assert(
|
||||
output.includes('--output-schema cannot be used with --detach'),
|
||||
'error should explain detach incompatibility'
|
||||
)
|
||||
console.log('✓ run --output-schema cannot be used with --detach\n')
|
||||
}
|
||||
|
||||
// Test 11: -q (quiet) flag is accepted with run
|
||||
{
|
||||
console.log('Test 11: -q (quiet) flag is accepted with run')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
@@ -149,9 +187,9 @@ try {
|
||||
console.log('✓ -q (quiet) flag is accepted with run\n')
|
||||
}
|
||||
|
||||
// Test 10: Combined flags work together
|
||||
// Test 12: Combined flags work together
|
||||
{
|
||||
console.log('Test 10: Combined flags work together')
|
||||
console.log('Test 12: Combined flags work together')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
@@ -160,9 +198,9 @@ try {
|
||||
console.log('✓ Combined flags work together\n')
|
||||
}
|
||||
|
||||
// Test 11: paseo --help shows run command
|
||||
// Test 13: paseo --help shows run command
|
||||
{
|
||||
console.log('Test 11: paseo --help shows run command')
|
||||
console.log('Test 13: paseo --help shows run command')
|
||||
const result = await $`npx paseo --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||
assert(result.stdout.includes('run'), 'help should mention run command')
|
||||
|
||||
132
packages/cli/tests/16-agent-update.test.ts
Normal file
132
packages/cli/tests/16-agent-update.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Phase 16: Agent Update Command Tests
|
||||
*
|
||||
* Tests the agent update command for metadata updates.
|
||||
* Since daemon may not be running, we test:
|
||||
* - Help and argument parsing
|
||||
* - Validation for required update fields
|
||||
* - Graceful daemon connection errors
|
||||
* - Top-level alias support (`paseo update`)
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
$.verbose = false
|
||||
|
||||
console.log('=== Agent Update Command Tests ===\n')
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
|
||||
try {
|
||||
// Test 1: agent update --help shows options
|
||||
{
|
||||
console.log('Test 1: agent update --help shows options')
|
||||
const result = await $`npx paseo agent update --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'agent update --help should exit 0')
|
||||
assert(result.stdout.includes('--name'), 'help should mention --name flag')
|
||||
assert(result.stdout.includes('--label'), 'help should mention --label flag')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
|
||||
console.log('✓ agent update --help shows options\n')
|
||||
}
|
||||
|
||||
// Test 2: agent update requires ID argument
|
||||
{
|
||||
console.log('Test 2: agent update requires ID argument')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update --name "New Name"`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||
const output = result.stdout + result.stderr
|
||||
const hasError =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument') ||
|
||||
output.toLowerCase().includes('id')
|
||||
assert(hasError, 'error should mention missing argument')
|
||||
console.log('✓ agent update requires ID argument\n')
|
||||
}
|
||||
|
||||
// Test 3: agent update requires at least one update field
|
||||
{
|
||||
console.log('Test 3: agent update requires update field')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without --name/--label')
|
||||
const output = result.stdout + result.stderr
|
||||
const hasError =
|
||||
output.toLowerCase().includes('nothing to update') ||
|
||||
output.toLowerCase().includes('name') ||
|
||||
output.toLowerCase().includes('label')
|
||||
assert(hasError, 'error should mention missing update fields')
|
||||
console.log('✓ agent update requires update field\n')
|
||||
}
|
||||
|
||||
// Test 4: agent update handles daemon not running
|
||||
{
|
||||
console.log('Test 4: agent update handles daemon not running')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name "Renamed Agent"`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error should mention connection issue')
|
||||
console.log('✓ agent update handles daemon not running\n')
|
||||
}
|
||||
|
||||
// Test 5: agent update accepts multiple/comma-separated labels
|
||||
{
|
||||
console.log('Test 5: agent update accepts multi-label syntax')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label ui=true,area=frontend --label priority=high`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --label flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ agent update accepts multi-label syntax\n')
|
||||
}
|
||||
|
||||
// Test 6: agent --help shows update subcommand
|
||||
{
|
||||
console.log('Test 6: agent --help shows update subcommand')
|
||||
const result = await $`npx paseo agent --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
|
||||
assert(result.stdout.includes('update'), 'help should mention update subcommand')
|
||||
console.log('✓ agent --help shows update subcommand\n')
|
||||
}
|
||||
|
||||
// Test 7: top-level update alias --help works
|
||||
{
|
||||
console.log('Test 7: top-level update --help works')
|
||||
const result = await $`npx paseo update --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'update --help should exit 0')
|
||||
assert(result.stdout.includes('--name'), 'help should mention --name flag')
|
||||
assert(result.stdout.includes('--label'), 'help should mention --label flag')
|
||||
console.log('✓ top-level update --help works\n')
|
||||
}
|
||||
|
||||
// Test 8: top-level update alias accepts flags
|
||||
{
|
||||
console.log('Test 8: top-level update alias accepts flags')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo update abc123 --name "Alias Name" --host localhost:${port}`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept top-level update flags')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ top-level update alias accepts flags\n')
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('=== All agent update tests passed ===')
|
||||
68
packages/cli/tests/17-onboard.test.ts
Normal file
68
packages/cli/tests/17-onboard.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { readFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { $ } from 'zx'
|
||||
|
||||
$.verbose = false
|
||||
|
||||
function randomPort(): number {
|
||||
return 10000 + Math.floor(Math.random() * 50000)
|
||||
}
|
||||
|
||||
console.log('=== Onboarding Command ===\n')
|
||||
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-onboard-home-'))
|
||||
const port = randomPort()
|
||||
|
||||
try {
|
||||
console.log('Test 1: `paseo` runs blocking onboarding and prints pairing info')
|
||||
const onboard =
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npm run -s cli --`.nothrow()
|
||||
|
||||
assert.strictEqual(onboard.exitCode, 0, `onboard should succeed: ${onboard.stderr}`)
|
||||
assert(onboard.stdout.includes('Scan to pair'), 'onboard output should include scan header')
|
||||
assert(onboard.stdout.includes('Pairing link'), 'onboard output should include pairing link header')
|
||||
assert(onboard.stdout.includes('#offer='), 'onboard output should include pairing offer URL')
|
||||
assert(onboard.stdout.includes('CLI quick reference'), 'onboard output should include CLI quick reference')
|
||||
assert(onboard.stdout.includes('paseo --help'), 'onboard output should include --help shortcut')
|
||||
assert(onboard.stdout.includes('paseo ls'), 'onboard output should include ls shortcut')
|
||||
assert(onboard.stdout.includes('paseo run "your prompt"'), 'onboard output should include run shortcut')
|
||||
assert(onboard.stdout.includes('paseo status'), 'onboard output should include status shortcut')
|
||||
assert(onboard.stdout.includes(join(paseoHome, 'daemon.log')), 'onboard output should include daemon log path')
|
||||
|
||||
const status =
|
||||
await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon status --home ${paseoHome}`.nothrow()
|
||||
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`)
|
||||
assert(status.stdout.includes('running'), 'daemon should be running when onboarding exits')
|
||||
console.log('✓ onboarding prints pairing info and waits for daemon readiness\n')
|
||||
|
||||
console.log('Test 2: non-interactive onboarding persists voice disabled config')
|
||||
const configRaw = await readFile(join(paseoHome, 'config.json'), 'utf-8')
|
||||
const config = JSON.parse(configRaw) as {
|
||||
features?: {
|
||||
dictation?: { enabled?: boolean }
|
||||
voiceMode?: { enabled?: boolean }
|
||||
}
|
||||
providers?: {
|
||||
local?: { autoDownload?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false')
|
||||
assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false')
|
||||
assert.strictEqual(config.providers?.local?.autoDownload, false, 'local.autoDownload should be false')
|
||||
const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8')
|
||||
assert(
|
||||
!daemonLog.includes('Ensuring local speech models'),
|
||||
'daemon should not attempt local speech model setup when voice is disabled'
|
||||
)
|
||||
console.log('✓ non-interactive run persisted voice disabled choices\n')
|
||||
} finally {
|
||||
await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon stop --home ${paseoHome} --force`.nothrow()
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('=== Onboarding tests passed ===')
|
||||
4
packages/desktop/package-lock.json
generated
4
packages/desktop/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "desktop",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "desktop",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Tauri wrapper)",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -53,7 +53,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/relay": "0.1.2",
|
||||
"@getpaseo/relay": "0.1.3",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
|
||||
@@ -2,35 +2,19 @@ import { fileURLToPath } from "url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
|
||||
function resolveWorkerEntry(): string {
|
||||
const candidates = [
|
||||
fileURLToPath(new URL("../server/server/index.js", import.meta.url)),
|
||||
fileURLToPath(new URL("../dist/server/server/index.js", import.meta.url)),
|
||||
fileURLToPath(new URL("../src/server/index.ts", import.meta.url)),
|
||||
fileURLToPath(new URL("../../src/server/index.ts", import.meta.url)),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function resolveWorkerExecArgv(): string[] {
|
||||
const workerEntry = resolveWorkerEntry();
|
||||
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
|
||||
const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
|
||||
if (!existsSync(WORKER_ENTRY)) {
|
||||
throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`);
|
||||
}
|
||||
|
||||
runSupervisor({
|
||||
name: "DevRunner",
|
||||
startupMessage: "Starting server worker (crash restarts enabled)",
|
||||
resolveWorkerEntry,
|
||||
resolveWorkerEntry: () => WORKER_ENTRY,
|
||||
workerArgs: process.argv.slice(2),
|
||||
workerEnv: process.env,
|
||||
workerExecArgv: resolveWorkerExecArgv(),
|
||||
// Always run worker with tsx so dev server uses TypeScript sources directly.
|
||||
workerExecArgv: ["--import", "tsx"],
|
||||
restartOnCrash: true,
|
||||
shutdownReasons: ["cli_shutdown"],
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
||||
|
||||
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
|
||||
expectTypeOf<"getHighlightedDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
|
||||
|
||||
function createMockLogger() {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
@@ -415,6 +418,60 @@ describe("DaemonClient", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("lists available providers via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.listAvailableProviders();
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: { type: "list_available_providers_request"; requestId: string };
|
||||
};
|
||||
expect(request.message.type).toBe("list_available_providers_request");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "list_available_providers_response",
|
||||
payload: {
|
||||
providers: [
|
||||
{ provider: "claude", available: true, error: null },
|
||||
{ provider: "codex", available: false, error: "Missing binary" },
|
||||
],
|
||||
error: null,
|
||||
fetchedAt: "2026-02-12T00:00:00.000Z",
|
||||
requestId: request.message.requestId,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
providers: [
|
||||
{ provider: "claude", available: true, error: null },
|
||||
{ provider: "codex", available: false, error: "Missing binary" },
|
||||
],
|
||||
error: null,
|
||||
fetchedAt: "2026-02-12T00:00:00.000Z",
|
||||
requestId: request.message.requestId,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses canonical agent_stream tool_call payloads without crashing", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -15,9 +15,7 @@ import type {
|
||||
CreateAgentRequestMessage,
|
||||
FileDownloadTokenResponse,
|
||||
FileExplorerResponse,
|
||||
GitDiffResponse,
|
||||
GitSetupOptions,
|
||||
HighlightedDiffResponse,
|
||||
CheckoutStatusResponse,
|
||||
CheckoutCommitResponse,
|
||||
CheckoutMergeResponse,
|
||||
@@ -32,6 +30,7 @@ import type {
|
||||
ListCommandsResponse,
|
||||
ExecuteCommandResponse,
|
||||
ListProviderModelsResponseMessage,
|
||||
ListAvailableProvidersResponse,
|
||||
SpeechModelsListResponse,
|
||||
SpeechModelsDownloadResponse,
|
||||
ListTerminalsResponse,
|
||||
@@ -171,6 +170,7 @@ export type CreateAgentRequestOptions = {
|
||||
provider?: AgentProvider;
|
||||
cwd?: string;
|
||||
initialPrompt?: string;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
images?: CreateAgentRequestMessage["images"];
|
||||
git?: GitSetupOptions;
|
||||
worktreeName?: string;
|
||||
@@ -178,8 +178,6 @@ export type CreateAgentRequestOptions = {
|
||||
labels?: Record<string, string>;
|
||||
} & AgentConfigOverrides;
|
||||
|
||||
type GitDiffPayload = GitDiffResponse["payload"];
|
||||
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
|
||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
type SubscribeCheckoutDiffPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
@@ -198,6 +196,7 @@ type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
||||
type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||
type SpeechModelsListPayload = SpeechModelsListResponse["payload"];
|
||||
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
|
||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||
@@ -228,6 +227,7 @@ export type WaitForFinishResult = {
|
||||
status: "idle" | "error" | "permission" | "timeout";
|
||||
final: AgentSnapshotPayload | null;
|
||||
error: string | null;
|
||||
lastMessage: string | null;
|
||||
};
|
||||
|
||||
type Waiter<T> = {
|
||||
@@ -988,6 +988,7 @@ export class DaemonClient {
|
||||
requestId,
|
||||
config,
|
||||
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
|
||||
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
|
||||
...(options.images && options.images.length > 0
|
||||
? { images: options.images }
|
||||
: {}),
|
||||
@@ -1074,6 +1075,40 @@ export class DaemonClient {
|
||||
return { archivedAt: result.archivedAt };
|
||||
}
|
||||
|
||||
async updateAgent(
|
||||
agentId: string,
|
||||
updates: { name?: string; labels?: Record<string, string> }
|
||||
): Promise<void> {
|
||||
const requestId = this.createRequestId();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "update_agent_request",
|
||||
agentId,
|
||||
...(updates.name !== undefined ? { name: updates.name } : {}),
|
||||
...(updates.labels && Object.keys(updates.labels).length > 0
|
||||
? { labels: updates.labels }
|
||||
: {}),
|
||||
requestId,
|
||||
});
|
||||
const payload = await this.sendRequest({
|
||||
requestId,
|
||||
message,
|
||||
timeout: 10000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "update_agent_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== requestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
if (!payload.accepted) {
|
||||
throw new Error(payload.error ?? "updateAgent rejected");
|
||||
}
|
||||
}
|
||||
|
||||
async resumeAgent(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>
|
||||
@@ -1873,60 +1908,6 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getGitDiff(
|
||||
agentId: string,
|
||||
requestId?: string
|
||||
): Promise<GitDiffPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "git_diff_request",
|
||||
agentId,
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
timeout: 10000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "git_diff_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== resolvedRequestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getHighlightedDiff(
|
||||
agentId: string,
|
||||
requestId?: string
|
||||
): Promise<HighlightedDiffPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "highlighted_diff_request",
|
||||
agentId,
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
timeout: 10000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "highlighted_diff_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== resolvedRequestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async validateBranch(
|
||||
options: { cwd: string; branchName: string },
|
||||
requestId?: string
|
||||
@@ -2078,6 +2059,31 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listAvailableProviders(options?: {
|
||||
requestId?: string;
|
||||
}): Promise<ListAvailableProvidersPayload> {
|
||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "list_available_providers_request",
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
timeout: 30000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "list_available_providers_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== resolvedRequestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listSpeechModels(requestId?: string): Promise<SpeechModelsListPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
@@ -2284,6 +2290,7 @@ export class DaemonClient {
|
||||
status: payload.status,
|
||||
final: payload.final,
|
||||
error: payload.error,
|
||||
lastMessage: payload.lastMessage,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,28 @@ describe("AgentManager", () => {
|
||||
expect(snapshot.model).toBeUndefined();
|
||||
});
|
||||
|
||||
test("normalizeConfig strips legacy 'default' model id", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000102",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
model: "default",
|
||||
});
|
||||
|
||||
expect(snapshot.model).toBeUndefined();
|
||||
});
|
||||
|
||||
test("createAgent fails when cwd does not exist", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { AGENT_PROVIDER_IDS } from "./provider-manifest.js";
|
||||
|
||||
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
|
||||
|
||||
@@ -53,6 +54,12 @@ export type AgentAttentionCallback = (params: {
|
||||
reason: "finished" | "error" | "permission";
|
||||
}) => void;
|
||||
|
||||
export type ProviderAvailability = {
|
||||
provider: AgentProvider;
|
||||
available: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type AgentManagerOptions = {
|
||||
clients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||
maxTimelineItems?: number;
|
||||
@@ -333,6 +340,42 @@ export class AgentManager {
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async listProviderAvailability(): Promise<ProviderAvailability[]> {
|
||||
const checks = AGENT_PROVIDER_IDS.map(async (providerId) => {
|
||||
const provider = providerId as AgentProvider;
|
||||
const client = this.clients.get(provider);
|
||||
if (!client) {
|
||||
return {
|
||||
provider,
|
||||
available: false,
|
||||
error: `No client registered for provider '${provider}'`,
|
||||
} satisfies ProviderAvailability;
|
||||
}
|
||||
|
||||
try {
|
||||
const available = await client.isAvailable();
|
||||
return {
|
||||
provider,
|
||||
available,
|
||||
error: null,
|
||||
} satisfies ProviderAvailability;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(
|
||||
{ err: error, provider },
|
||||
"Failed to check provider availability"
|
||||
);
|
||||
return {
|
||||
provider,
|
||||
available: false,
|
||||
error: message,
|
||||
} satisfies ProviderAvailability;
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(checks);
|
||||
}
|
||||
|
||||
getAgent(id: string): ManagedAgent | null {
|
||||
const agent = this.agents.get(id);
|
||||
return agent ? { ...agent } : null;
|
||||
@@ -527,6 +570,16 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
async setLabels(
|
||||
agentId: string,
|
||||
labels: Record<string, string>
|
||||
): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
agent.labels = { ...agent.labels, ...labels };
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
notifyAgentState(agentId: string): void {
|
||||
const agent = this.agents.get(agentId);
|
||||
if (!agent || agent.internal) {
|
||||
@@ -1450,7 +1503,9 @@ export class AgentManager {
|
||||
|
||||
if (typeof normalized.model === "string") {
|
||||
const trimmed = normalized.model.trim();
|
||||
normalized.model = trimmed.length > 0 ? trimmed : undefined;
|
||||
const normalizedId = trimmed.toLowerCase();
|
||||
normalized.model =
|
||||
trimmed.length > 0 && normalizedId !== "default" ? trimmed : undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
|
||||
@@ -1300,16 +1300,21 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.activeSidechains.set(parentToolUseId, toolName);
|
||||
|
||||
const toolCall = mapClaudeRunningToolCall({
|
||||
name: "Task",
|
||||
callId: parentToolUseId,
|
||||
input: null,
|
||||
output: null,
|
||||
metadata: { subAgentActivity: toolName },
|
||||
});
|
||||
if (!toolCall) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: "timeline",
|
||||
item: mapClaudeRunningToolCall({
|
||||
name: "Task",
|
||||
callId: parentToolUseId,
|
||||
input: null,
|
||||
output: null,
|
||||
metadata: { subAgentActivity: toolName },
|
||||
}),
|
||||
item: toolCall,
|
||||
provider: "claude",
|
||||
},
|
||||
];
|
||||
@@ -1497,14 +1502,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (toolName === "ExitPlanMode" && typeof input.plan === "string") {
|
||||
metadata.planText = input.plan;
|
||||
}
|
||||
const detail =
|
||||
const toolDetail =
|
||||
kind === "tool"
|
||||
? mapClaudeRunningToolCall({
|
||||
name: toolName,
|
||||
callId: options.toolUseID ?? requestId,
|
||||
input,
|
||||
output: null,
|
||||
}).detail
|
||||
})?.detail
|
||||
: undefined;
|
||||
|
||||
const request: AgentPermissionRequest = {
|
||||
@@ -1513,7 +1518,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
name: toolName,
|
||||
kind,
|
||||
input,
|
||||
detail,
|
||||
detail: toolDetail,
|
||||
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
|
||||
metadata: Object.keys(metadata).length ? metadata : undefined,
|
||||
};
|
||||
@@ -1591,9 +1596,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private pushToolCall(
|
||||
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
|
||||
item: Extract<AgentTimelineItem, { type: "tool_call" }> | null,
|
||||
target?: AgentTimelineItem[]
|
||||
) {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (target) {
|
||||
target.push(item);
|
||||
return;
|
||||
|
||||
@@ -19,15 +19,58 @@ import {
|
||||
toolDetailBranchByName,
|
||||
} from "../tool-call-detail-primitives.js";
|
||||
|
||||
const ClaudeKnownToolDetailSchema = z.union([
|
||||
const ClaudeToolEnvelopeSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
input: z.unknown().nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ClaudeSpeakToolDetailSchema = z
|
||||
.object({
|
||||
name: z.literal("speak"),
|
||||
input: z
|
||||
.union([
|
||||
z.string().transform((text) => ({ text })),
|
||||
z.object({ text: z.string() }).passthrough(),
|
||||
])
|
||||
.nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
})
|
||||
.transform(({ input }) => {
|
||||
const text = input?.text?.trim() ?? "";
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: "unknown",
|
||||
input: text,
|
||||
output: null,
|
||||
} satisfies ToolCallDetail;
|
||||
});
|
||||
|
||||
const ClaudeToolDetailPass2Schema = z.union([
|
||||
toolDetailBranchByName("Bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByName("Read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
|
||||
toolDetailBranchByName("read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
|
||||
toolDetailBranchByName("read_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
|
||||
toolDetailBranchByName("view_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
|
||||
toolDetailBranchByName("Read", ToolReadInputSchema, z.unknown(), (input, output) => {
|
||||
const parsedOutput = ToolReadOutputSchema.safeParse(output);
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
|
||||
}),
|
||||
toolDetailBranchByName("read", ToolReadInputSchema, z.unknown(), (input, output) => {
|
||||
const parsedOutput = ToolReadOutputSchema.safeParse(output);
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
|
||||
}),
|
||||
toolDetailBranchByName("read_file", ToolReadInputSchema, z.unknown(), (input, output) => {
|
||||
const parsedOutput = ToolReadOutputSchema.safeParse(output);
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
|
||||
}),
|
||||
toolDetailBranchByName("view_file", ToolReadInputSchema, z.unknown(), (input, output) => {
|
||||
const parsedOutput = ToolReadOutputSchema.safeParse(output);
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
|
||||
}),
|
||||
toolDetailBranchByName("Write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
toolDetailBranchByName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
toolDetailBranchByName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
@@ -38,7 +81,12 @@ const ClaudeKnownToolDetailSchema = z.union([
|
||||
toolDetailBranchByName("edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByName("str_replace_editor", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByName(
|
||||
"str_replace_editor",
|
||||
ToolEditInputSchema,
|
||||
ToolEditOutputSchema,
|
||||
toEditToolDetail
|
||||
),
|
||||
toolDetailBranchByName("WebSearch", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
),
|
||||
@@ -48,6 +96,7 @@ const ClaudeKnownToolDetailSchema = z.union([
|
||||
toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
),
|
||||
ClaudeSpeakToolDetailSchema,
|
||||
]);
|
||||
|
||||
export function deriveClaudeToolDetail(
|
||||
@@ -55,17 +104,27 @@ export function deriveClaudeToolDetail(
|
||||
input: unknown,
|
||||
output: unknown
|
||||
): ToolCallDetail {
|
||||
const parsed = ClaudeKnownToolDetailSchema.safeParse({
|
||||
const pass1 = ClaudeToolEnvelopeSchema.safeParse({
|
||||
name,
|
||||
input,
|
||||
output,
|
||||
});
|
||||
if (parsed.success && parsed.data) {
|
||||
return parsed.data;
|
||||
}
|
||||
return {
|
||||
type: "unknown",
|
||||
input: input ?? null,
|
||||
output: output ?? null,
|
||||
});
|
||||
if (!pass1.success) {
|
||||
return {
|
||||
type: "unknown",
|
||||
input: input ?? null,
|
||||
output: output ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const pass2 = ClaudeToolDetailPass2Schema.safeParse(pass1.data);
|
||||
if (pass2.success && pass2.data) {
|
||||
return pass2.data;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "unknown",
|
||||
input: pass1.data.input,
|
||||
output: pass1.data.output,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,14 +6,24 @@ import {
|
||||
mapClaudeRunningToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
|
||||
function expectMapped<T>(item: T | null): T {
|
||||
expect(item).toBeTruthy();
|
||||
if (!item) {
|
||||
throw new Error("Expected mapped tool call");
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
describe("claude tool-call mapper", () => {
|
||||
it("maps running shell calls with canonical fields", () => {
|
||||
const item = mapClaudeRunningToolCall({
|
||||
callId: "claude-call-1",
|
||||
name: "Bash",
|
||||
input: { command: "pwd", cwd: "/tmp/repo" },
|
||||
output: null,
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapClaudeRunningToolCall({
|
||||
callId: "claude-call-1",
|
||||
name: "Bash",
|
||||
input: { command: "pwd", cwd: "/tmp/repo" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.type).toBe("tool_call");
|
||||
expect(item.status).toBe("running");
|
||||
@@ -27,45 +37,53 @@ describe("claude tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps running known tool variants with detail for early summaries", () => {
|
||||
const readItem = mapClaudeRunningToolCall({
|
||||
callId: "claude-running-read",
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: null,
|
||||
});
|
||||
const readItem = expectMapped(
|
||||
mapClaudeRunningToolCall({
|
||||
callId: "claude-running-read",
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(readItem.detail).toEqual({
|
||||
type: "read",
|
||||
filePath: "README.md",
|
||||
});
|
||||
|
||||
const writeItem = mapClaudeRunningToolCall({
|
||||
callId: "claude-running-write",
|
||||
name: "write_file",
|
||||
input: { file_path: "src/new.ts" },
|
||||
output: null,
|
||||
});
|
||||
const writeItem = expectMapped(
|
||||
mapClaudeRunningToolCall({
|
||||
callId: "claude-running-write",
|
||||
name: "write_file",
|
||||
input: { file_path: "src/new.ts" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(writeItem.detail).toEqual({
|
||||
type: "write",
|
||||
filePath: "src/new.ts",
|
||||
});
|
||||
|
||||
const editItem = mapClaudeRunningToolCall({
|
||||
callId: "claude-running-edit",
|
||||
name: "apply_patch",
|
||||
input: { file_path: "src/index.ts" },
|
||||
output: null,
|
||||
});
|
||||
const editItem = expectMapped(
|
||||
mapClaudeRunningToolCall({
|
||||
callId: "claude-running-edit",
|
||||
name: "apply_patch",
|
||||
input: { file_path: "src/index.ts" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(editItem.detail).toEqual({
|
||||
type: "edit",
|
||||
filePath: "src/index.ts",
|
||||
});
|
||||
|
||||
const searchItem = mapClaudeRunningToolCall({
|
||||
callId: "claude-running-search",
|
||||
name: "web_search",
|
||||
input: { query: "tool call mapping" },
|
||||
output: null,
|
||||
});
|
||||
const searchItem = expectMapped(
|
||||
mapClaudeRunningToolCall({
|
||||
callId: "claude-running-search",
|
||||
name: "web_search",
|
||||
input: { query: "tool call mapping" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "tool call mapping",
|
||||
@@ -73,12 +91,14 @@ describe("claude tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps completed read calls with detail enrichment", () => {
|
||||
const item = mapClaudeCompletedToolCall({
|
||||
callId: "claude-call-2",
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: { content: "hello" },
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
callId: "claude-call-2",
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: { content: "hello" },
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
expect(item.error).toBeNull();
|
||||
@@ -91,33 +111,37 @@ describe("claude tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("preserves read content from array/object output variants", () => {
|
||||
const arrayContent = mapClaudeCompletedToolCall({
|
||||
callId: "claude-read-array",
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
content: [
|
||||
{ type: "output_text", text: "alpha" },
|
||||
{ type: "output_text", content: "beta" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const arrayContent = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
callId: "claude-read-array",
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
content: [
|
||||
{ type: "output_text", text: "alpha" },
|
||||
{ type: "output_text", content: "beta" },
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(arrayContent.detail?.type).toBe("read");
|
||||
if (arrayContent.detail?.type === "read") {
|
||||
expect(arrayContent.detail.content).toBe("alpha\nbeta");
|
||||
}
|
||||
|
||||
const objectContent = mapClaudeCompletedToolCall({
|
||||
callId: "claude-read-object",
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
structured_content: {
|
||||
content: { type: "output_text", text: "gamma" },
|
||||
const objectContent = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
callId: "claude-read-object",
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
structured_content: {
|
||||
content: { type: "output_text", text: "gamma" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
expect(objectContent.detail?.type).toBe("read");
|
||||
if (objectContent.detail?.type === "read") {
|
||||
@@ -126,13 +150,15 @@ describe("claude tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps failed calls with required error", () => {
|
||||
const item = mapClaudeFailedToolCall({
|
||||
callId: "claude-call-3",
|
||||
name: "shell",
|
||||
input: { command: "false" },
|
||||
output: null,
|
||||
error: { message: "Command failed" },
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapClaudeFailedToolCall({
|
||||
callId: "claude-call-3",
|
||||
name: "shell",
|
||||
input: { command: "false" },
|
||||
output: null,
|
||||
error: { message: "Command failed" },
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.status).toBe("failed");
|
||||
expect(item.error).toEqual({ message: "Command failed" });
|
||||
@@ -140,35 +166,41 @@ describe("claude tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps write/edit/search known shapes with distinct detail types", () => {
|
||||
const writeItem = mapClaudeCompletedToolCall({
|
||||
callId: "claude-write-1",
|
||||
name: "write_file",
|
||||
input: { file_path: "src/new.ts", content: "export const x = 1;" },
|
||||
output: null,
|
||||
});
|
||||
const writeItem = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
callId: "claude-write-1",
|
||||
name: "write_file",
|
||||
input: { file_path: "src/new.ts", content: "export const x = 1;" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(writeItem.detail?.type).toBe("write");
|
||||
if (writeItem.detail?.type === "write") {
|
||||
expect(writeItem.detail.filePath).toBe("src/new.ts");
|
||||
}
|
||||
|
||||
const editItem = mapClaudeCompletedToolCall({
|
||||
callId: "claude-edit-1",
|
||||
name: "apply_patch",
|
||||
input: { file_path: "src/index.ts", patch: "@@\\n-old\\n+new\\n" },
|
||||
output: null,
|
||||
});
|
||||
const editItem = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
callId: "claude-edit-1",
|
||||
name: "apply_patch",
|
||||
input: { file_path: "src/index.ts", patch: "@@\\n-old\\n+new\\n" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(editItem.detail?.type).toBe("edit");
|
||||
if (editItem.detail?.type === "edit") {
|
||||
expect(editItem.detail.filePath).toBe("src/index.ts");
|
||||
expect(editItem.detail.unifiedDiff).toContain("@@");
|
||||
}
|
||||
|
||||
const searchItem = mapClaudeCompletedToolCall({
|
||||
callId: "claude-search-1",
|
||||
name: "web_search",
|
||||
input: { query: "tool call mapping" },
|
||||
output: null,
|
||||
});
|
||||
const searchItem = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
callId: "claude-search-1",
|
||||
name: "web_search",
|
||||
input: { query: "tool call mapping" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "tool call mapping",
|
||||
@@ -176,12 +208,14 @@ describe("claude tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps unknown tools to unknown detail with raw payloads", () => {
|
||||
const item = mapClaudeCompletedToolCall({
|
||||
callId: "claude-call-4",
|
||||
name: "my_custom_tool",
|
||||
input: { foo: "bar" },
|
||||
output: { ok: true },
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
callId: "claude-call-4",
|
||||
name: "my_custom_tool",
|
||||
input: { foo: "bar" },
|
||||
output: { ok: true },
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
expect(item.error).toBeNull();
|
||||
@@ -191,4 +225,33 @@ describe("claude tool-call mapper", () => {
|
||||
output: { ok: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes claude speak tool names through schema transforms", () => {
|
||||
const item = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
callId: "claude-speak-1",
|
||||
name: "mcp__paseo__speak",
|
||||
input: { text: "Voice response from Claude." },
|
||||
output: { ok: true },
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.name).toBe("speak");
|
||||
expect(item.detail).toEqual({
|
||||
type: "unknown",
|
||||
input: "Voice response from Claude.",
|
||||
output: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops tool calls when callId is missing", () => {
|
||||
const item = mapClaudeCompletedToolCall({
|
||||
callId: null,
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: { content: "hello" },
|
||||
});
|
||||
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { coerceToolCallId } from "../tool-call-mapper-utils.js";
|
||||
import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js";
|
||||
|
||||
type MapperParams = {
|
||||
@@ -12,87 +11,152 @@ type MapperParams = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ClaudeMapperParamsSchema = z
|
||||
const ClaudeToolCallStatusSchema = z.enum([
|
||||
"running",
|
||||
"completed",
|
||||
"failed",
|
||||
"canceled",
|
||||
]);
|
||||
|
||||
const ClaudeRawToolCallSchema = z
|
||||
.object({
|
||||
callId: z.string().optional().nullable(),
|
||||
name: z.string().min(1),
|
||||
input: z.unknown().optional(),
|
||||
output: z.unknown().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
error: z.unknown().nullable().optional(),
|
||||
status: ClaudeToolCallStatusSchema,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ClaudeFailedMapperParamsSchema = ClaudeMapperParamsSchema.extend({
|
||||
error: z.unknown(),
|
||||
const ClaudeToolCallPass1Schema = ClaudeRawToolCallSchema.transform((raw) => ({
|
||||
callId:
|
||||
typeof raw.callId === "string" && raw.callId.trim().length > 0
|
||||
? raw.callId
|
||||
: null,
|
||||
name: raw.name.trim(),
|
||||
input: raw.input ?? null,
|
||||
output: raw.output ?? null,
|
||||
metadata: raw.metadata,
|
||||
error: raw.error ?? null,
|
||||
status: raw.status,
|
||||
}));
|
||||
|
||||
const ClaudeToolCallPass2BaseSchema = z.object({
|
||||
callId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
input: z.unknown().nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
error: z.unknown().nullable(),
|
||||
status: ClaudeToolCallStatusSchema,
|
||||
toolKind: z.enum(["speak", "other"]),
|
||||
});
|
||||
|
||||
function coerceCallId(callId: string | null | undefined, name: string, input: unknown): string {
|
||||
return coerceToolCallId({
|
||||
providerPrefix: "claude",
|
||||
rawCallId: callId,
|
||||
toolName: name,
|
||||
input,
|
||||
});
|
||||
}
|
||||
const ClaudeToolCallPass2InputSchema = ClaudeToolCallPass2BaseSchema.omit({
|
||||
toolKind: true,
|
||||
});
|
||||
|
||||
function buildBase(params: MapperParams): {
|
||||
callId: string;
|
||||
name: string;
|
||||
detail: Extract<ToolCallTimelineItem, { type: "tool_call" }>["detail"];
|
||||
metadata?: Record<string, unknown>;
|
||||
} {
|
||||
const parsedParams = ClaudeMapperParamsSchema.parse(params);
|
||||
const input = parsedParams.input ?? null;
|
||||
const output = parsedParams.output ?? null;
|
||||
const detail = deriveClaudeToolDetail(parsedParams.name, input, output);
|
||||
const ClaudeToolCallPass2EnvelopeSchema = z.union([
|
||||
ClaudeToolCallPass2InputSchema.extend({
|
||||
name: z.literal("mcp__paseo__speak"),
|
||||
}).transform((normalized) => ({
|
||||
...normalized,
|
||||
name: normalized.name.trim(),
|
||||
toolKind: "speak" as const,
|
||||
})),
|
||||
ClaudeToolCallPass2InputSchema.transform((normalized) => ({
|
||||
...normalized,
|
||||
name: normalized.name.trim(),
|
||||
toolKind: "other" as const,
|
||||
})),
|
||||
]);
|
||||
|
||||
const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
|
||||
ClaudeToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("speak"),
|
||||
name: z.literal("mcp__paseo__speak"),
|
||||
}),
|
||||
ClaudeToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("other"),
|
||||
}),
|
||||
]);
|
||||
|
||||
type ClaudeToolCallPass2 = z.infer<typeof ClaudeToolCallPass2Schema>;
|
||||
|
||||
function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimelineItem {
|
||||
const name = normalized.toolKind === "speak" ? ("speak" as const) : normalized.name;
|
||||
const detail = deriveClaudeToolDetail(name, normalized.input, normalized.output);
|
||||
if (normalized.status === "failed") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: normalized.callId,
|
||||
name,
|
||||
detail,
|
||||
status: "failed",
|
||||
error: normalized.error ?? { message: "Tool call failed" },
|
||||
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
callId: coerceCallId(parsedParams.callId, parsedParams.name, input),
|
||||
name: parsedParams.name,
|
||||
type: "tool_call",
|
||||
callId: normalized.callId,
|
||||
name,
|
||||
detail,
|
||||
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
|
||||
status: normalized.status,
|
||||
error: null,
|
||||
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapClaudeRunningToolCall(params: MapperParams): ToolCallTimelineItem {
|
||||
const base = buildBase(params);
|
||||
return {
|
||||
type: "tool_call",
|
||||
...base,
|
||||
status: "running",
|
||||
error: null,
|
||||
};
|
||||
function mapClaudeToolCall(
|
||||
params: MapperParams,
|
||||
status: z.infer<typeof ClaudeToolCallStatusSchema>,
|
||||
error: unknown | null
|
||||
): ToolCallTimelineItem | null {
|
||||
const pass1 = ClaudeToolCallPass1Schema.safeParse({
|
||||
...params,
|
||||
status,
|
||||
error,
|
||||
});
|
||||
if (!pass1.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pass2Envelope = ClaudeToolCallPass2EnvelopeSchema.safeParse(pass1.data);
|
||||
if (!pass2Envelope.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pass2 = ClaudeToolCallPass2Schema.safeParse(pass2Envelope.data);
|
||||
if (!pass2.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toToolCallTimelineItem(pass2.data);
|
||||
}
|
||||
|
||||
export function mapClaudeCompletedToolCall(params: MapperParams): ToolCallTimelineItem {
|
||||
const base = buildBase(params);
|
||||
return {
|
||||
type: "tool_call",
|
||||
...base,
|
||||
status: "completed",
|
||||
error: null,
|
||||
};
|
||||
export function mapClaudeRunningToolCall(
|
||||
params: MapperParams
|
||||
): ToolCallTimelineItem | null {
|
||||
return mapClaudeToolCall(params, "running", null);
|
||||
}
|
||||
|
||||
export function mapClaudeCompletedToolCall(
|
||||
params: MapperParams
|
||||
): ToolCallTimelineItem | null {
|
||||
return mapClaudeToolCall(params, "completed", null);
|
||||
}
|
||||
|
||||
export function mapClaudeFailedToolCall(
|
||||
params: MapperParams & { error: unknown }
|
||||
): ToolCallTimelineItem {
|
||||
const parsedParams = ClaudeFailedMapperParamsSchema.parse(params);
|
||||
const base = buildBase(parsedParams);
|
||||
return {
|
||||
type: "tool_call",
|
||||
...base,
|
||||
status: "failed",
|
||||
error: parsedParams.error,
|
||||
};
|
||||
): ToolCallTimelineItem | null {
|
||||
return mapClaudeToolCall(params, "failed", params.error);
|
||||
}
|
||||
|
||||
export function mapClaudeCanceledToolCall(params: MapperParams): ToolCallTimelineItem {
|
||||
const base = buildBase(params);
|
||||
return {
|
||||
type: "tool_call",
|
||||
...base,
|
||||
status: "canceled",
|
||||
error: null,
|
||||
};
|
||||
export function mapClaudeCanceledToolCall(
|
||||
params: MapperParams
|
||||
): ToolCallTimelineItem | null {
|
||||
return mapClaudeToolCall(params, "canceled", null);
|
||||
}
|
||||
|
||||
@@ -982,6 +982,9 @@ function mapCodexExecNotificationToToolCall(params: {
|
||||
: null,
|
||||
cwd: params.cwd ?? null,
|
||||
});
|
||||
if (!mapped) {
|
||||
return null;
|
||||
}
|
||||
return params.running ? toRunningToolCall(mapped) : mapped;
|
||||
}
|
||||
|
||||
@@ -993,7 +996,7 @@ function mapCodexPatchNotificationToToolCall(params: {
|
||||
stderr?: string | null;
|
||||
success?: boolean | null;
|
||||
running: boolean;
|
||||
}): ToolCallTimelineItem {
|
||||
}): ToolCallTimelineItem | null {
|
||||
const files = parseCodexPatchChanges(params.changes);
|
||||
const firstPath = files[0]?.path;
|
||||
const firstPatchText = files
|
||||
@@ -1038,6 +1041,9 @@ function mapCodexPatchNotificationToToolCall(params: {
|
||||
: { message: params.stderr?.trim() || "Patch apply failed" },
|
||||
cwd: params.cwd ?? null,
|
||||
});
|
||||
if (!mapped) {
|
||||
return null;
|
||||
}
|
||||
return params.running ? toRunningToolCall(mapped) : mapped;
|
||||
}
|
||||
|
||||
@@ -1829,7 +1835,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
developer_instructions: entry.developer_instructions ?? null,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.debug({ error }, "Failed to load collaboration modes");
|
||||
this.logger.trace({ error }, "Failed to load collaboration modes");
|
||||
this.collaborationModes = [];
|
||||
}
|
||||
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
|
||||
@@ -1856,7 +1862,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
this.cachedSkills = skills;
|
||||
} catch (error) {
|
||||
this.logger.debug({ error }, "Failed to load skills list");
|
||||
this.logger.trace({ error }, "Failed to load skills list");
|
||||
this.cachedSkills = [];
|
||||
}
|
||||
}
|
||||
@@ -2565,11 +2571,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
cwd: this.config.cwd ?? null,
|
||||
running: true,
|
||||
});
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
});
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
if (timelineItem) {
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
});
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2587,12 +2595,14 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
success: parsed.success,
|
||||
running: false,
|
||||
});
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
stdout: parsed.stdout,
|
||||
});
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
if (timelineItem) {
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
stdout: parsed.stdout,
|
||||
});
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2675,7 +2685,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.warnedUnknownNotificationMethods.add(method);
|
||||
this.logger.warn({ method, params }, "Unhandled Codex app-server notification method");
|
||||
this.logger.trace({ method, params }, "Unhandled Codex app-server notification method");
|
||||
}
|
||||
|
||||
private warnInvalidNotificationPayload(method: string, params: unknown): void {
|
||||
|
||||
@@ -503,14 +503,15 @@ export async function parseRolloutFile(
|
||||
record.kind === "timeline"
|
||||
? [record.item]
|
||||
: record.kind === "call"
|
||||
? [
|
||||
mapCodexRolloutToolCall({
|
||||
? (() => {
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
callId: record.callId ?? null,
|
||||
name: record.name,
|
||||
input: record.input ?? null,
|
||||
output: record.callId ? outputsByCallId.get(record.callId) ?? null : null,
|
||||
}),
|
||||
]
|
||||
});
|
||||
return mapped ? [mapped] : [];
|
||||
})()
|
||||
: []
|
||||
);
|
||||
return dedupeMirroredTextTimelineItems(timeline);
|
||||
|
||||
@@ -24,23 +24,43 @@ export type CodexToolDetailContext = {
|
||||
cwd?: string | null;
|
||||
};
|
||||
|
||||
export const CODEX_BUILTIN_TOOL_NAMES = new Set([
|
||||
"shell",
|
||||
"bash",
|
||||
"exec",
|
||||
"exec_command",
|
||||
"command",
|
||||
"read",
|
||||
"read_file",
|
||||
"write",
|
||||
"write_file",
|
||||
"create_file",
|
||||
"edit",
|
||||
"apply_patch",
|
||||
"apply_diff",
|
||||
"web_search",
|
||||
"search",
|
||||
]);
|
||||
const CodexToolEnvelopeSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
input: z.unknown().nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
cwd: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexSpeakToolDetailSchema = z
|
||||
.object({
|
||||
name: z.literal("speak"),
|
||||
input: z
|
||||
.union([
|
||||
z.string().transform((text) => ({ text })),
|
||||
z.object({ text: z.string() }).passthrough(),
|
||||
])
|
||||
.nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
cwd: z.string().nullable().optional(),
|
||||
})
|
||||
.transform(({ input }) => {
|
||||
const text = input?.text?.trim() ?? "";
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: "unknown",
|
||||
input: text,
|
||||
output: null,
|
||||
} satisfies ToolCallDetail;
|
||||
});
|
||||
|
||||
const CodexLooseEditOutputSchema = z.unknown().transform((value) => {
|
||||
const parsed = ToolEditOutputSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
});
|
||||
|
||||
export function normalizeCodexFilePath(
|
||||
filePath: string,
|
||||
@@ -60,7 +80,7 @@ function normalizePathForCwd(cwd: string | null): (filePath: string) => string |
|
||||
return (filePath) => normalizeCodexFilePath(filePath, cwd);
|
||||
}
|
||||
|
||||
const CodexKnownToolDetailSchema = z.union([
|
||||
const CodexToolDetailPass2Schema = z.union([
|
||||
toolDetailBranchByNameWithCwd("Bash", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
|
||||
toShellToolDetail(input, output)
|
||||
),
|
||||
@@ -82,14 +102,22 @@ const CodexKnownToolDetailSchema = z.union([
|
||||
toolDetailBranchByNameWithCwd("command", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
|
||||
toShellToolDetail(input, output)
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("read", ToolReadInputSchema, ToolReadOutputWithPathSchema, (input, output, cwd) =>
|
||||
toReadToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("read", ToolReadInputSchema, z.unknown(), (input, output, cwd) => {
|
||||
const parsedOutput = ToolReadOutputWithPathSchema.safeParse(output);
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null, {
|
||||
normalizePath: normalizePathForCwd(cwd),
|
||||
});
|
||||
}),
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"read_file",
|
||||
ToolReadInputSchema,
|
||||
ToolReadOutputWithPathSchema,
|
||||
(input, output, cwd) => toReadToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
z.unknown(),
|
||||
(input, output, cwd) => {
|
||||
const parsedOutput = ToolReadOutputWithPathSchema.safeParse(output);
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null, {
|
||||
normalizePath: normalizePathForCwd(cwd),
|
||||
});
|
||||
}
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("write", ToolWriteInputSchema, ToolWriteOutputSchema, (input, output, cwd) =>
|
||||
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
@@ -106,19 +134,19 @@ const CodexKnownToolDetailSchema = z.union([
|
||||
ToolWriteOutputSchema,
|
||||
(input, output, cwd) => toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("edit", ToolEditInputSchema, ToolEditOutputSchema, (input, output, cwd) =>
|
||||
toolDetailBranchByNameWithCwd("edit", ToolEditInputSchema, CodexLooseEditOutputSchema, (input, output, cwd) =>
|
||||
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
),
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"apply_patch",
|
||||
ToolEditInputSchema,
|
||||
ToolEditOutputSchema,
|
||||
CodexLooseEditOutputSchema,
|
||||
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
),
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"apply_diff",
|
||||
ToolEditInputSchema,
|
||||
ToolEditOutputSchema,
|
||||
CodexLooseEditOutputSchema,
|
||||
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
@@ -127,6 +155,7 @@ const CodexKnownToolDetailSchema = z.union([
|
||||
toolDetailBranchByNameWithCwd("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
),
|
||||
CodexSpeakToolDetailSchema,
|
||||
]);
|
||||
|
||||
export function deriveCodexToolDetail(params: {
|
||||
@@ -135,18 +164,28 @@ export function deriveCodexToolDetail(params: {
|
||||
output: unknown;
|
||||
cwd?: string | null;
|
||||
}): ToolCallDetail {
|
||||
const parsed = CodexKnownToolDetailSchema.safeParse({
|
||||
const pass1 = CodexToolEnvelopeSchema.safeParse({
|
||||
name: params.name,
|
||||
input: params.input,
|
||||
output: params.output,
|
||||
cwd: params.cwd ?? null,
|
||||
});
|
||||
if (parsed.success && parsed.data) {
|
||||
return parsed.data;
|
||||
}
|
||||
return {
|
||||
type: "unknown",
|
||||
input: params.input ?? null,
|
||||
output: params.output ?? null,
|
||||
cwd: params.cwd ?? null,
|
||||
});
|
||||
if (!pass1.success) {
|
||||
return {
|
||||
type: "unknown",
|
||||
input: params.input ?? null,
|
||||
output: params.output ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const pass2 = CodexToolDetailPass2Schema.safeParse(pass1.data);
|
||||
if (pass2.success && pass2.data) {
|
||||
return pass2.data;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "unknown",
|
||||
input: pass1.data.input,
|
||||
output: pass1.data.output,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ import {
|
||||
mapCodexToolCallFromThreadItem,
|
||||
} from "./tool-call-mapper.js";
|
||||
|
||||
function expectMapped<T>(item: T | null): T {
|
||||
expect(item).toBeTruthy();
|
||||
if (!item) {
|
||||
throw new Error("Expected mapped tool call");
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
describe("codex tool-call mapper", () => {
|
||||
it("maps commandExecution start into running canonical call", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
@@ -301,12 +309,14 @@ describe("codex tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps unknown tools to unknown detail with raw payloads", () => {
|
||||
const item = mapCodexRolloutToolCall({
|
||||
callId: "codex-call-4",
|
||||
name: "my_custom_tool",
|
||||
input: { foo: "bar" },
|
||||
output: { ok: true },
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapCodexRolloutToolCall({
|
||||
callId: "codex-call-4",
|
||||
name: "my_custom_tool",
|
||||
input: { foo: "bar" },
|
||||
output: { ok: true },
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
expect(item.error).toBeNull();
|
||||
@@ -327,13 +337,15 @@ describe("codex tool-call mapper", () => {
|
||||
"+new",
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const item = mapCodexRolloutToolCall({
|
||||
callId: "codex-call-apply",
|
||||
name: "apply_patch",
|
||||
input: patch,
|
||||
output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapCodexRolloutToolCall({
|
||||
callId: "codex-call-apply",
|
||||
name: "apply_patch",
|
||||
input: patch,
|
||||
output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
|
||||
cwd: "/tmp/repo",
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
expect(item.error).toBeNull();
|
||||
@@ -359,16 +371,18 @@ describe("codex tool-call mapper", () => {
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
|
||||
const item = mapCodexRolloutToolCall({
|
||||
callId: "codex-call-apply-object",
|
||||
name: "apply_patch",
|
||||
input: {
|
||||
path: "/tmp/repo/src/object.ts",
|
||||
content: patch,
|
||||
},
|
||||
output: null,
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapCodexRolloutToolCall({
|
||||
callId: "codex-call-apply-object",
|
||||
name: "apply_patch",
|
||||
input: {
|
||||
path: "/tmp/repo/src/object.ts",
|
||||
content: patch,
|
||||
},
|
||||
output: null,
|
||||
cwd: "/tmp/repo",
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.detail.type).toBe("edit");
|
||||
if (item.detail.type === "edit") {
|
||||
@@ -412,58 +426,6 @@ describe("codex tool-call mapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange patch alias fields into edit unified diff detail", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-file-change-patch-alias",
|
||||
status: "completed",
|
||||
changes: [
|
||||
{
|
||||
path: "/tmp/repo/src/from-patch-alias.ts",
|
||||
kind: "modify",
|
||||
patch: "@@\n-oldAlias\n+newAlias\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item?.detail?.type).toBe("edit");
|
||||
if (item?.detail?.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/from-patch-alias.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-oldAlias");
|
||||
expect(item.detail.unifiedDiff).toContain("+newAlias");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange unifiedDiff alias fields into edit unified diff detail", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-file-change-unified-diff-alias",
|
||||
status: "completed",
|
||||
changes: [
|
||||
{
|
||||
path: "/tmp/repo/src/from-unified-diff-alias.ts",
|
||||
kind: "modify",
|
||||
unified_diff: "@@\n-beforeAlias\n+afterAlias\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item?.detail?.type).toBe("edit");
|
||||
if (item?.detail?.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/from-unified-diff-alias.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-beforeAlias");
|
||||
expect(item.detail.unifiedDiff).toContain("+afterAlias");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps path-only fileChange payloads to unknown detail instead of empty edit detail", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
@@ -484,17 +446,80 @@ describe("codex tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps path-only apply_patch rollout payloads to unknown detail instead of empty edit detail", () => {
|
||||
const item = mapCodexRolloutToolCall({
|
||||
callId: "codex-call-apply-path-only",
|
||||
name: "apply_patch",
|
||||
input: { path: "/tmp/repo/src/path-only-rollout.ts" },
|
||||
output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] },
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapCodexRolloutToolCall({
|
||||
callId: "codex-call-apply-path-only",
|
||||
name: "apply_patch",
|
||||
input: { path: "/tmp/repo/src/path-only-rollout.ts" },
|
||||
output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] },
|
||||
cwd: "/tmp/repo",
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.detail.type).toBe("unknown");
|
||||
if (item.detail.type === "unknown") {
|
||||
expect(item.detail.input).toEqual({ path: "/tmp/repo/src/path-only-rollout.ts" });
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes codex paseo speak mcp calls and extracts spoken text", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "mcpToolCall",
|
||||
id: "codex-speak-thread-1",
|
||||
status: "completed",
|
||||
server: "paseo",
|
||||
tool: "speak",
|
||||
arguments: { text: "Voice response from Codex." },
|
||||
result: { ok: true },
|
||||
});
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
expect(item?.name).toBe("speak");
|
||||
expect(item?.detail).toEqual({
|
||||
type: "unknown",
|
||||
input: "Voice response from Codex.",
|
||||
output: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes codex paseo speak rollout names and extracts spoken text", () => {
|
||||
const item = expectMapped(
|
||||
mapCodexRolloutToolCall({
|
||||
callId: "codex-speak-rollout-1",
|
||||
name: "paseo.speak",
|
||||
input: { text: "Rollout speech text." },
|
||||
output: { ok: true },
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.name).toBe("speak");
|
||||
expect(item.detail).toEqual({
|
||||
type: "unknown",
|
||||
input: "Rollout speech text.",
|
||||
output: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops rollout tool calls when callId is missing", () => {
|
||||
const item = mapCodexRolloutToolCall({
|
||||
callId: null,
|
||||
name: "read_file",
|
||||
input: { path: "/tmp/repo/README.md" },
|
||||
output: { content: "hello" },
|
||||
});
|
||||
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
|
||||
it("drops thread mcp tool calls when id is missing", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "mcpToolCall",
|
||||
status: "completed",
|
||||
tool: "read_file",
|
||||
arguments: { path: "/tmp/repo/README.md" },
|
||||
result: { content: "hello" },
|
||||
});
|
||||
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { CommandValueSchema } from "../tool-call-detail-primitives.js";
|
||||
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
coerceToolCallId,
|
||||
extractCodexShellOutput,
|
||||
truncateDiffText,
|
||||
} from "../tool-call-mapper-utils.js";
|
||||
import {
|
||||
CODEX_BUILTIN_TOOL_NAMES,
|
||||
deriveCodexToolDetail,
|
||||
normalizeCodexFilePath,
|
||||
} from "./tool-call-detail-parser.js";
|
||||
@@ -18,6 +15,14 @@ type CodexMapperOptions = { cwd?: string | null };
|
||||
const FAILED_STATUSES = new Set(["failed", "error", "errored", "rejected", "denied"]);
|
||||
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "interrupted", "aborted"]);
|
||||
const COMPLETED_STATUSES = new Set(["completed", "complete", "done", "success", "succeeded"]);
|
||||
const CodexCommandValueSchema = z.union([z.string(), z.array(z.string())]);
|
||||
|
||||
const CodexToolCallStatusSchema = z.enum([
|
||||
"running",
|
||||
"completed",
|
||||
"failed",
|
||||
"canceled",
|
||||
]);
|
||||
|
||||
const CodexRolloutToolCallParamsSchema = z
|
||||
.object({
|
||||
@@ -29,6 +34,159 @@ const CodexRolloutToolCallParamsSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type CodexNormalizedToolCallEnvelope = {
|
||||
callId: string;
|
||||
name: string;
|
||||
input?: unknown | null;
|
||||
output?: unknown | null;
|
||||
status?: ToolCallTimelineItem["status"];
|
||||
error?: unknown | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
cwd?: string | null;
|
||||
};
|
||||
|
||||
const CodexNormalizedToolCallPass1Schema = z
|
||||
.object({
|
||||
callId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
input: z.unknown().nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
status: CodexToolCallStatusSchema,
|
||||
error: z.unknown().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
cwd: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexShellToolNameSchema = z.union([
|
||||
z.literal("Bash"),
|
||||
z.literal("shell"),
|
||||
z.literal("bash"),
|
||||
z.literal("exec"),
|
||||
z.literal("exec_command"),
|
||||
z.literal("command"),
|
||||
]);
|
||||
const CodexReadToolNameSchema = z.union([z.literal("read"), z.literal("read_file")]);
|
||||
const CodexWriteToolNameSchema = z.union([
|
||||
z.literal("write"),
|
||||
z.literal("write_file"),
|
||||
z.literal("create_file"),
|
||||
]);
|
||||
const CodexEditToolNameSchema = z.union([
|
||||
z.literal("edit"),
|
||||
z.literal("apply_patch"),
|
||||
z.literal("apply_diff"),
|
||||
]);
|
||||
const CodexSearchToolNameSchema = z.union([z.literal("search"), z.literal("web_search")]);
|
||||
const CodexSpeakToolNameSchema = z.literal("paseo.speak");
|
||||
|
||||
const CodexToolKindSchema = z.enum(["shell", "read", "write", "edit", "search", "speak", "unknown"]);
|
||||
|
||||
const CodexToolCallPass2BaseSchema = CodexNormalizedToolCallPass1Schema.extend({
|
||||
toolKind: CodexToolKindSchema,
|
||||
});
|
||||
|
||||
const CodexToolCallPass2EnvelopeSchema = z.union([
|
||||
CodexNormalizedToolCallPass1Schema.extend({
|
||||
name: CodexShellToolNameSchema,
|
||||
}).transform((envelope) => ({ ...envelope, toolKind: "shell" as const })),
|
||||
CodexNormalizedToolCallPass1Schema.extend({
|
||||
name: CodexReadToolNameSchema,
|
||||
}).transform((envelope) => ({ ...envelope, toolKind: "read" as const })),
|
||||
CodexNormalizedToolCallPass1Schema.extend({
|
||||
name: CodexWriteToolNameSchema,
|
||||
}).transform((envelope) => ({ ...envelope, toolKind: "write" as const })),
|
||||
CodexNormalizedToolCallPass1Schema.extend({
|
||||
name: CodexEditToolNameSchema,
|
||||
}).transform((envelope) => ({ ...envelope, toolKind: "edit" as const })),
|
||||
CodexNormalizedToolCallPass1Schema.extend({
|
||||
name: CodexSearchToolNameSchema,
|
||||
}).transform((envelope) => ({ ...envelope, toolKind: "search" as const })),
|
||||
CodexNormalizedToolCallPass1Schema.extend({
|
||||
name: CodexSpeakToolNameSchema,
|
||||
}).transform((envelope) => ({ ...envelope, toolKind: "speak" as const })),
|
||||
CodexNormalizedToolCallPass1Schema.transform((envelope) => ({
|
||||
...envelope,
|
||||
name: envelope.name.trim(),
|
||||
toolKind: "unknown" as const,
|
||||
})),
|
||||
]);
|
||||
|
||||
const CodexNormalizedToolCallPass2Schema = z.discriminatedUnion("toolKind", [
|
||||
CodexToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("shell"),
|
||||
name: CodexShellToolNameSchema,
|
||||
}),
|
||||
CodexToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("read"),
|
||||
name: CodexReadToolNameSchema,
|
||||
}),
|
||||
CodexToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("write"),
|
||||
name: CodexWriteToolNameSchema,
|
||||
}),
|
||||
CodexToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("edit"),
|
||||
name: CodexEditToolNameSchema,
|
||||
}),
|
||||
CodexToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("search"),
|
||||
name: CodexSearchToolNameSchema,
|
||||
}),
|
||||
CodexToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("speak"),
|
||||
name: CodexSpeakToolNameSchema,
|
||||
}),
|
||||
CodexToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("unknown"),
|
||||
}),
|
||||
]);
|
||||
|
||||
type CodexNormalizedToolCallPass2 = z.infer<typeof CodexNormalizedToolCallPass2Schema>;
|
||||
|
||||
function toToolCallTimelineItem(envelope: CodexNormalizedToolCallPass2): ToolCallTimelineItem {
|
||||
const name = envelope.toolKind === "speak" ? ("speak" as const) : envelope.name;
|
||||
const parsedDetail = deriveCodexToolDetail({
|
||||
name,
|
||||
input: envelope.input,
|
||||
output: envelope.output,
|
||||
cwd: envelope.cwd ?? null,
|
||||
});
|
||||
|
||||
const detail: ToolCallTimelineItem["detail"] =
|
||||
envelope.toolKind === "edit" &&
|
||||
envelope.status !== "running" &&
|
||||
!hasRenderableEditDetail(parsedDetail)
|
||||
? {
|
||||
type: "unknown",
|
||||
input: envelope.input,
|
||||
output: envelope.output,
|
||||
}
|
||||
: parsedDetail;
|
||||
|
||||
if (envelope.status === "failed") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: envelope.callId,
|
||||
name,
|
||||
status: "failed",
|
||||
error: envelope.error ?? { message: "Tool call failed" },
|
||||
detail,
|
||||
...(envelope.metadata ? { metadata: envelope.metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: envelope.callId,
|
||||
name,
|
||||
status: envelope.status,
|
||||
error: null,
|
||||
detail,
|
||||
...(envelope.metadata ? { metadata: envelope.metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread-item parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -36,10 +194,10 @@ const CodexRolloutToolCallParamsSchema = z
|
||||
const CodexCommandExecutionItemSchema = z
|
||||
.object({
|
||||
type: z.literal("commandExecution"),
|
||||
id: z.string().optional(),
|
||||
id: z.string().min(1),
|
||||
status: z.string().optional(),
|
||||
error: z.unknown().optional(),
|
||||
command: CommandValueSchema.optional(),
|
||||
command: CodexCommandValueSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
aggregatedOutput: z.string().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
@@ -49,7 +207,7 @@ const CodexCommandExecutionItemSchema = z
|
||||
const CodexFileChangeItemSchema = z
|
||||
.object({
|
||||
type: z.literal("fileChange"),
|
||||
id: z.string().optional(),
|
||||
id: z.string().min(1),
|
||||
status: z.string().optional(),
|
||||
error: z.unknown().optional(),
|
||||
changes: z
|
||||
@@ -60,10 +218,7 @@ const CodexFileChangeItemSchema = z
|
||||
kind: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
patch: z.string().optional(),
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
newString: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
@@ -74,12 +229,10 @@ const CodexFileChangeItemSchema = z
|
||||
const CodexMcpToolCallItemSchema = z
|
||||
.object({
|
||||
type: z.literal("mcpToolCall"),
|
||||
id: z.string().optional(),
|
||||
callID: z.string().optional(),
|
||||
call_id: z.string().optional(),
|
||||
id: z.string().min(1),
|
||||
status: z.string().optional(),
|
||||
error: z.unknown().optional(),
|
||||
tool: z.string().optional(),
|
||||
tool: z.string().min(1),
|
||||
server: z.string().optional(),
|
||||
arguments: z.unknown().optional(),
|
||||
result: z.unknown().optional(),
|
||||
@@ -89,7 +242,7 @@ const CodexMcpToolCallItemSchema = z
|
||||
const CodexWebSearchItemSchema = z
|
||||
.object({
|
||||
type: z.literal("webSearch"),
|
||||
id: z.string().optional(),
|
||||
id: z.string().min(1),
|
||||
status: z.string().optional(),
|
||||
error: z.unknown().optional(),
|
||||
query: z.string().optional(),
|
||||
@@ -104,15 +257,6 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
|
||||
CodexWebSearchItemSchema,
|
||||
]);
|
||||
|
||||
function coerceCallId(raw: string | null | undefined, name: string, input: unknown): string {
|
||||
return coerceToolCallId({
|
||||
providerPrefix: "codex",
|
||||
rawCallId: raw,
|
||||
toolName: name,
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
function maybeUnwrapShellWrapperCommand(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
const wrapperMatch = trimmed.match(
|
||||
@@ -174,6 +318,10 @@ type CodexApplyPatchDirective = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseCodexApplyPatchDirective(line: string): CodexApplyPatchDirective | null {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("*** Add File:")) {
|
||||
@@ -188,6 +336,16 @@ function parseCodexApplyPatchDirective(line: string): CodexApplyPatchDirective |
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractPatchPrimaryFilePath(patch: string): string | undefined {
|
||||
for (const line of patch.split(/\r?\n/)) {
|
||||
const directive = parseCodexApplyPatchDirective(line);
|
||||
if (directive && directive.path.length > 0) {
|
||||
return directive.path;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function looksLikeCodexApplyPatch(text: string): boolean {
|
||||
const normalized = text.trimStart();
|
||||
if (!normalized) {
|
||||
@@ -285,6 +443,64 @@ function asEditTextFields(
|
||||
return { newString: text };
|
||||
}
|
||||
|
||||
function normalizeRolloutEditInput(input: unknown): unknown {
|
||||
if (typeof input === "string") {
|
||||
const textFields = asEditTextFields(input);
|
||||
const path = extractPatchPrimaryFilePath(input);
|
||||
return {
|
||||
...(path ? { path } : {}),
|
||||
...(textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}),
|
||||
...(textFields.newString ? { content: textFields.newString } : {}),
|
||||
};
|
||||
}
|
||||
if (!isRecord(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const candidatePatchText =
|
||||
(typeof input.patch === "string" && input.patch) ||
|
||||
(typeof input.diff === "string" && input.diff) ||
|
||||
(typeof input.unified_diff === "string" && input.unified_diff) ||
|
||||
(typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
|
||||
(typeof input.content === "string" && input.content) ||
|
||||
undefined;
|
||||
if (!candidatePatchText) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const textFields = asEditTextFields(candidatePatchText);
|
||||
const rawPath =
|
||||
(typeof input.path === "string" && input.path.trim().length > 0 ? input.path : undefined) ||
|
||||
(typeof input.file_path === "string" && input.file_path.trim().length > 0
|
||||
? input.file_path
|
||||
: undefined) ||
|
||||
(typeof input.filePath === "string" && input.filePath.trim().length > 0
|
||||
? input.filePath
|
||||
: undefined) ||
|
||||
extractPatchPrimaryFilePath(candidatePatchText);
|
||||
|
||||
const {
|
||||
patch: _patch,
|
||||
diff: _diff,
|
||||
unified_diff: _unifiedDiffSnake,
|
||||
unifiedDiff: _unifiedDiffCamel,
|
||||
...rest
|
||||
} = input;
|
||||
|
||||
const normalized: Record<string, unknown> = {
|
||||
...rest,
|
||||
...(rawPath ? { path: rawPath } : {}),
|
||||
...(textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}),
|
||||
...(textFields.newString ? { content: textFields.newString } : {}),
|
||||
};
|
||||
|
||||
if (textFields.unifiedDiff && "content" in normalized) {
|
||||
delete normalized.content;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function asEditFileOutputFields(
|
||||
text: string | undefined
|
||||
): { patch?: string; content?: string } {
|
||||
@@ -298,28 +514,6 @@ function asEditFileOutputFields(
|
||||
return { content: text };
|
||||
}
|
||||
|
||||
function asPatchOrContentFields(text: string | undefined): { patch?: string; content?: string } {
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const classified = classifyDiffLikeText(text);
|
||||
if (classified.isDiff) {
|
||||
return { patch: truncateDiffText(classified.text) };
|
||||
}
|
||||
return { content: text };
|
||||
}
|
||||
|
||||
function hasRenderableEditContent(detail: ToolCallDetail): boolean {
|
||||
if (detail.type !== "edit") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(typeof detail.unifiedDiff === "string" && detail.unifiedDiff.length > 0) ||
|
||||
(typeof detail.newString === "string" && detail.newString.length > 0) ||
|
||||
(typeof detail.oldString === "string" && detail.oldString.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function pickFirstPatchLikeString(values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
@@ -329,15 +523,15 @@ function pickFirstPatchLikeString(values: unknown[]): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function removePatchLikeFields(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const {
|
||||
patch: _patch,
|
||||
diff: _diff,
|
||||
unified_diff: _unifiedDiffSnake,
|
||||
unifiedDiff: _unifiedDiffCamel,
|
||||
...rest
|
||||
} = input;
|
||||
return rest;
|
||||
function hasRenderableEditDetail(detail: ToolCallTimelineItem["detail"]): boolean {
|
||||
if (detail.type !== "edit") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
(typeof detail.unifiedDiff === "string" && detail.unifiedDiff.trim().length > 0) ||
|
||||
(typeof detail.newString === "string" && detail.newString.trim().length > 0) ||
|
||||
(typeof detail.oldString === "string" && detail.oldString.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveStatus(
|
||||
@@ -368,47 +562,12 @@ function resolveStatus(
|
||||
return output !== null && output !== undefined ? "completed" : "running";
|
||||
}
|
||||
|
||||
function buildToolCall(params: {
|
||||
callId: string;
|
||||
name: string;
|
||||
status: ToolCallTimelineItem["status"];
|
||||
error: unknown | null;
|
||||
detail: ToolCallDetail;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): ToolCallTimelineItem {
|
||||
if (params.status === "failed") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: params.callId,
|
||||
name: params.name,
|
||||
status: "failed",
|
||||
error: params.error ?? { message: "Tool call failed" },
|
||||
detail: params.detail,
|
||||
...(params.metadata ? { metadata: params.metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: params.callId,
|
||||
name: params.name,
|
||||
status: params.status,
|
||||
error: null,
|
||||
detail: params.detail,
|
||||
...(params.metadata ? { metadata: params.metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildMcpToolName(server: string | undefined, tool: string): string {
|
||||
const trimmedTool = tool.trim();
|
||||
if (!trimmedTool) {
|
||||
return "tool";
|
||||
}
|
||||
|
||||
if (CODEX_BUILTIN_TOOL_NAMES.has(trimmedTool)) {
|
||||
return trimmedTool;
|
||||
}
|
||||
|
||||
const trimmedServer = typeof server === "string" ? server.trim() : "";
|
||||
if (trimmedServer.length > 0) {
|
||||
return `${trimmedServer}.${trimmedTool}`;
|
||||
@@ -421,109 +580,23 @@ function toNullableObject(value: Record<string, unknown>): Record<string, unknow
|
||||
return Object.keys(value).length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function extractPatchPrimaryFilePath(patch: string): string | undefined {
|
||||
for (const line of patch.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("*** Add File:")) {
|
||||
return trimmed.replace("*** Add File:", "").trim();
|
||||
}
|
||||
if (trimmed.startsWith("*** Update File:")) {
|
||||
return trimmed.replace("*** Update File:", "").trim();
|
||||
}
|
||||
if (trimmed.startsWith("*** Delete File:")) {
|
||||
return trimmed.replace("*** Delete File:", "").trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeApplyPatchInput(input: unknown): unknown {
|
||||
if (typeof input === "string") {
|
||||
const filePath = extractPatchPrimaryFilePath(input);
|
||||
const textFields = asPatchOrContentFields(input);
|
||||
return filePath ? { path: filePath, ...textFields } : textFields;
|
||||
}
|
||||
|
||||
if (!isRecord(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const existingPath =
|
||||
(typeof input.path === "string" && input.path.trim().length > 0 && input.path.trim()) ||
|
||||
(typeof input.file_path === "string" &&
|
||||
input.file_path.trim().length > 0 &&
|
||||
input.file_path.trim()) ||
|
||||
(typeof input.filePath === "string" &&
|
||||
input.filePath.trim().length > 0 &&
|
||||
input.filePath.trim());
|
||||
const patchText =
|
||||
(typeof input.patch === "string" && input.patch) ||
|
||||
(typeof input.diff === "string" && input.diff) ||
|
||||
(typeof input.unified_diff === "string" && input.unified_diff) ||
|
||||
(typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
|
||||
undefined;
|
||||
const contentText = typeof input.content === "string" ? input.content : undefined;
|
||||
const inferredPatchFromContent = !patchText && typeof contentText === "string" ? contentText : undefined;
|
||||
const patchOrContentText = patchText ?? inferredPatchFromContent;
|
||||
|
||||
if (existingPath && !patchOrContentText) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (!patchOrContentText) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const base = removePatchLikeFields(input);
|
||||
if (inferredPatchFromContent) {
|
||||
delete (base as { content?: unknown }).content;
|
||||
}
|
||||
const filePath = existingPath || extractPatchPrimaryFilePath(patchOrContentText);
|
||||
const textFields = asPatchOrContentFields(patchOrContentText);
|
||||
return filePath ? { ...base, path: filePath, ...textFields } : { ...base, ...textFields };
|
||||
}
|
||||
|
||||
function deriveApplyPatchDetailFromInput(
|
||||
input: unknown,
|
||||
cwd: string | null | undefined
|
||||
): ToolCallDetail | null {
|
||||
if (!isRecord(input)) {
|
||||
function toToolCallFromNormalizedEnvelope(
|
||||
envelope: CodexNormalizedToolCallEnvelope
|
||||
): ToolCallTimelineItem | null {
|
||||
const pass2Envelope = CodexToolCallPass2EnvelopeSchema.safeParse(envelope);
|
||||
if (!pass2Envelope.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathValue =
|
||||
(typeof input.path === "string" && input.path.trim()) ||
|
||||
(typeof input.file_path === "string" && input.file_path.trim()) ||
|
||||
(typeof input.filePath === "string" && input.filePath.trim()) ||
|
||||
"";
|
||||
if (!pathValue) {
|
||||
const parsed = CodexNormalizedToolCallPass2Schema.safeParse(pass2Envelope.data);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeCodexFilePath(pathValue, cwd) ?? pathValue;
|
||||
const diffText =
|
||||
(typeof input.patch === "string" && input.patch) ||
|
||||
(typeof input.diff === "string" && input.diff) ||
|
||||
(typeof input.unified_diff === "string" && input.unified_diff) ||
|
||||
(typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
|
||||
(typeof input.content === "string" && input.content) ||
|
||||
undefined;
|
||||
|
||||
const textFields = asEditTextFields(diffText);
|
||||
return {
|
||||
type: "edit",
|
||||
filePath: normalizedPath,
|
||||
...textFields,
|
||||
};
|
||||
return toToolCallTimelineItem(parsed.data);
|
||||
}
|
||||
|
||||
function mapCommandExecutionItem(
|
||||
item: z.infer<typeof CodexCommandExecutionItemSchema>
|
||||
): ToolCallTimelineItem {
|
||||
): CodexNormalizedToolCallEnvelope {
|
||||
const command = normalizeCommandExecutionCommand(item.command);
|
||||
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput);
|
||||
const input = toNullableObject({
|
||||
@@ -540,38 +613,25 @@ function mapCommandExecutionItem(
|
||||
}
|
||||
: null;
|
||||
|
||||
const detail = command
|
||||
? {
|
||||
type: "shell" as const,
|
||||
command,
|
||||
...(item.cwd ? { cwd: item.cwd } : {}),
|
||||
...(parsedOutput ? { output: parsedOutput } : {}),
|
||||
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
|
||||
}
|
||||
: {
|
||||
type: "unknown" as const,
|
||||
input,
|
||||
output,
|
||||
};
|
||||
|
||||
const name = "shell";
|
||||
const callId = coerceCallId(item.id, name, input);
|
||||
const error = item.error ?? null;
|
||||
const status = resolveStatus(item.status, error, output);
|
||||
|
||||
return buildToolCall({
|
||||
callId,
|
||||
return {
|
||||
callId: item.id,
|
||||
name,
|
||||
input,
|
||||
output,
|
||||
status,
|
||||
error,
|
||||
detail,
|
||||
});
|
||||
cwd: item.cwd ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapFileChangeItem(
|
||||
item: z.infer<typeof CodexFileChangeItemSchema>,
|
||||
options?: CodexMapperOptions
|
||||
): ToolCallTimelineItem {
|
||||
): CodexNormalizedToolCallEnvelope {
|
||||
const changes = item.changes ?? [];
|
||||
|
||||
const files = changes
|
||||
@@ -587,16 +647,13 @@ function mapFileChangeItem(
|
||||
diff: pickFirstPatchLikeString([
|
||||
change.diff,
|
||||
change.patch,
|
||||
change.unified_diff,
|
||||
change.unifiedDiff,
|
||||
change.content,
|
||||
change.newString,
|
||||
]),
|
||||
};
|
||||
})
|
||||
.filter((change) => change.path !== undefined);
|
||||
|
||||
const input = toNullableObject({
|
||||
const inputBase = {
|
||||
...(files.length > 0
|
||||
? {
|
||||
files: files.map((file) => ({
|
||||
@@ -605,7 +662,7 @@ function mapFileChangeItem(
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
const output = toNullableObject({
|
||||
...(files.length > 0
|
||||
@@ -619,112 +676,97 @@ function mapFileChangeItem(
|
||||
: {}),
|
||||
});
|
||||
|
||||
const name = "apply_patch";
|
||||
const error = item.error ?? null;
|
||||
const status = resolveStatus(item.status, error, output);
|
||||
const firstFile = files[0];
|
||||
const firstTextFields = asEditTextFields(firstFile?.diff);
|
||||
const hasFirstTextFields = Object.keys(firstTextFields).length > 0;
|
||||
const detail = firstFile?.path
|
||||
? hasFirstTextFields
|
||||
? {
|
||||
type: "edit" as const,
|
||||
filePath: firstFile.path,
|
||||
...firstTextFields,
|
||||
}
|
||||
: {
|
||||
type: "unknown" as const,
|
||||
input,
|
||||
output,
|
||||
}
|
||||
: {
|
||||
type: "unknown" as const,
|
||||
input,
|
||||
output,
|
||||
};
|
||||
const input = toNullableObject({
|
||||
...inputBase,
|
||||
...(firstFile?.path && hasFirstTextFields ? { path: firstFile.path } : {}),
|
||||
...(hasFirstTextFields && firstTextFields.unifiedDiff
|
||||
? { patch: firstTextFields.unifiedDiff }
|
||||
: {}),
|
||||
...(hasFirstTextFields && firstTextFields.newString
|
||||
? { content: firstTextFields.newString }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const name = "apply_patch";
|
||||
const callId = coerceCallId(item.id, name, input);
|
||||
const error = item.error ?? null;
|
||||
const status = resolveStatus(item.status, error, output);
|
||||
|
||||
return buildToolCall({
|
||||
callId,
|
||||
return {
|
||||
callId: item.id,
|
||||
name,
|
||||
input,
|
||||
output,
|
||||
status,
|
||||
error,
|
||||
detail,
|
||||
});
|
||||
cwd: options?.cwd ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMcpToolCallItem(
|
||||
item: z.infer<typeof CodexMcpToolCallItemSchema>,
|
||||
options?: CodexMapperOptions
|
||||
): ToolCallTimelineItem {
|
||||
const tool = item.tool?.trim() || "tool";
|
||||
): CodexNormalizedToolCallEnvelope | null {
|
||||
const tool = item.tool.trim();
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
const name = buildMcpToolName(item.server, tool);
|
||||
const input = item.arguments ?? null;
|
||||
const output = item.result ?? null;
|
||||
const error = item.error ?? null;
|
||||
const callId = coerceCallId(item.id ?? item.callID ?? item.call_id, name, input);
|
||||
const status = resolveStatus(item.status, error, output);
|
||||
const detail = deriveCodexToolDetail({
|
||||
name: tool,
|
||||
|
||||
return {
|
||||
callId: item.id,
|
||||
name,
|
||||
input,
|
||||
output,
|
||||
cwd: options?.cwd ?? null,
|
||||
});
|
||||
|
||||
return buildToolCall({
|
||||
callId,
|
||||
name,
|
||||
status,
|
||||
error,
|
||||
detail,
|
||||
});
|
||||
cwd: options?.cwd ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapWebSearchItem(item: z.infer<typeof CodexWebSearchItemSchema>): ToolCallTimelineItem {
|
||||
function mapWebSearchItem(
|
||||
item: z.infer<typeof CodexWebSearchItemSchema>
|
||||
): CodexNormalizedToolCallEnvelope {
|
||||
const input = item.query !== undefined ? { query: item.query } : null;
|
||||
const output = item.action ?? null;
|
||||
const name = "web_search";
|
||||
const callId = coerceCallId(item.id, name, input);
|
||||
const error = item.error ?? null;
|
||||
const status = resolveStatus(item.status ?? "completed", error, output);
|
||||
const detail = item.query
|
||||
? {
|
||||
type: "search" as const,
|
||||
query: item.query,
|
||||
}
|
||||
: {
|
||||
type: "unknown" as const,
|
||||
input,
|
||||
output,
|
||||
};
|
||||
|
||||
return buildToolCall({
|
||||
callId,
|
||||
return {
|
||||
callId: item.id,
|
||||
name,
|
||||
input,
|
||||
output,
|
||||
status,
|
||||
error,
|
||||
detail,
|
||||
});
|
||||
cwd: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createCodexThreadItemToTimelineSchema(options?: CodexMapperOptions) {
|
||||
return CodexThreadItemSchema.transform((item): ToolCallTimelineItem => {
|
||||
switch (item.type) {
|
||||
case "commandExecution":
|
||||
return mapCommandExecutionItem(item);
|
||||
case "fileChange":
|
||||
return mapFileChangeItem(item, options);
|
||||
case "mcpToolCall":
|
||||
return mapMcpToolCallItem(item, options);
|
||||
case "webSearch":
|
||||
return mapWebSearchItem(item);
|
||||
default: {
|
||||
const exhaustiveCheck: never = item;
|
||||
throw new Error(`Unhandled Codex thread item type: ${String(exhaustiveCheck)}`);
|
||||
}
|
||||
function mapThreadItemToNormalizedEnvelope(
|
||||
item: z.infer<typeof CodexThreadItemSchema>,
|
||||
options?: CodexMapperOptions
|
||||
): CodexNormalizedToolCallEnvelope | null {
|
||||
switch (item.type) {
|
||||
case "commandExecution":
|
||||
return mapCommandExecutionItem(item);
|
||||
case "fileChange":
|
||||
return mapFileChangeItem(item, options);
|
||||
case "mcpToolCall":
|
||||
return mapMcpToolCallItem(item, options);
|
||||
case "webSearch":
|
||||
return mapWebSearchItem(item);
|
||||
default: {
|
||||
const exhaustiveCheck: never = item;
|
||||
throw new Error(`Unhandled Codex thread item type: ${String(exhaustiveCheck)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -735,11 +777,15 @@ export function mapCodexToolCallFromThreadItem(
|
||||
item: unknown,
|
||||
options?: CodexMapperOptions
|
||||
): ToolCallTimelineItem | null {
|
||||
const parsed = createCodexThreadItemToTimelineSchema(options).safeParse(item);
|
||||
const parsed = CodexThreadItemSchema.safeParse(item);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
const envelope = mapThreadItemToNormalizedEnvelope(parsed.data, options);
|
||||
if (!envelope) {
|
||||
return null;
|
||||
}
|
||||
return toToolCallFromNormalizedEnvelope(envelope);
|
||||
}
|
||||
|
||||
export function mapCodexRolloutToolCall(params: {
|
||||
@@ -749,43 +795,36 @@ export function mapCodexRolloutToolCall(params: {
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
cwd?: string | null;
|
||||
}): ToolCallTimelineItem {
|
||||
const parsed = CodexRolloutToolCallParamsSchema.parse(params);
|
||||
const rawInput = parsed.input ?? null;
|
||||
const normalizedName = parsed.name.trim().toLowerCase();
|
||||
const input =
|
||||
normalizedName === "apply_patch" || normalizedName === "apply_diff"
|
||||
? normalizeApplyPatchInput(rawInput)
|
||||
: rawInput;
|
||||
const output = parsed.output ?? null;
|
||||
const error = parsed.error ?? null;
|
||||
const status = resolveStatus("completed", error, output);
|
||||
const callId = coerceCallId(parsed.callId, parsed.name, input);
|
||||
let detail = deriveCodexToolDetail({
|
||||
name: parsed.name,
|
||||
input,
|
||||
output,
|
||||
cwd: params.cwd ?? null,
|
||||
});
|
||||
if (detail.type === "unknown" && (normalizedName === "apply_patch" || normalizedName === "apply_diff")) {
|
||||
const fallbackDetail = deriveApplyPatchDetailFromInput(input, params.cwd ?? null);
|
||||
if (fallbackDetail) {
|
||||
detail = fallbackDetail;
|
||||
}
|
||||
}
|
||||
if (detail.type === "edit" && !hasRenderableEditContent(detail)) {
|
||||
detail = {
|
||||
type: "unknown",
|
||||
input,
|
||||
output,
|
||||
};
|
||||
}): ToolCallTimelineItem | null {
|
||||
const parsed = CodexRolloutToolCallParamsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildToolCall({
|
||||
callId,
|
||||
name: parsed.name,
|
||||
status,
|
||||
error,
|
||||
detail,
|
||||
const normalizedName = parsed.data.name.trim();
|
||||
const normalizedInput =
|
||||
normalizedName === "apply_patch" || normalizedName === "apply_diff"
|
||||
? normalizeRolloutEditInput(parsed.data.input ?? null)
|
||||
: parsed.data.input ?? null;
|
||||
|
||||
const pass1 = CodexNormalizedToolCallPass1Schema.safeParse({
|
||||
callId:
|
||||
typeof parsed.data.callId === "string" ? parsed.data.callId.trim() : "",
|
||||
name: normalizedName,
|
||||
input: normalizedInput,
|
||||
output: parsed.data.output ?? null,
|
||||
error: parsed.data.error ?? null,
|
||||
status: resolveStatus("completed", parsed.data.error ?? null, parsed.data.output ?? null),
|
||||
cwd: params.cwd ?? null,
|
||||
});
|
||||
if (!pass1.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapped = toToolCallFromNormalizedEnvelope(pass1.data);
|
||||
if (!mapped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import net from "node:net";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
@@ -69,6 +70,84 @@ type OpenCodeMcpConfig =
|
||||
|
||||
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
|
||||
|
||||
const OpencodeToolStateSchema = z
|
||||
.object({
|
||||
status: z.string().optional(),
|
||||
input: z.unknown().optional(),
|
||||
output: z.unknown().optional(),
|
||||
error: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const OpencodeToolPartBaseSchema = z
|
||||
.object({
|
||||
tool: z.string().trim().min(1),
|
||||
state: OpencodeToolStateSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const OpencodeToolPartWithCallIdSchema = OpencodeToolPartBaseSchema.extend({
|
||||
callID: z.string().trim().min(1),
|
||||
id: z.string().optional(),
|
||||
}).transform((part) => ({
|
||||
toolName: part.tool,
|
||||
callId: part.callID,
|
||||
status: part.state?.status,
|
||||
input: part.state?.input,
|
||||
output: part.state?.output,
|
||||
error: part.state?.error,
|
||||
}));
|
||||
|
||||
const OpencodeToolPartWithIdSchema = OpencodeToolPartBaseSchema.extend({
|
||||
id: z.string().trim().min(1),
|
||||
callID: z.string().optional(),
|
||||
}).transform((part) => ({
|
||||
toolName: part.tool,
|
||||
callId: part.id,
|
||||
status: part.state?.status,
|
||||
input: part.state?.input,
|
||||
output: part.state?.output,
|
||||
error: part.state?.error,
|
||||
}));
|
||||
|
||||
const OpencodeToolPartWithoutIdSchema = OpencodeToolPartBaseSchema.extend({
|
||||
id: z.string().optional(),
|
||||
callID: z.string().optional(),
|
||||
}).transform((part) => ({
|
||||
toolName: part.tool,
|
||||
callId: undefined,
|
||||
status: part.state?.status,
|
||||
input: part.state?.input,
|
||||
output: part.state?.output,
|
||||
error: part.state?.error,
|
||||
}));
|
||||
|
||||
const OpencodeToolPartSchema = z.union([
|
||||
OpencodeToolPartWithCallIdSchema,
|
||||
OpencodeToolPartWithIdSchema,
|
||||
OpencodeToolPartWithoutIdSchema,
|
||||
]);
|
||||
|
||||
const OpencodeToolPartTimelineEnvelopeSchema = OpencodeToolPartSchema.transform((part) => ({
|
||||
toolName: part.toolName,
|
||||
callId: part.callId,
|
||||
status: part.status,
|
||||
input: part.input,
|
||||
output: part.output,
|
||||
error: part.error,
|
||||
}));
|
||||
|
||||
const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform((part) =>
|
||||
mapOpencodeToolCall({
|
||||
toolName: part.toolName,
|
||||
callId: part.callId,
|
||||
status: part.status,
|
||||
input: part.input,
|
||||
output: part.output,
|
||||
error: part.error,
|
||||
})
|
||||
);
|
||||
|
||||
function resolveOpenCodeBinary(): string {
|
||||
try {
|
||||
const opencodePath = execSync("which opencode", { encoding: "utf8" }).trim();
|
||||
@@ -650,33 +729,15 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
} else if (partType === "tool") {
|
||||
const toolPart = part as {
|
||||
id?: string;
|
||||
tool?: string;
|
||||
callID?: string;
|
||||
state?: {
|
||||
status?: string;
|
||||
input?: AgentMetadata;
|
||||
output?: string;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
const toolName = toolPart.tool;
|
||||
const state = toolPart.state;
|
||||
|
||||
if (toolName) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: mapOpencodeToolCall({
|
||||
toolName,
|
||||
callId: toolPart.callID ?? toolPart.id,
|
||||
status: state?.status,
|
||||
input: state?.input,
|
||||
output: state?.output,
|
||||
error: state?.error,
|
||||
}),
|
||||
};
|
||||
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
||||
if (parsedToolPart.success) {
|
||||
if (parsedToolPart.data) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: parsedToolPart.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -931,26 +992,15 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
} else if (partType === "tool") {
|
||||
// Tool parts: { tool: string, state: { status, input, output?, error? } }
|
||||
const toolName = part.tool as string | undefined;
|
||||
const state = part.state as AgentMetadata | undefined;
|
||||
const status = state?.status as string | undefined;
|
||||
const input = state?.input as AgentMetadata | undefined;
|
||||
const output = state?.output as string | undefined;
|
||||
const error = state?.error as string | undefined;
|
||||
|
||||
if (toolName) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: mapOpencodeToolCall({
|
||||
toolName,
|
||||
callId: (part.callID as string | undefined) ?? (part.id as string | undefined),
|
||||
status,
|
||||
input,
|
||||
output,
|
||||
error,
|
||||
}),
|
||||
});
|
||||
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
||||
if (parsedToolPart.success) {
|
||||
if (parsedToolPart.data) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: parsedToolPart.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (partType === "step-finish") {
|
||||
// Extract usage from step-finish parts
|
||||
|
||||
@@ -23,8 +23,14 @@ const OpencodeKnownToolDetailSchema = z.union([
|
||||
toolDetailBranchByToolName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByToolName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByToolName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByToolName("read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
|
||||
toolDetailBranchByToolName("read_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
|
||||
toolDetailBranchByToolName("read", ToolReadInputSchema, z.unknown(), (input, output) => {
|
||||
const parsedOutput = ToolReadOutputSchema.safeParse(output);
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
|
||||
}),
|
||||
toolDetailBranchByToolName("read_file", ToolReadInputSchema, z.unknown(), (input, output) => {
|
||||
const parsedOutput = ToolReadOutputSchema.safeParse(output);
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
|
||||
}),
|
||||
toolDetailBranchByToolName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
toolDetailBranchByToolName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
toolDetailBranchByToolName("create_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
|
||||
@@ -2,15 +2,25 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mapOpencodeToolCall } from "./tool-call-mapper.js";
|
||||
|
||||
function expectMapped<T>(item: T | null): T {
|
||||
expect(item).toBeTruthy();
|
||||
if (!item) {
|
||||
throw new Error("Expected mapped tool call");
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
describe("opencode tool-call mapper", () => {
|
||||
it("maps running shell calls", () => {
|
||||
const item = mapOpencodeToolCall({
|
||||
toolName: "shell",
|
||||
callId: "opencode-call-1",
|
||||
status: "running",
|
||||
input: { command: "pwd", cwd: "/tmp/repo" },
|
||||
output: null,
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "shell",
|
||||
callId: "opencode-call-1",
|
||||
status: "running",
|
||||
input: { command: "pwd", cwd: "/tmp/repo" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.status).toBe("running");
|
||||
expect(item.error).toBeNull();
|
||||
@@ -22,49 +32,57 @@ describe("opencode tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps running known tool variants with detail for early summaries", () => {
|
||||
const readItem = mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: "opencode-running-read",
|
||||
status: "running",
|
||||
input: { file_path: "README.md" },
|
||||
output: null,
|
||||
});
|
||||
const readItem = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: "opencode-running-read",
|
||||
status: "running",
|
||||
input: { file_path: "README.md" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(readItem.detail).toEqual({
|
||||
type: "read",
|
||||
filePath: "README.md",
|
||||
});
|
||||
|
||||
const writeItem = mapOpencodeToolCall({
|
||||
toolName: "write_file",
|
||||
callId: "opencode-running-write",
|
||||
status: "running",
|
||||
input: { file_path: "src/new.ts" },
|
||||
output: null,
|
||||
});
|
||||
const writeItem = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "write_file",
|
||||
callId: "opencode-running-write",
|
||||
status: "running",
|
||||
input: { file_path: "src/new.ts" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(writeItem.detail).toEqual({
|
||||
type: "write",
|
||||
filePath: "src/new.ts",
|
||||
});
|
||||
|
||||
const editItem = mapOpencodeToolCall({
|
||||
toolName: "apply_patch",
|
||||
callId: "opencode-running-edit",
|
||||
status: "running",
|
||||
input: { file_path: "src/index.ts" },
|
||||
output: null,
|
||||
});
|
||||
const editItem = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "apply_patch",
|
||||
callId: "opencode-running-edit",
|
||||
status: "running",
|
||||
input: { file_path: "src/index.ts" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(editItem.detail).toEqual({
|
||||
type: "edit",
|
||||
filePath: "src/index.ts",
|
||||
});
|
||||
|
||||
const searchItem = mapOpencodeToolCall({
|
||||
toolName: "web_search",
|
||||
callId: "opencode-running-search",
|
||||
status: "running",
|
||||
input: { query: "opencode mapper" },
|
||||
output: null,
|
||||
});
|
||||
const searchItem = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "web_search",
|
||||
callId: "opencode-running-search",
|
||||
status: "running",
|
||||
input: { query: "opencode mapper" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "opencode mapper",
|
||||
@@ -72,13 +90,15 @@ describe("opencode tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps completed read calls", () => {
|
||||
const item = mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: "opencode-call-2",
|
||||
status: "complete",
|
||||
input: { file_path: "README.md" },
|
||||
output: { content: "hello" },
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: "opencode-call-2",
|
||||
status: "complete",
|
||||
input: { file_path: "README.md" },
|
||||
output: { content: "hello" },
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
expect(item.error).toBeNull();
|
||||
@@ -91,35 +111,39 @@ describe("opencode tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("preserves read content from array/object output variants", () => {
|
||||
const arrayContent = mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: "opencode-read-array",
|
||||
status: "completed",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
content: [
|
||||
{ type: "output_text", text: "alpha" },
|
||||
{ type: "output_text", output: "beta" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const arrayContent = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: "opencode-read-array",
|
||||
status: "completed",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
content: [
|
||||
{ type: "output_text", text: "alpha" },
|
||||
{ type: "output_text", output: "beta" },
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(arrayContent.detail?.type).toBe("read");
|
||||
if (arrayContent.detail?.type === "read") {
|
||||
expect(arrayContent.detail.content).toBe("alpha\nbeta");
|
||||
}
|
||||
|
||||
const objectContent = mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: "opencode-read-object",
|
||||
status: "completed",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
data: {
|
||||
content: { type: "output_text", text: "gamma" },
|
||||
const objectContent = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: "opencode-read-object",
|
||||
status: "completed",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
data: {
|
||||
content: { type: "output_text", text: "gamma" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
expect(objectContent.detail?.type).toBe("read");
|
||||
if (objectContent.detail?.type === "read") {
|
||||
@@ -128,14 +152,16 @@ describe("opencode tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps failed calls with required error", () => {
|
||||
const item = mapOpencodeToolCall({
|
||||
toolName: "shell",
|
||||
callId: "opencode-call-3",
|
||||
status: "error",
|
||||
input: { command: "false" },
|
||||
output: null,
|
||||
error: "command failed",
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "shell",
|
||||
callId: "opencode-call-3",
|
||||
status: "error",
|
||||
input: { command: "false" },
|
||||
output: null,
|
||||
error: "command failed",
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.status).toBe("failed");
|
||||
expect(item.error).toBe("command failed");
|
||||
@@ -143,38 +169,44 @@ describe("opencode tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps write/edit/search known variants into canonical detail", () => {
|
||||
const writeItem = mapOpencodeToolCall({
|
||||
toolName: "write_file",
|
||||
callId: "opencode-write-1",
|
||||
status: "completed",
|
||||
input: { file_path: "src/new.ts", content: "const x = 1;" },
|
||||
output: null,
|
||||
});
|
||||
const writeItem = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "write_file",
|
||||
callId: "opencode-write-1",
|
||||
status: "completed",
|
||||
input: { file_path: "src/new.ts", content: "const x = 1;" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(writeItem.detail?.type).toBe("write");
|
||||
if (writeItem.detail?.type === "write") {
|
||||
expect(writeItem.detail.filePath).toBe("src/new.ts");
|
||||
}
|
||||
|
||||
const editItem = mapOpencodeToolCall({
|
||||
toolName: "apply_patch",
|
||||
callId: "opencode-edit-1",
|
||||
status: "completed",
|
||||
input: { file_path: "src/index.ts", diff: "@@\\n-old\\n+new\\n" },
|
||||
output: null,
|
||||
});
|
||||
const editItem = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "apply_patch",
|
||||
callId: "opencode-edit-1",
|
||||
status: "completed",
|
||||
input: { file_path: "src/index.ts", diff: "@@\\n-old\\n+new\\n" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(editItem.detail?.type).toBe("edit");
|
||||
if (editItem.detail?.type === "edit") {
|
||||
expect(editItem.detail.filePath).toBe("src/index.ts");
|
||||
expect(editItem.detail.unifiedDiff).toContain("@@");
|
||||
}
|
||||
|
||||
const searchItem = mapOpencodeToolCall({
|
||||
toolName: "web_search",
|
||||
callId: "opencode-search-1",
|
||||
status: "completed",
|
||||
input: { query: "opencode mapper" },
|
||||
output: null,
|
||||
});
|
||||
const searchItem = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "web_search",
|
||||
callId: "opencode-search-1",
|
||||
status: "completed",
|
||||
input: { query: "opencode mapper" },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "opencode mapper",
|
||||
@@ -182,13 +214,15 @@ describe("opencode tool-call mapper", () => {
|
||||
});
|
||||
|
||||
it("maps unknown tools to unknown detail with raw payloads", () => {
|
||||
const item = mapOpencodeToolCall({
|
||||
toolName: "my_custom_tool",
|
||||
callId: "opencode-call-4",
|
||||
status: "completed",
|
||||
input: { foo: "bar" },
|
||||
output: { ok: true },
|
||||
});
|
||||
const item = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "my_custom_tool",
|
||||
callId: "opencode-call-4",
|
||||
status: "completed",
|
||||
input: { foo: "bar" },
|
||||
output: { ok: true },
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
expect(item.error).toBeNull();
|
||||
@@ -198,4 +232,35 @@ describe("opencode tool-call mapper", () => {
|
||||
output: { ok: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not apply cross-provider speak normalization in opencode mapper", () => {
|
||||
const item = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "paseo_voice.speak",
|
||||
callId: "opencode-call-voice-1",
|
||||
status: "completed",
|
||||
input: { text: "Voice response from OpenCode." },
|
||||
output: { ok: true },
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.name).toBe("paseo_voice.speak");
|
||||
expect(item.detail).toEqual({
|
||||
type: "unknown",
|
||||
input: { text: "Voice response from OpenCode." },
|
||||
output: { ok: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops tool calls when callId is missing", () => {
|
||||
const item = mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: null,
|
||||
status: "completed",
|
||||
input: { file_path: "README.md" },
|
||||
output: { content: "hello" },
|
||||
});
|
||||
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { coerceToolCallId } from "../tool-call-mapper-utils.js";
|
||||
import { deriveOpencodeToolDetail } from "./tool-call-detail-parser.js";
|
||||
|
||||
type OpencodeToolCallParams = {
|
||||
@@ -18,7 +17,14 @@ const FAILED_STATUSES = new Set(["error", "failed", "failure"]);
|
||||
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "aborted", "interrupted"]);
|
||||
const COMPLETED_STATUSES = new Set(["complete", "completed", "success", "succeeded", "done"]);
|
||||
|
||||
const OpencodeToolCallParamsSchema = z
|
||||
const OpencodeToolCallStatusSchema = z.enum([
|
||||
"running",
|
||||
"completed",
|
||||
"failed",
|
||||
"canceled",
|
||||
]);
|
||||
|
||||
const OpencodeRawToolCallSchema = z
|
||||
.object({
|
||||
toolName: z.string().min(1),
|
||||
callId: z.string().optional().nullable(),
|
||||
@@ -26,74 +32,145 @@ const OpencodeToolCallParamsSchema = z
|
||||
input: z.unknown().optional(),
|
||||
output: z.unknown().optional(),
|
||||
error: z.unknown().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function coerceCallId(callId: string | null | undefined, toolName: string, input: unknown): string {
|
||||
return coerceToolCallId({
|
||||
providerPrefix: "opencode",
|
||||
rawCallId: callId,
|
||||
toolName,
|
||||
input,
|
||||
});
|
||||
}
|
||||
const OpencodeNormalizedToolCallPass1Schema = OpencodeRawToolCallSchema.transform((raw) => {
|
||||
const input = raw.input ?? null;
|
||||
const output = raw.output ?? null;
|
||||
const error = raw.error ?? null;
|
||||
const callId =
|
||||
typeof raw.callId === "string" && raw.callId.trim().length > 0
|
||||
? raw.callId.trim()
|
||||
: null;
|
||||
let status: z.infer<typeof OpencodeToolCallStatusSchema>;
|
||||
|
||||
function resolveStatus(
|
||||
rawStatus: unknown,
|
||||
error: unknown,
|
||||
output: unknown
|
||||
): ToolCallTimelineItem["status"] {
|
||||
if (error !== null && error !== undefined) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
if (typeof rawStatus === "string") {
|
||||
const normalized = rawStatus.trim().toLowerCase();
|
||||
if (normalized.length > 0) {
|
||||
if (FAILED_STATUSES.has(normalized)) {
|
||||
return "failed";
|
||||
}
|
||||
if (CANCELED_STATUSES.has(normalized)) {
|
||||
return "canceled";
|
||||
}
|
||||
if (COMPLETED_STATUSES.has(normalized)) {
|
||||
return "completed";
|
||||
}
|
||||
return "running";
|
||||
if (error !== null) {
|
||||
status = "failed";
|
||||
} else if (typeof raw.status === "string") {
|
||||
const normalized = raw.status.trim().toLowerCase();
|
||||
if (FAILED_STATUSES.has(normalized)) {
|
||||
status = "failed";
|
||||
} else if (CANCELED_STATUSES.has(normalized)) {
|
||||
status = "canceled";
|
||||
} else if (COMPLETED_STATUSES.has(normalized)) {
|
||||
status = "completed";
|
||||
} else {
|
||||
status = "running";
|
||||
}
|
||||
}
|
||||
|
||||
return output !== null && output !== undefined ? "completed" : "running";
|
||||
}
|
||||
|
||||
export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem {
|
||||
const parsedParams = OpencodeToolCallParamsSchema.parse(params);
|
||||
const input = parsedParams.input ?? null;
|
||||
const output = parsedParams.output ?? null;
|
||||
const status = resolveStatus(parsedParams.status, parsedParams.error, output);
|
||||
const callId = coerceCallId(parsedParams.callId, parsedParams.toolName, input);
|
||||
const detail = deriveOpencodeToolDetail(parsedParams.toolName, input, output);
|
||||
|
||||
if (status === "failed") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: parsedParams.toolName,
|
||||
status: "failed",
|
||||
detail,
|
||||
error: parsedParams.error ?? { message: "Tool call failed" },
|
||||
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
|
||||
};
|
||||
} else {
|
||||
status = output !== null ? "completed" : "running";
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: parsedParams.toolName,
|
||||
name: raw.toolName.trim(),
|
||||
input,
|
||||
output,
|
||||
error,
|
||||
metadata: raw.metadata,
|
||||
status,
|
||||
};
|
||||
});
|
||||
|
||||
const OpencodeKnownToolNameSchema = z.union([
|
||||
z.literal("shell"),
|
||||
z.literal("bash"),
|
||||
z.literal("exec_command"),
|
||||
z.literal("read"),
|
||||
z.literal("read_file"),
|
||||
z.literal("write"),
|
||||
z.literal("write_file"),
|
||||
z.literal("create_file"),
|
||||
z.literal("edit"),
|
||||
z.literal("apply_patch"),
|
||||
z.literal("apply_diff"),
|
||||
z.literal("search"),
|
||||
z.literal("web_search"),
|
||||
]);
|
||||
|
||||
const OpencodeToolCallPass2BaseSchema = z.object({
|
||||
callId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
input: z.unknown().nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
error: z.unknown().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
status: OpencodeToolCallStatusSchema,
|
||||
toolKind: z.enum(["known", "other"]),
|
||||
});
|
||||
|
||||
const OpencodeToolCallPass2InputSchema = OpencodeToolCallPass2BaseSchema.omit({
|
||||
toolKind: true,
|
||||
});
|
||||
|
||||
const OpencodeToolCallPass2EnvelopeSchema = z.union([
|
||||
OpencodeToolCallPass2InputSchema.extend({
|
||||
name: OpencodeKnownToolNameSchema,
|
||||
}).transform((normalized) => ({
|
||||
...normalized,
|
||||
name: normalized.name.trim(),
|
||||
toolKind: "known" as const,
|
||||
})),
|
||||
OpencodeToolCallPass2InputSchema.transform((normalized) => ({
|
||||
...normalized,
|
||||
name: normalized.name.trim(),
|
||||
toolKind: "other" as const,
|
||||
})),
|
||||
]);
|
||||
|
||||
const OpencodeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
|
||||
OpencodeToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("known"),
|
||||
name: OpencodeKnownToolNameSchema,
|
||||
}),
|
||||
OpencodeToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("other"),
|
||||
}),
|
||||
]);
|
||||
|
||||
type OpencodeToolCallPass2 = z.infer<typeof OpencodeToolCallPass2Schema>;
|
||||
|
||||
function toToolCallTimelineItem(normalized: OpencodeToolCallPass2): ToolCallTimelineItem {
|
||||
const detail = deriveOpencodeToolDetail(normalized.name, normalized.input, normalized.output);
|
||||
if (normalized.status === "failed") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: normalized.callId,
|
||||
name: normalized.name,
|
||||
status: "failed",
|
||||
detail,
|
||||
error: normalized.error ?? { message: "Tool call failed" },
|
||||
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: normalized.callId,
|
||||
name: normalized.name,
|
||||
status: normalized.status,
|
||||
detail,
|
||||
error: null,
|
||||
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
|
||||
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem | null {
|
||||
const pass1 = OpencodeNormalizedToolCallPass1Schema.safeParse(params);
|
||||
if (!pass1.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pass2Envelope = OpencodeToolCallPass2EnvelopeSchema.safeParse(pass1.data);
|
||||
if (!pass2Envelope.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pass2 = OpencodeToolCallPass2Schema.safeParse(pass2Envelope.data);
|
||||
if (!pass2.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toToolCallTimelineItem(pass2.data);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "../agent-sdk-types.js";
|
||||
import {
|
||||
commandFromValue,
|
||||
extractCodexShellOutput,
|
||||
flattenReadContent as flattenToolReadContent,
|
||||
nonEmptyString,
|
||||
@@ -29,9 +28,19 @@ export const ToolShellInputSchema = z
|
||||
.passthrough(),
|
||||
])
|
||||
.transform((value) => {
|
||||
const commandValue = "command" in value ? value.command : value.cmd;
|
||||
const parsedCommand = CommandValueSchema.safeParse(
|
||||
"command" in value ? value.command : value.cmd
|
||||
);
|
||||
const command = parsedCommand.success
|
||||
? typeof parsedCommand.data === "string"
|
||||
? nonEmptyString(parsedCommand.data)
|
||||
: parsedCommand.data
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0)
|
||||
.join(" ") || undefined
|
||||
: undefined;
|
||||
return {
|
||||
command: commandFromValue(commandValue),
|
||||
command,
|
||||
cwd: nonEmptyString(value.cwd) ?? nonEmptyString(value.directory),
|
||||
};
|
||||
});
|
||||
@@ -314,11 +323,8 @@ type ToolReadOutputValue = {
|
||||
content?: string;
|
||||
};
|
||||
|
||||
export const ToolReadOutputSchema: z.ZodType<
|
||||
ToolReadOutputValue,
|
||||
z.ZodTypeDef,
|
||||
unknown
|
||||
> = ToolReadOutputContentSchema;
|
||||
export const ToolReadOutputSchema: z.ZodType<ToolReadOutputValue, z.ZodTypeDef, unknown> =
|
||||
ToolReadOutputContentSchema;
|
||||
|
||||
export const ToolReadOutputWithPathSchema: z.ZodType<
|
||||
ToolReadOutputValue,
|
||||
@@ -508,8 +514,8 @@ export const ToolSearchInputSchema = z.union([
|
||||
export type ParsedToolShellInput = z.infer<typeof ToolShellInputSchema>;
|
||||
export type ParsedToolShellOutput = z.infer<typeof ToolShellOutputSchema>;
|
||||
export type ParsedToolReadInput = z.infer<typeof ToolReadInputSchema>;
|
||||
export type ParsedToolReadOutput = z.infer<typeof ToolReadOutputSchema>;
|
||||
export type ParsedToolReadOutputWithPath = z.infer<typeof ToolReadOutputWithPathSchema>;
|
||||
export type ParsedToolReadOutput = ToolReadOutputValue;
|
||||
export type ParsedToolReadOutputWithPath = ToolReadOutputValue;
|
||||
export type ParsedToolWriteInput = z.infer<typeof ToolWriteInputSchema>;
|
||||
export type ParsedToolWriteOutput = z.infer<typeof ToolWriteOutputSchema>;
|
||||
export type ParsedToolEditInput = z.infer<typeof ToolEditInputSchema>;
|
||||
@@ -649,7 +655,13 @@ export function toolDetailBranchByName<
|
||||
input: inputSchema.nullable(),
|
||||
output: outputSchema.nullable(),
|
||||
})
|
||||
.transform(({ input, output }) => mapper(input, output));
|
||||
.transform((value) => {
|
||||
const parsed = value as unknown as {
|
||||
input: z.infer<InputSchema> | null;
|
||||
output: z.infer<OutputSchema> | null;
|
||||
};
|
||||
return mapper(parsed.input, parsed.output);
|
||||
});
|
||||
}
|
||||
|
||||
export function toolDetailBranchByToolName<
|
||||
@@ -671,7 +683,13 @@ export function toolDetailBranchByToolName<
|
||||
input: inputSchema.nullable(),
|
||||
output: outputSchema.nullable(),
|
||||
})
|
||||
.transform(({ input, output }) => mapper(input, output));
|
||||
.transform((value) => {
|
||||
const parsed = value as unknown as {
|
||||
input: z.infer<InputSchema> | null;
|
||||
output: z.infer<OutputSchema> | null;
|
||||
};
|
||||
return mapper(parsed.input, parsed.output);
|
||||
});
|
||||
}
|
||||
|
||||
export function toolDetailBranchByNameWithCwd<
|
||||
@@ -695,5 +713,12 @@ export function toolDetailBranchByNameWithCwd<
|
||||
output: outputSchema.nullable(),
|
||||
cwd: z.string().optional().nullable(),
|
||||
})
|
||||
.transform(({ input, output, cwd }) => mapper(input, output, cwd ?? null));
|
||||
.transform((value) => {
|
||||
const parsed = value as unknown as {
|
||||
input: z.infer<InputSchema> | null;
|
||||
output: z.infer<OutputSchema> | null;
|
||||
cwd?: string | null;
|
||||
};
|
||||
return mapper(parsed.input, parsed.output, parsed.cwd ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,19 +8,6 @@ export function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function commandFromValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return nonEmptyString(value);
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const tokens = value.filter(
|
||||
(token): token is string => typeof token === "string" && token.length > 0
|
||||
);
|
||||
return tokens.length > 0 ? tokens.join(" ") : undefined;
|
||||
}
|
||||
|
||||
const CODEX_SHELL_ENVELOPE_HEADER_LINES = new Set([
|
||||
"chunk id:",
|
||||
"wall time:",
|
||||
|
||||
@@ -229,17 +229,6 @@ export class TTSManager {
|
||||
isVoiceMode,
|
||||
},
|
||||
});
|
||||
this.logger.info(
|
||||
{
|
||||
audioId,
|
||||
chunkId,
|
||||
chunkIndex,
|
||||
isLastChunk: next.done,
|
||||
bytes: chunkBuffer.length,
|
||||
isVoiceMode,
|
||||
},
|
||||
"Emitted audio_output chunk to client"
|
||||
);
|
||||
|
||||
chunkIndex += 1;
|
||||
|
||||
@@ -298,15 +287,6 @@ export class TTSManager {
|
||||
}
|
||||
|
||||
pending.pendingChunks = Math.max(0, pending.pendingChunks - 1);
|
||||
this.logger.info(
|
||||
{
|
||||
chunkId,
|
||||
audioId,
|
||||
remainingPendingChunks: pending.pendingChunks,
|
||||
streamEnded: pending.streamEnded,
|
||||
},
|
||||
"Received audio playback confirmation from client"
|
||||
);
|
||||
|
||||
if (pending.pendingChunks === 0 && pending.streamEnded) {
|
||||
pending.resolve();
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
encodeOfferToFragmentUrl,
|
||||
} from "./connection-offer.js";
|
||||
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
|
||||
import { printPairingQrIfEnabled } from "./pairing-qr.js";
|
||||
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
||||
import { getOrCreateServerId } from "./server-id.js";
|
||||
import type {
|
||||
@@ -548,7 +547,6 @@ export async function createPaseoDaemon(
|
||||
|
||||
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||
logger.info({ url }, "pairing_offer");
|
||||
void printPairingQrIfEnabled({ url, logger }).catch(() => undefined);
|
||||
} else {
|
||||
logger.info("relay_disabled");
|
||||
}
|
||||
|
||||
@@ -1012,63 +1012,11 @@ describe("daemon client E2E", () => {
|
||||
expect(checkoutStatus.isGit).toBe(true);
|
||||
expect(checkoutStatus.repoRoot).toContain(cwd);
|
||||
|
||||
const diffRequestId = `diff-${Date.now()}`;
|
||||
const diffMessagePromise = waitForSignal(15000, (resolve) => {
|
||||
const unsubscribeDiff = ctx.client.on("git_diff_response", (message) => {
|
||||
if (message.type !== "git_diff_response") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.agentId !== agent.id) {
|
||||
return;
|
||||
}
|
||||
if (message.payload.requestId !== diffRequestId) {
|
||||
return;
|
||||
}
|
||||
resolve(message);
|
||||
});
|
||||
return unsubscribeDiff;
|
||||
});
|
||||
|
||||
const diffResult = await ctx.client.getGitDiff(agent.id, diffRequestId);
|
||||
const diffMessage = await diffMessagePromise;
|
||||
const diffResult = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
expect(diffResult.error).toBeNull();
|
||||
expect(diffResult.diff).toContain("test.txt");
|
||||
expect(diffResult.diff).toContain("-original content");
|
||||
expect(diffResult.diff).toContain("+modified content");
|
||||
expect(diffResult.requestId).toBe(diffRequestId);
|
||||
expect(diffMessage.payload.agentId).toBe(agent.id);
|
||||
expect(diffMessage.payload.requestId).toBe(diffRequestId);
|
||||
|
||||
const highlightRequestId = `highlight-${Date.now()}`;
|
||||
const highlightMessagePromise = waitForSignal(15000, (resolve) => {
|
||||
const unsubscribeHighlight = ctx.client.on(
|
||||
"highlighted_diff_response",
|
||||
(message) => {
|
||||
if (message.type !== "highlighted_diff_response") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.agentId !== agent.id) {
|
||||
return;
|
||||
}
|
||||
if (message.payload.requestId !== highlightRequestId) {
|
||||
return;
|
||||
}
|
||||
resolve(message);
|
||||
}
|
||||
);
|
||||
return unsubscribeHighlight;
|
||||
});
|
||||
|
||||
const highlightResult = await ctx.client.getHighlightedDiff(
|
||||
agent.id,
|
||||
highlightRequestId
|
||||
);
|
||||
const highlightMessage = await highlightMessagePromise;
|
||||
expect(highlightResult.error).toBeNull();
|
||||
expect(Array.isArray(highlightResult.files)).toBe(true);
|
||||
expect(highlightResult.requestId).toBe(highlightRequestId);
|
||||
expect(highlightMessage.payload.agentId).toBe(agent.id);
|
||||
expect(highlightMessage.payload.requestId).toBe(highlightRequestId);
|
||||
expect(Array.isArray(diffResult.files)).toBe(true);
|
||||
expect(diffResult.files.length).toBeGreaterThan(0);
|
||||
expect(diffResult.files.some((file) => file.path === "test.txt")).toBe(true);
|
||||
|
||||
const listRequestId = `list-${Date.now()}`;
|
||||
const listMessagePromise = waitForSignal(15000, (resolve) => {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { createDaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
const RUN = process.env.PASEO_GIT_DIFF_BOTTLENECK_E2E === "1";
|
||||
const LARGE_CHANGESET_SIZE = Number.parseInt(
|
||||
process.env.PASEO_GIT_DIFF_BOTTLENECK_FILE_COUNT ?? "1200",
|
||||
10
|
||||
);
|
||||
|
||||
function tmpRepo(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "paseo-git-diff-bottleneck-"));
|
||||
}
|
||||
|
||||
function initGitRepo(cwd: string): void {
|
||||
execSync("git init -b main", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||
}
|
||||
|
||||
function seedLargeDirtyRepo(cwd: string, fileCount: number): void {
|
||||
mkdirSync(path.join(cwd, "files"), { recursive: true });
|
||||
for (let i = 0; i < fileCount; i += 1) {
|
||||
writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i}\n`);
|
||||
}
|
||||
execSync("git add .", { cwd, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'init'", {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
for (let i = 0; i < fileCount; i += 1) {
|
||||
writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i} changed\n`);
|
||||
}
|
||||
|
||||
// Explicit binary artifact to verify we do not diff binary contents.
|
||||
writeFileSync(path.join(cwd, "blob.bin"), Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f]));
|
||||
}
|
||||
|
||||
const runDescribe = RUN ? describe : describe.skip;
|
||||
|
||||
runDescribe("daemon E2E git diff bottleneck profiling", () => {
|
||||
test(
|
||||
"shows per-file git diff subprocess fanout and timeout pressure",
|
||||
async () => {
|
||||
const cwd = tmpRepo();
|
||||
|
||||
try {
|
||||
initGitRepo(cwd);
|
||||
seedLargeDirtyRepo(cwd, LARGE_CHANGESET_SIZE);
|
||||
|
||||
const cliStart = performance.now();
|
||||
const cliDiff = execSync("git diff HEAD", { cwd, stdio: "pipe" }).toString();
|
||||
const cliMs = performance.now() - cliStart;
|
||||
|
||||
const ctx = await createDaemonTestContext();
|
||||
try {
|
||||
const checkoutStart = performance.now();
|
||||
const checkoutPayload = await ctx.client.getCheckoutDiff(cwd, {
|
||||
mode: "uncommitted",
|
||||
});
|
||||
const checkoutMs = performance.now() - checkoutStart;
|
||||
|
||||
expect(checkoutPayload.error).toBeNull();
|
||||
expect(checkoutPayload.files.length).toBeGreaterThanOrEqual(LARGE_CHANGESET_SIZE);
|
||||
|
||||
const binaryEntry = checkoutPayload.files.find((file) => file.path === "blob.bin");
|
||||
expect(binaryEntry).toBeTruthy();
|
||||
expect(binaryEntry?.status).toBe("binary");
|
||||
|
||||
// Keep this visible in test output for local bottleneck analysis.
|
||||
console.info(
|
||||
"[git-diff-bottleneck]",
|
||||
JSON.stringify(
|
||||
{
|
||||
fileCount: LARGE_CHANGESET_SIZE,
|
||||
cliMs: Math.round(cliMs),
|
||||
cliDiffBytes: cliDiff.length,
|
||||
checkoutMs: Math.round(checkoutMs),
|
||||
checkoutFiles: checkoutPayload.files.length,
|
||||
speedRatio: Number((checkoutMs / Math.max(cliMs, 1)).toFixed(2)),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
expect(checkoutMs).toBeLessThan(cliMs * 10);
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
240000
|
||||
);
|
||||
});
|
||||
@@ -110,7 +110,7 @@ describe("daemon E2E", () => {
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
describe("getGitDiff", () => {
|
||||
describe("getCheckoutDiff", () => {
|
||||
test(
|
||||
"returns diff for modified file in git repo",
|
||||
async () => {
|
||||
@@ -134,28 +134,12 @@ describe("daemon E2E", () => {
|
||||
// Modify the file (creates unstaged changes)
|
||||
writeFileSync(testFile, "modified content\n");
|
||||
|
||||
// Create agent in the git repo
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Git Diff Test",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Get git diff
|
||||
const result = await ctx.client.getGitDiff(agent.id);
|
||||
|
||||
// Verify diff returned without error
|
||||
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.diff).toBeTruthy();
|
||||
expect(result.diff).toContain("test.txt");
|
||||
expect(result.diff).toContain("-original content");
|
||||
expect(result.diff).toContain("+modified content");
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
expect(result.files.length).toBeGreaterThan(0);
|
||||
const file = result.files.find((entry) => entry.path === "test.txt");
|
||||
expect(file).toBeTruthy();
|
||||
expect(file?.hunks.length).toBeGreaterThan(0);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
60000 // 1 minute timeout
|
||||
@@ -181,23 +165,11 @@ describe("daemon E2E", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
// Create agent in the git repo (no modifications)
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Git Diff Clean Test",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
|
||||
// Get git diff - should be empty
|
||||
const result = await ctx.client.getGitDiff(agent.id);
|
||||
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.diff).toBe("");
|
||||
expect(result.files).toEqual([]);
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
60000 // 1 minute timeout
|
||||
@@ -209,24 +181,12 @@ describe("daemon E2E", () => {
|
||||
const cwd = tmpCwd();
|
||||
// Don't initialize git - just a regular directory
|
||||
|
||||
// Create agent in a non-git directory
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Git Diff Non-Git Test",
|
||||
});
|
||||
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
|
||||
// Get git diff - should return error
|
||||
const result = await ctx.client.getGitDiff(agent.id);
|
||||
|
||||
expect(result.diff).toBe("");
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.error).toContain("git");
|
||||
expect(result.error?.code).toBe("NOT_GIT_REPO");
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
60000 // 1 minute timeout
|
||||
|
||||
@@ -315,7 +315,7 @@ export class DictationStreamManager {
|
||||
state.bytesSinceCommit += resampled.length;
|
||||
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
|
||||
try {
|
||||
this.maybeAutoCommitDictationSegment(params.dictationId, state);
|
||||
this.maybeAutoCommitDictationSegment(state);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
void this.failAndCleanupDictationStream(params.dictationId, message, true);
|
||||
@@ -482,7 +482,7 @@ export class DictationStreamManager {
|
||||
this.streams.delete(dictationId);
|
||||
}
|
||||
|
||||
private maybeAutoCommitDictationSegment(dictationId: string, state: DictationStreamState): void {
|
||||
private maybeAutoCommitDictationSegment(state: DictationStreamState): void {
|
||||
if (state.finishRequested) {
|
||||
return;
|
||||
}
|
||||
@@ -490,29 +490,12 @@ export class DictationStreamManager {
|
||||
return;
|
||||
}
|
||||
if (state.peakSinceCommit < DICTATION_SILENCE_PEAK_THRESHOLD) {
|
||||
this.logger.debug(
|
||||
{
|
||||
dictationId,
|
||||
autoCommitBytes: state.autoCommitBytes,
|
||||
bytesSinceCommit: state.bytesSinceCommit,
|
||||
peakSinceCommit: state.peakSinceCommit,
|
||||
},
|
||||
"Dictation auto-segment: clearing silence-only segment"
|
||||
);
|
||||
state.stt.clear();
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
dictationId,
|
||||
autoCommitBytes: state.autoCommitBytes,
|
||||
bytesSinceCommit: state.bytesSinceCommit,
|
||||
},
|
||||
"Dictation auto-segment: committing buffered audio"
|
||||
);
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
state.stt.commit();
|
||||
|
||||
@@ -6,6 +6,13 @@ export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
||||
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
||||
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
|
||||
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
|
||||
export {
|
||||
ensureLocalSpeechModels,
|
||||
listLocalSpeechModels,
|
||||
type LocalSpeechModelId,
|
||||
type LocalSttModelId,
|
||||
type LocalTtsModelId,
|
||||
} from "./speech/providers/local/models.js";
|
||||
|
||||
// Agent SDK types for CLI commands
|
||||
export type {
|
||||
|
||||
@@ -41,6 +41,7 @@ const SpeechProviderIdSchema = z
|
||||
|
||||
const FeatureDictationSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
stt: z
|
||||
.object({
|
||||
provider: SpeechProviderIdSchema.optional(),
|
||||
@@ -54,6 +55,7 @@ const FeatureDictationSchema = z
|
||||
|
||||
const FeatureVoiceModeSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
llm: z
|
||||
.object({
|
||||
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(),
|
||||
|
||||
191
packages/server/src/server/relay-transport.test.ts
Normal file
191
packages/server/src/server/relay-transport.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const wsMock = vi.hoisted(() => {
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
static instances: MockWebSocket[] = [];
|
||||
|
||||
readonly url: string;
|
||||
readonly options: unknown;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
sent: string[] = [];
|
||||
terminateCalls = 0;
|
||||
private listeners = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
constructor(url: string, options?: unknown) {
|
||||
this.url = url;
|
||||
this.options = options;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
static reset() {
|
||||
MockWebSocket.instances = [];
|
||||
}
|
||||
|
||||
on(event: string, listener: (...args: any[]) => void) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
handlers.push(listener);
|
||||
this.listeners.set(event, handlers);
|
||||
return this;
|
||||
}
|
||||
|
||||
once(event: string, listener: (...args: any[]) => void) {
|
||||
const wrapped = (...args: any[]) => {
|
||||
this.off(event, wrapped);
|
||||
listener(...args);
|
||||
};
|
||||
return this.on(event, wrapped);
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.emit("close", code ?? 1000, reason ?? "");
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminateCalls += 1;
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.emit("close", 1006, "");
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
if (this.readyState !== MockWebSocket.OPEN) {
|
||||
throw new Error(`WebSocket not open (readyState=${this.readyState})`);
|
||||
}
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.emit("open");
|
||||
}
|
||||
|
||||
message(data: unknown) {
|
||||
this.emit("message", data);
|
||||
}
|
||||
|
||||
error(err: unknown) {
|
||||
this.emit("error", err);
|
||||
}
|
||||
|
||||
private off(event: string, listener: (...args: any[]) => void) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
this.listeners.set(
|
||||
event,
|
||||
handlers.filter((handler) => handler !== listener)
|
||||
);
|
||||
}
|
||||
|
||||
private emit(event: string, ...args: any[]) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
for (const handler of [...handlers]) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { MockWebSocket };
|
||||
});
|
||||
|
||||
vi.mock("ws", () => ({ default: wsMock.MockWebSocket }));
|
||||
|
||||
import { startRelayTransport } from "./relay-transport";
|
||||
|
||||
function createMockLogger() {
|
||||
const logger = {
|
||||
child: vi.fn(() => logger),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
return logger;
|
||||
}
|
||||
|
||||
function hasLogMessage(mockFn: ReturnType<typeof vi.fn>, message: string): boolean {
|
||||
return mockFn.mock.calls.some((call) => call.some((arg) => arg === message));
|
||||
}
|
||||
|
||||
describe("relay-transport control lifecycle", () => {
|
||||
const controllers: Array<{ stop: () => Promise<void> }> = [];
|
||||
const MockWebSocket = wsMock.MockWebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
MockWebSocket.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const controller of controllers) {
|
||||
await controller.stop();
|
||||
}
|
||||
controllers.length = 0;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("logs relay_control_connected only after first valid control message", () => {
|
||||
const logger = createMockLogger();
|
||||
const controller = startRelayTransport({
|
||||
logger: logger as any,
|
||||
attachSocket: async () => {},
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
serverId: "srv_test",
|
||||
});
|
||||
controllers.push(controller);
|
||||
|
||||
const control = MockWebSocket.instances[0];
|
||||
expect(control).toBeDefined();
|
||||
|
||||
control.open();
|
||||
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(false);
|
||||
expect(control.sent.length).toBeGreaterThan(0);
|
||||
|
||||
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(true);
|
||||
});
|
||||
|
||||
test("terminates and reconnects when control socket opens but never becomes ready", () => {
|
||||
vi.useFakeTimers();
|
||||
const logger = createMockLogger();
|
||||
const controller = startRelayTransport({
|
||||
logger: logger as any,
|
||||
attachSocket: async () => {},
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
serverId: "srv_test",
|
||||
});
|
||||
controllers.push(controller);
|
||||
|
||||
const firstControl = MockWebSocket.instances[0];
|
||||
firstControl.open();
|
||||
|
||||
vi.advanceTimersByTime(8_000);
|
||||
expect(hasLogMessage(logger.warn, "relay_control_ready_timeout_terminating")).toBe(true);
|
||||
expect(firstControl.terminateCalls).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("terminates stale control sockets in under one minute", () => {
|
||||
vi.useFakeTimers();
|
||||
const logger = createMockLogger();
|
||||
const controller = startRelayTransport({
|
||||
logger: logger as any,
|
||||
attachSocket: async () => {},
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
serverId: "srv_test",
|
||||
});
|
||||
controllers.push(controller);
|
||||
|
||||
const control = MockWebSocket.instances[0];
|
||||
control.open();
|
||||
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||
logger.warn.mockClear();
|
||||
|
||||
vi.advanceTimersByTime(40_000);
|
||||
expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true);
|
||||
expect(control.terminateCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,10 @@ type ControlMessage =
|
||||
| { type: "ping" }
|
||||
| { type: "pong" };
|
||||
|
||||
const CONTROL_PING_INTERVAL_MS = 10_000;
|
||||
const CONTROL_STALE_TIMEOUT_MS = 30_000;
|
||||
const CONTROL_READY_TIMEOUT_MS = 8_000;
|
||||
|
||||
function tryParseControlMessage(raw: unknown): ControlMessage | null {
|
||||
try {
|
||||
const text =
|
||||
@@ -76,7 +80,9 @@ export function startRelayTransport({
|
||||
let reconnectAttempt = 0;
|
||||
const dataSockets = new Map<string, WebSocket>(); // clientId -> ws
|
||||
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let controlLastSeenAt = 0;
|
||||
let controlConnectionSeq = 0;
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
stopped = true;
|
||||
@@ -88,6 +94,10 @@ export function startRelayTransport({
|
||||
clearInterval(controlKeepaliveInterval);
|
||||
controlKeepaliveInterval = null;
|
||||
}
|
||||
if (controlReadyTimeout) {
|
||||
clearTimeout(controlReadyTimeout);
|
||||
controlReadyTimeout = null;
|
||||
}
|
||||
if (controlWs) {
|
||||
try {
|
||||
controlWs.close();
|
||||
@@ -109,6 +119,7 @@ export function startRelayTransport({
|
||||
const connectControl = (): void => {
|
||||
if (stopped) return;
|
||||
|
||||
const connectionId = ++controlConnectionSeq;
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: relayEndpoint,
|
||||
serverId,
|
||||
@@ -116,14 +127,46 @@ export function startRelayTransport({
|
||||
});
|
||||
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
|
||||
controlWs = socket;
|
||||
let controlConnected = false;
|
||||
|
||||
const markControlReady = () => {
|
||||
if (controlWs !== socket) return;
|
||||
if (controlConnected) return;
|
||||
controlConnected = true;
|
||||
reconnectAttempt = 0;
|
||||
if (controlReadyTimeout) {
|
||||
clearTimeout(controlReadyTimeout);
|
||||
controlReadyTimeout = null;
|
||||
}
|
||||
relayLogger.info({ url, connectionId }, "relay_control_connected");
|
||||
};
|
||||
|
||||
socket.on("open", () => {
|
||||
reconnectAttempt = 0;
|
||||
if (controlWs !== socket) return;
|
||||
|
||||
controlLastSeenAt = Date.now();
|
||||
if (controlKeepaliveInterval) {
|
||||
clearInterval(controlKeepaliveInterval);
|
||||
controlKeepaliveInterval = null;
|
||||
}
|
||||
if (controlReadyTimeout) {
|
||||
clearTimeout(controlReadyTimeout);
|
||||
controlReadyTimeout = null;
|
||||
}
|
||||
controlReadyTimeout = setTimeout(() => {
|
||||
if (stopped) return;
|
||||
if (controlWs !== socket) return;
|
||||
if (controlConnected) return;
|
||||
relayLogger.warn(
|
||||
{ url, connectionId, waitedMs: CONTROL_READY_TIMEOUT_MS },
|
||||
"relay_control_ready_timeout_terminating"
|
||||
);
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, CONTROL_READY_TIMEOUT_MS);
|
||||
controlKeepaliveInterval = setInterval(() => {
|
||||
if (stopped) return;
|
||||
if (controlWs !== socket) return;
|
||||
@@ -133,8 +176,11 @@ export function startRelayTransport({
|
||||
const staleForMs = now - controlLastSeenAt;
|
||||
// If the control socket is half-open or silently dropped, ws may never emit "close".
|
||||
// Use app-level ping/pong to detect staleness and force a reconnect.
|
||||
if (staleForMs > 90_000) {
|
||||
relayLogger.warn({ url, staleForMs }, "relay_control_stale_terminating");
|
||||
if (staleForMs > CONTROL_STALE_TIMEOUT_MS) {
|
||||
relayLogger.warn(
|
||||
{ url, staleForMs, connectionId, staleTimeoutMs: CONTROL_STALE_TIMEOUT_MS },
|
||||
"relay_control_stale_terminating"
|
||||
);
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
@@ -146,40 +192,58 @@ export function startRelayTransport({
|
||||
try {
|
||||
socket.send(JSON.stringify({ type: "ping", ts: now }));
|
||||
} catch (error) {
|
||||
relayLogger.warn({ err: error, url }, "relay_control_ping_send_failed");
|
||||
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, 20_000);
|
||||
relayLogger.info({ url }, "relay_control_connected");
|
||||
}, CONTROL_PING_INTERVAL_MS);
|
||||
try {
|
||||
socket.send(JSON.stringify({ type: "ping", ts: Date.now() }));
|
||||
} catch (error) {
|
||||
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
relayLogger.debug({ url, connectionId }, "relay_control_open_waiting_for_ready");
|
||||
});
|
||||
|
||||
socket.on("close", (code, reason) => {
|
||||
if (controlWs !== socket) return;
|
||||
relayLogger.warn(
|
||||
{ code, reason: reason?.toString?.(), url },
|
||||
{ code, reason: reason?.toString?.(), url, connectionId },
|
||||
"relay_control_disconnected"
|
||||
);
|
||||
if (controlWs === socket) {
|
||||
controlWs = null;
|
||||
}
|
||||
controlWs = null;
|
||||
if (controlKeepaliveInterval) {
|
||||
clearInterval(controlKeepaliveInterval);
|
||||
controlKeepaliveInterval = null;
|
||||
}
|
||||
if (controlReadyTimeout) {
|
||||
clearTimeout(controlReadyTimeout);
|
||||
controlReadyTimeout = null;
|
||||
}
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
relayLogger.warn({ err, url }, "relay_error");
|
||||
if (controlWs !== socket) return;
|
||||
relayLogger.warn({ err, url, connectionId }, "relay_error");
|
||||
// close event will schedule reconnect
|
||||
});
|
||||
|
||||
socket.on("message", (data) => {
|
||||
if (controlWs !== socket) return;
|
||||
controlLastSeenAt = Date.now();
|
||||
const msg = tryParseControlMessage(data);
|
||||
if (msg) {
|
||||
markControlReady();
|
||||
}
|
||||
if (!msg) return;
|
||||
if (msg.type === "ping") {
|
||||
try {
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
type ProjectPlacementPayload,
|
||||
} from "./messages.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
|
||||
import { TTSManager } from "./agent/tts-manager.js";
|
||||
import { STTManager } from "./agent/stt-manager.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||
@@ -64,6 +63,7 @@ import type {
|
||||
AgentPermissionResponse,
|
||||
AgentPromptContentBlock,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
McpServerConfig,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
@@ -581,7 +581,7 @@ export class Session {
|
||||
void this.initializeAgentMcp();
|
||||
this.subscribeToAgentEvents();
|
||||
|
||||
this.sessionLogger.info("Session created");
|
||||
this.sessionLogger.trace("Session created");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -684,7 +684,8 @@ export class Session {
|
||||
*/
|
||||
private startAgentStream(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput
|
||||
prompt: AgentPromptInput,
|
||||
runOptions?: AgentRunOptions
|
||||
): { ok: true } | { ok: false; error: string } {
|
||||
this.sessionLogger.info(
|
||||
{ agentId },
|
||||
@@ -693,7 +694,7 @@ export class Session {
|
||||
|
||||
let iterator: AsyncGenerator<AgentStreamEvent>;
|
||||
try {
|
||||
iterator = this.agentManager.streamAgent(agentId, prompt);
|
||||
iterator = this.agentManager.streamAgent(agentId, prompt, runOptions);
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(agentId, error, "Failed to start agent run");
|
||||
const message =
|
||||
@@ -750,7 +751,7 @@ export class Session {
|
||||
|
||||
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
|
||||
const agentToolCount = Object.keys(this.agentTools ?? {}).length;
|
||||
this.sessionLogger.info(
|
||||
this.sessionLogger.trace(
|
||||
{ agentToolCount },
|
||||
`Agent MCP initialized with ${agentToolCount} tools`
|
||||
);
|
||||
@@ -941,9 +942,17 @@ export class Session {
|
||||
agentId,
|
||||
extractTimestamps(record)
|
||||
);
|
||||
this.sessionLogger.info(
|
||||
{ agentId, provider: record.provider },
|
||||
"Agent resumed from persistence"
|
||||
);
|
||||
} else {
|
||||
const config = buildSessionConfig(record);
|
||||
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels });
|
||||
this.sessionLogger.info(
|
||||
{ agentId, provider: record.provider },
|
||||
"Agent created from stored config"
|
||||
);
|
||||
}
|
||||
|
||||
await this.agentManager.hydrateTimelineFromProvider(agentId);
|
||||
@@ -1125,6 +1134,15 @@ export class Session {
|
||||
await this.handleArchiveAgentRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
|
||||
case "update_agent_request":
|
||||
await this.handleUpdateAgentRequest(
|
||||
msg.agentId,
|
||||
msg.name,
|
||||
msg.labels,
|
||||
msg.requestId
|
||||
);
|
||||
break;
|
||||
|
||||
case "set_voice_mode":
|
||||
await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId);
|
||||
break;
|
||||
@@ -1206,10 +1224,6 @@ export class Session {
|
||||
);
|
||||
break;
|
||||
|
||||
case "git_diff_request":
|
||||
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
|
||||
case "checkout_status_request":
|
||||
await this.handleCheckoutStatusRequest(msg);
|
||||
break;
|
||||
@@ -1258,10 +1272,6 @@ export class Session {
|
||||
await this.handlePaseoWorktreeArchiveRequest(msg);
|
||||
break;
|
||||
|
||||
case "highlighted_diff_request":
|
||||
await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
|
||||
case "file_explorer_request":
|
||||
await this.handleFileExplorerRequest(msg);
|
||||
break;
|
||||
@@ -1278,6 +1288,10 @@ export class Session {
|
||||
await this.handleListProviderModelsRequest(msg);
|
||||
break;
|
||||
|
||||
case "list_available_providers_request":
|
||||
await this.handleListAvailableProvidersRequest(msg);
|
||||
break;
|
||||
|
||||
case "speech_models_list_request":
|
||||
await this.handleSpeechModelsListRequest(msg);
|
||||
break;
|
||||
@@ -1506,6 +1520,88 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
private async handleUpdateAgentRequest(
|
||||
agentId: string,
|
||||
name: string | undefined,
|
||||
labels: Record<string, string> | undefined,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
this.sessionLogger.info(
|
||||
{ agentId, requestId, hasName: typeof name === "string", labelCount: labels ? Object.keys(labels).length : 0 },
|
||||
"session: update_agent_request"
|
||||
);
|
||||
|
||||
const normalizedName = name?.trim();
|
||||
const normalizedLabels =
|
||||
labels && Object.keys(labels).length > 0 ? labels : undefined;
|
||||
|
||||
if (!normalizedName && !normalizedLabels) {
|
||||
this.emit({
|
||||
type: "update_agent_response",
|
||||
payload: {
|
||||
requestId,
|
||||
agentId,
|
||||
accepted: false,
|
||||
error: "Nothing to update (provide name and/or labels)",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const liveAgent = this.agentManager.getAgent(agentId);
|
||||
if (liveAgent) {
|
||||
if (normalizedName) {
|
||||
await this.agentManager.setTitle(agentId, normalizedName);
|
||||
}
|
||||
if (normalizedLabels) {
|
||||
await this.agentManager.setLabels(agentId, normalizedLabels);
|
||||
}
|
||||
} else {
|
||||
const existing = await this.agentStorage.get(agentId);
|
||||
if (!existing) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
await this.agentStorage.upsert({
|
||||
...existing,
|
||||
...(normalizedName ? { title: normalizedName } : {}),
|
||||
...(normalizedLabels
|
||||
? { labels: { ...existing.labels, ...normalizedLabels } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "update_agent_response",
|
||||
payload: { requestId, agentId, accepted: true, error: null },
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId, requestId },
|
||||
"session: update_agent_request error"
|
||||
);
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Failed to update agent: ${error.message}`,
|
||||
},
|
||||
});
|
||||
this.emit({
|
||||
type: "update_agent_response",
|
||||
payload: {
|
||||
requestId,
|
||||
agentId,
|
||||
accepted: false,
|
||||
error: error?.message ? String(error.message) : "Failed to update agent",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle voice mode toggle
|
||||
*/
|
||||
@@ -1946,7 +2042,8 @@ export class Session {
|
||||
agentId: string,
|
||||
text: string,
|
||||
messageId?: string,
|
||||
images?: Array<{ data: string; mimeType: string }>
|
||||
images?: Array<{ data: string; mimeType: string }>,
|
||||
runOptions?: AgentRunOptions
|
||||
): Promise<void> {
|
||||
this.sessionLogger.info(
|
||||
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
|
||||
@@ -1986,7 +2083,7 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
this.startAgentStream(agentId, prompt);
|
||||
this.startAgentStream(agentId, prompt, runOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1996,11 +2093,6 @@ export class Session {
|
||||
agentId: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
this.sessionLogger.info(
|
||||
{ agentId },
|
||||
`Initializing agent ${agentId} on demand`
|
||||
);
|
||||
|
||||
try {
|
||||
const snapshot = await this.ensureAgentLoaded(agentId);
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
@@ -2017,11 +2109,6 @@ export class Session {
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
|
||||
this.sessionLogger.info(
|
||||
{ agentId, timelineSize, status: snapshot.lifecycle },
|
||||
`Agent ${agentId} initialized with ${timelineSize} timeline item(s); status=${snapshot.lifecycle}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
@@ -2044,7 +2131,7 @@ export class Session {
|
||||
private async handleCreateAgentRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "create_agent_request" }>
|
||||
): Promise<void> {
|
||||
const { config, worktreeName, requestId, initialPrompt, git, images, labels } = msg;
|
||||
const { config, worktreeName, requestId, initialPrompt, outputSchema, git, images, labels } = msg;
|
||||
this.sessionLogger.info(
|
||||
{ cwd: config.cwd, provider: config.provider, worktreeName },
|
||||
`Creating agent in ${config.cwd} (${config.provider})${
|
||||
@@ -2083,7 +2170,8 @@ export class Session {
|
||||
snapshot.id,
|
||||
trimmedPrompt,
|
||||
uuidv4(),
|
||||
images
|
||||
images,
|
||||
outputSchema ? { outputSchema } : undefined
|
||||
);
|
||||
} catch (promptError) {
|
||||
this.sessionLogger.error(
|
||||
@@ -2514,6 +2602,38 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleListAvailableProvidersRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>
|
||||
): Promise<void> {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
try {
|
||||
const providers = await this.agentManager.listProviderAvailability();
|
||||
this.emit({
|
||||
type: "list_available_providers_response",
|
||||
payload: {
|
||||
providers,
|
||||
error: null,
|
||||
fetchedAt,
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error },
|
||||
"Failed to list provider availability"
|
||||
);
|
||||
this.emit({
|
||||
type: "list_available_providers_response",
|
||||
payload: {
|
||||
providers: [],
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
fetchedAt,
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSpeechModelsListRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "speech_models_list_request" }>
|
||||
): Promise<void> {
|
||||
@@ -3302,66 +3422,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle git diff request for an agent
|
||||
*/
|
||||
private async handleGitDiffRequest(agentId: string, requestId: string): Promise<void> {
|
||||
this.sessionLogger.debug(
|
||||
{ agentId },
|
||||
`Handling git diff request for agent ${agentId}`
|
||||
);
|
||||
|
||||
try {
|
||||
const agents = this.agentManager.listAgents();
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
|
||||
if (!agent) {
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: "",
|
||||
error: `Agent not found: ${agentId}`,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const diffResult = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome });
|
||||
const combinedDiff = diffResult.diff;
|
||||
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: combinedDiff,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{ agentId, diffBytes: combinedDiff.length },
|
||||
`Git diff for agent ${agentId} completed (${combinedDiff.length} bytes)`
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
`Failed to get git diff for agent ${agentId}`
|
||||
);
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: "",
|
||||
error: error.message,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheckoutStatusRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
|
||||
): Promise<void> {
|
||||
@@ -4099,12 +4159,13 @@ export class Session {
|
||||
const { cwd, requestId } = msg;
|
||||
|
||||
try {
|
||||
const status = await getPullRequestStatus(cwd);
|
||||
const prStatus = await getPullRequestStatus(cwd);
|
||||
this.emit({
|
||||
type: "checkout_pr_status_response",
|
||||
payload: {
|
||||
cwd,
|
||||
status,
|
||||
status: prStatus.status,
|
||||
githubFeaturesEnabled: prStatus.githubFeaturesEnabled,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
@@ -4115,6 +4176,7 @@ export class Session {
|
||||
payload: {
|
||||
cwd,
|
||||
status: null,
|
||||
githubFeaturesEnabled: true,
|
||||
error: this.toCheckoutError(error),
|
||||
requestId,
|
||||
},
|
||||
@@ -4282,237 +4344,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle highlighted diff request - returns parsed and syntax-highlighted diff
|
||||
*/
|
||||
private async handleHighlightedDiffRequest(
|
||||
agentId: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
this.sessionLogger.debug(
|
||||
{ agentId },
|
||||
`Handling highlighted diff request for agent ${agentId}`
|
||||
);
|
||||
|
||||
// Maximum lines changed before we skip showing the diff content
|
||||
const MAX_DIFF_LINES = 5000;
|
||||
|
||||
try {
|
||||
const agents = this.agentManager.listAgents();
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
|
||||
if (!agent) {
|
||||
this.emit({
|
||||
type: "highlighted_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
files: [],
|
||||
error: `Agent not found: ${agentId}`,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Get the list of changed files with their stats (numstat gives additions/deletions per file)
|
||||
const { stdout: numstatOutput } = await execAsync(
|
||||
"git diff --numstat HEAD",
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
|
||||
// Get file statuses (A=added, D=deleted, M=modified) to detect deleted files
|
||||
const { stdout: nameStatusOutput } = await execAsync(
|
||||
"git diff --name-status HEAD",
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
const deletedFiles = new Set<string>();
|
||||
const addedFiles = new Set<string>();
|
||||
for (const line of nameStatusOutput.trim().split("\n").filter(Boolean)) {
|
||||
const [status, ...pathParts] = line.split("\t");
|
||||
const path = pathParts.join("\t");
|
||||
if (status === "D") {
|
||||
deletedFiles.add(path);
|
||||
} else if (status === "A") {
|
||||
addedFiles.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse numstat output: "additions\tdeletions\tfilepath" or "-\t-\tfilepath" for binary
|
||||
interface FileStats {
|
||||
path: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
isBinary: boolean;
|
||||
isTracked: boolean;
|
||||
isDeleted: boolean;
|
||||
isNew: boolean;
|
||||
}
|
||||
const fileStats: FileStats[] = [];
|
||||
|
||||
for (const line of numstatOutput.trim().split("\n").filter(Boolean)) {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length >= 3) {
|
||||
const [addStr, delStr, ...pathParts] = parts;
|
||||
const path = pathParts.join("\t"); // Handle paths with tabs
|
||||
const isBinary = addStr === "-" && delStr === "-";
|
||||
fileStats.push({
|
||||
path,
|
||||
additions: isBinary ? 0 : parseInt(addStr, 10),
|
||||
deletions: isBinary ? 0 : parseInt(delStr, 10),
|
||||
isBinary,
|
||||
isTracked: true,
|
||||
isDeleted: deletedFiles.has(path),
|
||||
isNew: addedFiles.has(path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Get untracked files
|
||||
try {
|
||||
const { stdout: untrackedFiles } = await execAsync(
|
||||
"git ls-files --others --exclude-standard",
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
for (const filePath of untrackedFiles.trim().split("\n").filter(Boolean)) {
|
||||
// Use git's numstat with --no-index to detect binary files (cross-platform)
|
||||
// Binary files show as "-\t-\tfilepath", text files show line counts
|
||||
try {
|
||||
const { stdout: numstatLine } = await execAsync(
|
||||
`git diff --numstat --no-index /dev/null "${filePath}" || true`,
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
const parts = numstatLine.trim().split("\t");
|
||||
const isBinary = parts[0] === "-" && parts[1] === "-";
|
||||
const additions = isBinary ? 0 : (parseInt(parts[0], 10) || 0);
|
||||
|
||||
fileStats.push({
|
||||
path: filePath,
|
||||
additions,
|
||||
deletions: 0,
|
||||
isBinary,
|
||||
isTracked: false,
|
||||
isDeleted: false,
|
||||
isNew: true,
|
||||
});
|
||||
} catch {
|
||||
// If we can't determine, assume text and try to get it
|
||||
fileStats.push({
|
||||
path: filePath,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
isBinary: false,
|
||||
isTracked: false,
|
||||
isDeleted: false,
|
||||
isNew: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors getting untracked files
|
||||
}
|
||||
|
||||
// Step 3: Fetch diffs per-file, respecting limits
|
||||
const allFiles: ParsedDiffFile[] = [];
|
||||
|
||||
for (const stats of fileStats) {
|
||||
const totalLines = stats.additions + stats.deletions;
|
||||
|
||||
// Handle binary files
|
||||
if (stats.isBinary) {
|
||||
allFiles.push({
|
||||
path: stats.path,
|
||||
isNew: stats.isNew,
|
||||
isDeleted: stats.isDeleted,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
status: "binary",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle files that are too large
|
||||
if (totalLines > MAX_DIFF_LINES) {
|
||||
allFiles.push({
|
||||
path: stats.path,
|
||||
isNew: stats.isNew,
|
||||
isDeleted: stats.isDeleted,
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
hunks: [],
|
||||
status: "too_large",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch the actual diff for this file
|
||||
try {
|
||||
let fileDiff: string;
|
||||
if (stats.isTracked) {
|
||||
const { stdout } = await execAsync(
|
||||
`git diff HEAD -- "${stats.path}"`,
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
fileDiff = stdout;
|
||||
} else {
|
||||
const { stdout } = await execAsync(
|
||||
`git diff --no-index /dev/null "${stats.path}" || true`,
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
fileDiff = stdout;
|
||||
}
|
||||
|
||||
if (fileDiff) {
|
||||
const parsedFiles = await parseAndHighlightDiff(fileDiff, agent.cwd);
|
||||
for (const file of parsedFiles) {
|
||||
allFiles.push({ ...file, status: "ok" });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If diff fails for this file, add it with empty hunks
|
||||
allFiles.push({
|
||||
path: stats.path,
|
||||
isNew: stats.isNew,
|
||||
isDeleted: stats.isDeleted,
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
hunks: [],
|
||||
status: "ok",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "highlighted_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
files: allFiles,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{ agentId, fileCount: allFiles.length },
|
||||
`Highlighted diff for agent ${agentId} completed (${allFiles.length} files)`
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
`Failed to get highlighted diff for agent ${agentId}`
|
||||
);
|
||||
this.emit({
|
||||
type: "highlighted_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
files: [],
|
||||
error: error.message,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle read-only file explorer requests scoped to an agent's cwd
|
||||
*/
|
||||
@@ -5041,7 +4872,7 @@ export class Session {
|
||||
if (!resolved.ok) {
|
||||
this.emit({
|
||||
type: "wait_for_finish_response",
|
||||
payload: { requestId, status: "error", final: null, error: resolved.error },
|
||||
payload: { requestId, status: "error", final: null, error: resolved.error, lastMessage: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -5058,6 +4889,7 @@ export class Session {
|
||||
status: "error",
|
||||
final: null,
|
||||
error: `Agent not found: ${agentId}`,
|
||||
lastMessage: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -5071,7 +4903,7 @@ export class Session {
|
||||
: "idle";
|
||||
this.emit({
|
||||
type: "wait_for_finish_response",
|
||||
payload: { requestId, status, final, error: null },
|
||||
payload: { requestId, status, final, error: null, lastMessage: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -5101,7 +4933,7 @@ export class Session {
|
||||
|
||||
this.emit({
|
||||
type: "wait_for_finish_response",
|
||||
payload: { requestId, status, final, error: null },
|
||||
payload: { requestId, status, final, error: null, lastMessage: result.lastMessage },
|
||||
});
|
||||
} catch (error) {
|
||||
const isAbort =
|
||||
@@ -5119,6 +4951,7 @@ export class Session {
|
||||
status: "error",
|
||||
final,
|
||||
error: message,
|
||||
lastMessage: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -5130,7 +4963,7 @@ export class Session {
|
||||
}
|
||||
this.emit({
|
||||
type: "wait_for_finish_response",
|
||||
payload: { requestId, status: "timeout", final, error: null },
|
||||
payload: { requestId, status: "timeout", final, error: null, lastMessage: null },
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
@@ -5780,7 +5613,7 @@ export class Session {
|
||||
* Clean up session resources
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
this.sessionLogger.info("Cleaning up");
|
||||
this.sessionLogger.trace("Cleaning up");
|
||||
|
||||
if (this.unsubscribeAgentEvents) {
|
||||
this.unsubscribeAgentEvents();
|
||||
|
||||
@@ -73,9 +73,10 @@ const LocalSpeechResolutionSchema = z.object({
|
||||
|
||||
function persistedLocalFeatureModel(
|
||||
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"],
|
||||
enabled: boolean | undefined,
|
||||
model: string | undefined
|
||||
): string | undefined {
|
||||
if (provider !== "local") {
|
||||
if (provider !== "local" || enabled === false) {
|
||||
return undefined;
|
||||
}
|
||||
return model;
|
||||
@@ -87,9 +88,12 @@ function shouldIncludeLocalProviderConfig(params: {
|
||||
persisted: PersistedConfig;
|
||||
}): boolean {
|
||||
const localRequestedByFeature =
|
||||
params.providers.dictationStt.provider === "local" ||
|
||||
params.providers.voiceStt.provider === "local" ||
|
||||
params.providers.voiceTts.provider === "local";
|
||||
(params.providers.dictationStt.enabled !== false &&
|
||||
params.providers.dictationStt.provider === "local") ||
|
||||
(params.providers.voiceStt.enabled !== false &&
|
||||
params.providers.voiceStt.provider === "local") ||
|
||||
(params.providers.voiceTts.enabled !== false &&
|
||||
params.providers.voiceTts.provider === "local");
|
||||
|
||||
return (
|
||||
localRequestedByFeature ||
|
||||
@@ -119,6 +123,7 @@ export function resolveLocalSpeechConfig(params: {
|
||||
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
|
||||
persistedLocalFeatureModel(
|
||||
params.providers.dictationStt.provider,
|
||||
params.providers.dictationStt.enabled,
|
||||
params.persisted.features?.dictation?.stt?.model
|
||||
) ??
|
||||
DEFAULT_LOCAL_STT_MODEL,
|
||||
@@ -126,6 +131,7 @@ export function resolveLocalSpeechConfig(params: {
|
||||
params.env.PASEO_VOICE_LOCAL_STT_MODEL ??
|
||||
persistedLocalFeatureModel(
|
||||
params.providers.voiceStt.provider,
|
||||
params.providers.voiceStt.enabled,
|
||||
params.persisted.features?.voiceMode?.stt?.model
|
||||
) ??
|
||||
DEFAULT_LOCAL_STT_MODEL,
|
||||
@@ -133,6 +139,7 @@ export function resolveLocalSpeechConfig(params: {
|
||||
params.env.PASEO_VOICE_LOCAL_TTS_MODEL ??
|
||||
persistedLocalFeatureModel(
|
||||
params.providers.voiceTts.provider,
|
||||
params.providers.voiceTts.enabled,
|
||||
params.persisted.features?.voiceMode?.tts?.model
|
||||
) ??
|
||||
DEFAULT_LOCAL_TTS_MODEL,
|
||||
|
||||
@@ -88,13 +88,22 @@ function computeRequiredLocalModelIds(params: {
|
||||
models: ResolvedLocalModels;
|
||||
}): LocalSpeechModelId[] {
|
||||
const ids = new Set<LocalSpeechModelId>();
|
||||
if (params.providers.dictationStt.provider === "local") {
|
||||
if (
|
||||
params.providers.dictationStt.enabled !== false &&
|
||||
params.providers.dictationStt.provider === "local"
|
||||
) {
|
||||
ids.add(params.models.dictationLocalSttModel);
|
||||
}
|
||||
if (params.providers.voiceStt.provider === "local") {
|
||||
if (
|
||||
params.providers.voiceStt.enabled !== false &&
|
||||
params.providers.voiceStt.provider === "local"
|
||||
) {
|
||||
ids.add(params.models.voiceLocalSttModel);
|
||||
}
|
||||
if (params.providers.voiceTts.provider === "local") {
|
||||
if (
|
||||
params.providers.voiceTts.enabled !== false &&
|
||||
params.providers.voiceTts.provider === "local"
|
||||
) {
|
||||
ids.add(params.models.voiceLocalTtsModel);
|
||||
}
|
||||
return Array.from(ids);
|
||||
@@ -258,7 +267,7 @@ export async function initializeLocalSpeechServices(params: {
|
||||
}
|
||||
};
|
||||
|
||||
if (providers.voiceStt.provider === "local") {
|
||||
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") {
|
||||
if (!localConfig) {
|
||||
logger.warn(
|
||||
{ configured: false },
|
||||
@@ -274,7 +283,7 @@ export async function initializeLocalSpeechServices(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (providers.dictationStt.provider === "local") {
|
||||
if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === "local") {
|
||||
if (!localConfig) {
|
||||
logger.warn(
|
||||
{ configured: false },
|
||||
@@ -297,7 +306,7 @@ export async function initializeLocalSpeechServices(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (providers.voiceTts.provider === "local") {
|
||||
if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === "local") {
|
||||
if (!localConfig) {
|
||||
logger.warn(
|
||||
{ configured: false },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -53,4 +53,46 @@ describe("sherpa model downloader", () => {
|
||||
})
|
||||
).rejects.toThrow(/auto-download/i);
|
||||
});
|
||||
|
||||
test("ensureSherpaOnnxModel logs artifact download progress", async () => {
|
||||
const modelsDir = makeTmpDir();
|
||||
const progressLogs: Array<Record<string, unknown>> = [];
|
||||
|
||||
const loggerWithSpy = {
|
||||
child: () => loggerWithSpy,
|
||||
info: (obj?: unknown, msg?: string) => {
|
||||
if (msg === "Downloading model artifact" && obj && typeof obj === "object") {
|
||||
progressLogs.push(obj as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
error: () => undefined,
|
||||
} as unknown as pino.Logger;
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const payload = Buffer.alloc(128 * 1024, 7);
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "content-length": String(payload.length) },
|
||||
});
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
try {
|
||||
await ensureSherpaOnnxModel({
|
||||
modelsDir,
|
||||
modelId: "pocket-tts-onnx-int8",
|
||||
autoDownload: true,
|
||||
logger: loggerWithSpy,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(progressLogs.length).toBeGreaterThan(0);
|
||||
const final = progressLogs.at(-1);
|
||||
expect(final?.modelId).toBe("pocket-tts-onnx-int8");
|
||||
expect(final?.pct).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, rename, rm, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { spawn } from "node:child_process";
|
||||
import type pino from "pino";
|
||||
@@ -39,7 +39,31 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom
|
||||
return true;
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, outputPath: string, logger: pino.Logger): Promise<void> {
|
||||
const UNKNOWN_SIZE_PROGRESS_BYTES_STEP = 5 * 1024 * 1024;
|
||||
const UNKNOWN_SIZE_PROGRESS_MS_STEP = 1000;
|
||||
|
||||
type DownloadToFileOptions = {
|
||||
url: string;
|
||||
outputPath: string;
|
||||
logger: pino.Logger;
|
||||
modelId: SherpaOnnxModelId;
|
||||
artifact: string;
|
||||
};
|
||||
|
||||
function parseContentLength(res: Response): number | null {
|
||||
const raw = res.headers.get("content-length");
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
|
||||
const { url, outputPath, logger, modelId, artifact } = options;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
|
||||
@@ -51,24 +75,68 @@ async function downloadToFile(url: string, outputPath: string, logger: pino.Logg
|
||||
const tmpPath = `${outputPath}.tmp-${Date.now()}`;
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const total = Number(res.headers.get("content-length") ?? "0");
|
||||
let downloaded = 0;
|
||||
let lastLoggedBucket = -1;
|
||||
const nodeStream = Readable.fromWeb(res.body as any);
|
||||
const totalBytes = parseContentLength(res);
|
||||
|
||||
const nodeStream = Readable.fromWeb(res.body as any).on("data", (chunk: Buffer) => {
|
||||
downloaded += chunk.length;
|
||||
if (total > 0) {
|
||||
const pct = Math.floor((downloaded / total) * 100);
|
||||
const bucket = Math.min(100, Math.floor(pct / 10) * 10);
|
||||
if (bucket >= 0 && bucket <= 100 && bucket !== lastLoggedBucket) {
|
||||
lastLoggedBucket = bucket;
|
||||
logger.info({ pct: bucket, downloaded, total }, "Downloading model artifact");
|
||||
let downloadedBytes = 0;
|
||||
let lastLoggedPct = -1;
|
||||
let lastLoggedBytes = 0;
|
||||
let lastLoggedAt = 0;
|
||||
|
||||
const logProgress = (force: boolean): void => {
|
||||
const pct =
|
||||
totalBytes && totalBytes > 0
|
||||
? Math.min(100, Math.floor((downloadedBytes * 100) / totalBytes))
|
||||
: null;
|
||||
|
||||
if (!force) {
|
||||
if (pct !== null) {
|
||||
if (pct <= lastLoggedPct) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const now = Date.now();
|
||||
const advancedBytes = downloadedBytes - lastLoggedBytes;
|
||||
if (advancedBytes < UNKNOWN_SIZE_PROGRESS_BYTES_STEP && now - lastLoggedAt < UNKNOWN_SIZE_PROGRESS_MS_STEP) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
modelId,
|
||||
artifact,
|
||||
url,
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
pct,
|
||||
},
|
||||
"Downloading model artifact"
|
||||
);
|
||||
lastLoggedPct = pct ?? -1;
|
||||
lastLoggedBytes = downloadedBytes;
|
||||
lastLoggedAt = Date.now();
|
||||
};
|
||||
|
||||
logProgress(false);
|
||||
|
||||
const progressStream = new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
downloadedBytes += chunk.length;
|
||||
logProgress(false);
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
|
||||
await pipeline(nodeStream, createWriteStream(tmpPath));
|
||||
await rename(tmpPath, outputPath);
|
||||
try {
|
||||
await pipeline(nodeStream, progressStream, createWriteStream(tmpPath));
|
||||
logProgress(true);
|
||||
await rename(tmpPath, outputPath);
|
||||
} catch (error) {
|
||||
await rm(tmpPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
|
||||
@@ -114,60 +182,103 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
|
||||
);
|
||||
}
|
||||
|
||||
if (spec.archiveUrl) {
|
||||
logger.info({ modelsDir: options.modelsDir, url: spec.archiveUrl }, "Model files missing; downloading");
|
||||
logger.info({ modelsDir: options.modelsDir }, "Starting model download");
|
||||
|
||||
const downloadsDir = path.join(options.modelsDir, ".downloads");
|
||||
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
|
||||
const archivePath = path.join(downloadsDir, archiveFilename);
|
||||
try {
|
||||
if (spec.archiveUrl) {
|
||||
const downloadsDir = path.join(options.modelsDir, ".downloads");
|
||||
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
|
||||
const archivePath = path.join(downloadsDir, archiveFilename);
|
||||
|
||||
if (!(await isNonEmptyFile(archivePath))) {
|
||||
await downloadToFile(spec.archiveUrl, archivePath, logger);
|
||||
} else {
|
||||
logger.info({ archivePath }, "Using cached archive");
|
||||
}
|
||||
|
||||
await extractTarArchive(archivePath, options.modelsDir);
|
||||
|
||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
||||
throw new Error(
|
||||
`Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(archivePath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
logger.info({ modelDir }, "Model ready");
|
||||
return modelDir;
|
||||
}
|
||||
|
||||
if (spec.downloadFiles && spec.downloadFiles.length > 0) {
|
||||
logger.info({ modelsDir: options.modelsDir, fileCount: spec.downloadFiles.length }, "Model files missing; downloading");
|
||||
await mkdir(modelDir, { recursive: true });
|
||||
|
||||
for (const file of spec.downloadFiles) {
|
||||
const dst = path.join(modelDir, file.relPath);
|
||||
if (await isNonEmptyFile(dst)) {
|
||||
continue;
|
||||
if (!(await isNonEmptyFile(archivePath))) {
|
||||
await downloadToFile({
|
||||
url: spec.archiveUrl,
|
||||
outputPath: archivePath,
|
||||
logger,
|
||||
modelId: options.modelId,
|
||||
artifact: archiveFilename,
|
||||
});
|
||||
}
|
||||
await downloadToFile(file.url, dst, logger);
|
||||
}
|
||||
|
||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
||||
throw new Error(
|
||||
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.`
|
||||
logger.info(
|
||||
{
|
||||
modelId: options.modelId,
|
||||
archivePath,
|
||||
modelDir,
|
||||
},
|
||||
"Extracting model archive"
|
||||
);
|
||||
await extractTarArchive(archivePath, options.modelsDir);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
modelId: options.modelId,
|
||||
modelDir,
|
||||
},
|
||||
"Verifying downloaded model files"
|
||||
);
|
||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
||||
throw new Error(
|
||||
`Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
modelId: options.modelId,
|
||||
archivePath,
|
||||
},
|
||||
"Finalizing model artifacts"
|
||||
);
|
||||
try {
|
||||
await rm(archivePath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
logger.info({ modelDir }, "Model download completed");
|
||||
return modelDir;
|
||||
}
|
||||
|
||||
logger.info({ modelDir }, "Model ready");
|
||||
return modelDir;
|
||||
}
|
||||
if (spec.downloadFiles && spec.downloadFiles.length > 0) {
|
||||
await mkdir(modelDir, { recursive: true });
|
||||
|
||||
throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`);
|
||||
for (const file of spec.downloadFiles) {
|
||||
const dst = path.join(modelDir, file.relPath);
|
||||
if (await isNonEmptyFile(dst)) {
|
||||
continue;
|
||||
}
|
||||
await downloadToFile({
|
||||
url: file.url,
|
||||
outputPath: dst,
|
||||
logger,
|
||||
modelId: options.modelId,
|
||||
artifact: file.relPath,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
modelId: options.modelId,
|
||||
modelDir,
|
||||
},
|
||||
"Verifying downloaded model files"
|
||||
);
|
||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
||||
throw new Error(
|
||||
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info({ modelDir }, "Model download completed");
|
||||
return modelDir;
|
||||
}
|
||||
|
||||
throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Model download failed");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSherpaOnnxModels(options: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
|
||||
|
||||
import type { SpeechStreamResult, TextToSpeechProvider } from "../../../speech-provider.js";
|
||||
import { chunkBuffer, float32ToPcm16le } from "../../../audio.js";
|
||||
import { loadSherpaOnnx } from "./sherpa-onnx-loader.js";
|
||||
import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js";
|
||||
|
||||
export type SherpaTtsPreset = "kokoro-en-v0_19" | "kitten-nano-en-v0_1-fp16";
|
||||
|
||||
@@ -37,7 +37,10 @@ export class SherpaOnnxTTS implements TextToSpeechProvider {
|
||||
this.speakerId = config.speakerId ?? 0;
|
||||
this.speed = config.speed ?? 1.0;
|
||||
|
||||
const sherpa = loadSherpaOnnx();
|
||||
const sherpa = loadSherpaOnnxNode();
|
||||
if (typeof sherpa.OfflineTts !== "function") {
|
||||
throw new Error("sherpa-onnx-node OfflineTts is unavailable");
|
||||
}
|
||||
|
||||
const modelFile = config.preset === "kokoro-en-v0_19" ? "model.onnx" : "model.fp16.onnx";
|
||||
const modelPath = `${config.modelDir}/${modelFile}`;
|
||||
@@ -50,30 +53,35 @@ export class SherpaOnnxTTS implements TextToSpeechProvider {
|
||||
assertFileExists(tokensPath, "TTS tokens");
|
||||
assertFileExists(dataDir, "TTS espeak-ng dataDir");
|
||||
|
||||
const modelConfigKey =
|
||||
const modelConfig =
|
||||
config.preset === "kokoro-en-v0_19"
|
||||
? "offlineTtsKokoroModelConfig"
|
||||
: "offlineTtsKittenModelConfig";
|
||||
|
||||
const modelConfig = {
|
||||
[modelConfigKey]: {
|
||||
model: modelPath,
|
||||
voices: voicesPath,
|
||||
tokens: tokensPath,
|
||||
dataDir,
|
||||
lengthScale: config.lengthScale ?? 1.0,
|
||||
},
|
||||
numThreads: config.numThreads ?? 2,
|
||||
debug: 0,
|
||||
provider: "cpu",
|
||||
};
|
||||
? {
|
||||
kokoro: {
|
||||
model: modelPath,
|
||||
voices: voicesPath,
|
||||
tokens: tokensPath,
|
||||
dataDir,
|
||||
lengthScale: config.lengthScale ?? 1.0,
|
||||
},
|
||||
}
|
||||
: {
|
||||
kitten: {
|
||||
model: modelPath,
|
||||
voices: voicesPath,
|
||||
tokens: tokensPath,
|
||||
dataDir,
|
||||
lengthScale: config.lengthScale ?? 1.0,
|
||||
},
|
||||
};
|
||||
|
||||
const offlineTtsConfig = {
|
||||
offlineTtsModelConfig: modelConfig,
|
||||
model: modelConfig,
|
||||
numThreads: config.numThreads ?? 2,
|
||||
provider: "cpu",
|
||||
maxNumSentences: 1,
|
||||
};
|
||||
|
||||
this.tts = sherpa.createOfflineTts(offlineTtsConfig);
|
||||
this.tts = new sherpa.OfflineTts(offlineTtsConfig);
|
||||
this.logger.info({ preset: config.preset, modelDir: config.modelDir }, "Sherpa offline TTS initialized");
|
||||
}
|
||||
|
||||
|
||||
@@ -74,27 +74,32 @@ export function resolveOpenAiSpeechConfig(params: {
|
||||
params.persisted.features?.dictation?.stt?.confidenceThreshold,
|
||||
sttModel:
|
||||
params.env.STT_MODEL ??
|
||||
(params.providers.voiceStt.provider === "openai"
|
||||
(params.providers.voiceStt.enabled !== false &&
|
||||
params.providers.voiceStt.provider === "openai"
|
||||
? params.persisted.features?.voiceMode?.stt?.model
|
||||
: undefined) ??
|
||||
(params.providers.dictationStt.provider === "openai"
|
||||
(params.providers.dictationStt.enabled !== false &&
|
||||
params.providers.dictationStt.provider === "openai"
|
||||
? params.persisted.features?.dictation?.stt?.model
|
||||
: undefined),
|
||||
ttsVoice:
|
||||
params.env.TTS_VOICE ??
|
||||
(params.providers.voiceTts.provider === "openai"
|
||||
(params.providers.voiceTts.enabled !== false &&
|
||||
params.providers.voiceTts.provider === "openai"
|
||||
? params.persisted.features?.voiceMode?.tts?.voice
|
||||
: undefined) ??
|
||||
"alloy",
|
||||
ttsModel:
|
||||
params.env.TTS_MODEL ??
|
||||
(params.providers.voiceTts.provider === "openai"
|
||||
(params.providers.voiceTts.enabled !== false &&
|
||||
params.providers.voiceTts.provider === "openai"
|
||||
? params.persisted.features?.voiceMode?.tts?.model
|
||||
: undefined) ??
|
||||
DEFAULT_OPENAI_TTS_MODEL,
|
||||
realtimeTranscriptionModel:
|
||||
params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ??
|
||||
(params.providers.dictationStt.provider === "openai"
|
||||
(params.providers.dictationStt.enabled !== false &&
|
||||
params.providers.dictationStt.provider === "openai"
|
||||
? params.persisted.features?.dictation?.stt?.model
|
||||
: undefined) ??
|
||||
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||
|
||||
@@ -60,13 +60,22 @@ export function validateOpenAiCredentialRequirements(params: {
|
||||
const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
|
||||
|
||||
const missingOpenAiCredentialsFor: string[] = [];
|
||||
if (providers.voiceStt.provider === "openai" && !openAiCredentials.openaiSttApiKey) {
|
||||
if (
|
||||
providers.voiceStt.enabled !== false &&
|
||||
providers.voiceStt.provider === "openai" &&
|
||||
!openAiCredentials.openaiSttApiKey
|
||||
) {
|
||||
missingOpenAiCredentialsFor.push("voice.stt");
|
||||
}
|
||||
if (providers.voiceTts.provider === "openai" && !openAiCredentials.openaiTtsApiKey) {
|
||||
if (
|
||||
providers.voiceTts.enabled !== false &&
|
||||
providers.voiceTts.provider === "openai" &&
|
||||
!openAiCredentials.openaiTtsApiKey
|
||||
) {
|
||||
missingOpenAiCredentialsFor.push("voice.tts");
|
||||
}
|
||||
if (
|
||||
providers.dictationStt.enabled !== false &&
|
||||
providers.dictationStt.provider === "openai" &&
|
||||
!openAiCredentials.openaiDictationApiKey
|
||||
) {
|
||||
@@ -104,10 +113,18 @@ export function initializeOpenAiSpeechServices(params: {
|
||||
let ttsService = existing.ttsService;
|
||||
let dictationSttService = existing.dictationSttService;
|
||||
|
||||
const needsOpenAiStt = !sttService && providers.voiceStt.provider === "openai";
|
||||
const needsOpenAiTts = !ttsService && providers.voiceTts.provider === "openai";
|
||||
const needsOpenAiStt =
|
||||
!sttService &&
|
||||
providers.voiceStt.enabled !== false &&
|
||||
providers.voiceStt.provider === "openai";
|
||||
const needsOpenAiTts =
|
||||
!ttsService &&
|
||||
providers.voiceTts.enabled !== false &&
|
||||
providers.voiceTts.provider === "openai";
|
||||
const needsOpenAiDictation =
|
||||
!dictationSttService && providers.dictationStt.provider === "openai";
|
||||
!dictationSttService &&
|
||||
providers.dictationStt.enabled !== false &&
|
||||
providers.dictationStt.provider === "openai";
|
||||
|
||||
if (
|
||||
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&
|
||||
|
||||
@@ -21,14 +21,17 @@ describe("resolveSpeechConfig", () => {
|
||||
expect(result.speech.providers.dictationStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceTts).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.local).toEqual({
|
||||
modelsDir: path.join(paseoHome, "models", "local-speech"),
|
||||
@@ -91,14 +94,17 @@ describe("resolveSpeechConfig", () => {
|
||||
expect(result.speech.providers.dictationStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "openai",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceTts).toEqual({
|
||||
provider: "local",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.local?.models.dictationStt).toBe("zipformer-bilingual-zh-en-2023-02-20");
|
||||
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
|
||||
@@ -127,4 +133,35 @@ describe("resolveSpeechConfig", () => {
|
||||
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
|
||||
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0);
|
||||
});
|
||||
|
||||
test("respects disabled dictation and voice mode feature flags", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
dictation: { enabled: false },
|
||||
voiceMode: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
});
|
||||
|
||||
expect(result.speech.providers.dictationStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.speech.providers.voiceTts).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,25 @@ const OptionalSpeechProviderSchema = z
|
||||
.pipe(SpeechProviderIdSchema)
|
||||
.optional();
|
||||
|
||||
const OptionalBooleanFlagSchema = z
|
||||
.union([z.boolean(), z.string().trim().toLowerCase()])
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (["1", "true", "yes", "y", "on"].includes(value)) {
|
||||
return true;
|
||||
}
|
||||
if (["0", "false", "no", "n", "off"].includes(value)) {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const RequestedSpeechProvidersSchema = z.object({
|
||||
dictationStt: OptionalSpeechProviderSchema.default("local"),
|
||||
voiceStt: OptionalSpeechProviderSchema.default("local"),
|
||||
@@ -29,10 +48,12 @@ function resolveRequestedSpeechProviders(params: {
|
||||
}): RequestedSpeechProviders {
|
||||
const resolveFeatureProvider = (
|
||||
configuredValue: string | undefined,
|
||||
parsedValue: z.infer<typeof SpeechProviderIdSchema>
|
||||
parsedValue: z.infer<typeof SpeechProviderIdSchema>,
|
||||
enabled: boolean
|
||||
): RequestedSpeechProvider => ({
|
||||
provider: parsedValue,
|
||||
explicit: configuredValue !== undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const dictationSttProviderFromConfig =
|
||||
@@ -44,6 +65,14 @@ function resolveRequestedSpeechProviders(params: {
|
||||
const voiceTtsProviderFromConfig =
|
||||
params.env.PASEO_VOICE_TTS_PROVIDER ??
|
||||
params.persisted.features?.voiceMode?.tts?.provider;
|
||||
const dictationEnabled =
|
||||
OptionalBooleanFlagSchema.parse(
|
||||
params.env.PASEO_DICTATION_ENABLED ?? params.persisted.features?.dictation?.enabled
|
||||
) ?? true;
|
||||
const voiceModeEnabled =
|
||||
OptionalBooleanFlagSchema.parse(
|
||||
params.env.PASEO_VOICE_MODE_ENABLED ?? params.persisted.features?.voiceMode?.enabled
|
||||
) ?? true;
|
||||
|
||||
const parsed = RequestedSpeechProvidersSchema.parse({
|
||||
dictationStt: dictationSttProviderFromConfig ?? "local",
|
||||
@@ -54,15 +83,18 @@ function resolveRequestedSpeechProviders(params: {
|
||||
return {
|
||||
dictationStt: resolveFeatureProvider(
|
||||
dictationSttProviderFromConfig,
|
||||
parsed.dictationStt
|
||||
parsed.dictationStt,
|
||||
dictationEnabled
|
||||
),
|
||||
voiceStt: resolveFeatureProvider(
|
||||
voiceSttProviderFromConfig,
|
||||
parsed.voiceStt
|
||||
parsed.voiceStt,
|
||||
voiceModeEnabled
|
||||
),
|
||||
voiceTts: resolveFeatureProvider(
|
||||
voiceTtsProviderFromConfig,
|
||||
parsed.voiceTts
|
||||
parsed.voiceTts,
|
||||
voiceModeEnabled
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ function resolveRequestedSpeechProviders(
|
||||
}
|
||||
|
||||
return {
|
||||
dictationStt: { provider: "local", explicit: false },
|
||||
voiceStt: { provider: "local", explicit: false },
|
||||
voiceTts: { provider: "local", explicit: false },
|
||||
dictationStt: { provider: "local", explicit: false, enabled: true },
|
||||
voiceStt: { provider: "local", explicit: false, enabled: true },
|
||||
voiceTts: { provider: "local", explicit: false, enabled: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,9 +59,18 @@ export async function initializeSpeechRuntime(params: {
|
||||
logger.info(
|
||||
{
|
||||
requestedProviders: {
|
||||
dictationStt: providers.dictationStt.provider,
|
||||
voiceStt: providers.voiceStt.provider,
|
||||
voiceTts: providers.voiceTts.provider,
|
||||
dictationStt: {
|
||||
provider: providers.dictationStt.provider,
|
||||
enabled: providers.dictationStt.enabled !== false,
|
||||
},
|
||||
voiceStt: {
|
||||
provider: providers.voiceStt.provider,
|
||||
enabled: providers.voiceStt.enabled !== false,
|
||||
},
|
||||
voiceTts: {
|
||||
provider: providers.voiceTts.provider,
|
||||
enabled: providers.voiceTts.enabled !== false,
|
||||
},
|
||||
},
|
||||
availability: {
|
||||
openai: getOpenAiSpeechAvailability(openaiConfig),
|
||||
@@ -99,9 +108,11 @@ export async function initializeSpeechRuntime(params: {
|
||||
: "openai",
|
||||
};
|
||||
const unavailableFeatures = [
|
||||
!openAiSpeech.dictationSttService ? "dictation.stt" : null,
|
||||
!openAiSpeech.sttService ? "voice.stt" : null,
|
||||
!openAiSpeech.ttsService ? "voice.tts" : null,
|
||||
providers.dictationStt.enabled !== false && !openAiSpeech.dictationSttService
|
||||
? "dictation.stt"
|
||||
: null,
|
||||
providers.voiceStt.enabled !== false && !openAiSpeech.sttService ? "voice.stt" : null,
|
||||
providers.voiceTts.enabled !== false && !openAiSpeech.ttsService ? "voice.tts" : null,
|
||||
].filter((feature): feature is string => feature !== null);
|
||||
const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => {
|
||||
if (feature === "dictation.stt") {
|
||||
@@ -117,9 +128,18 @@ export async function initializeSpeechRuntime(params: {
|
||||
logger.error(
|
||||
{
|
||||
requestedProviders: {
|
||||
dictationStt: providers.dictationStt.provider,
|
||||
voiceStt: providers.voiceStt.provider,
|
||||
voiceTts: providers.voiceTts.provider,
|
||||
dictationStt: {
|
||||
provider: providers.dictationStt.provider,
|
||||
enabled: providers.dictationStt.enabled !== false,
|
||||
},
|
||||
voiceStt: {
|
||||
provider: providers.voiceStt.provider,
|
||||
enabled: providers.voiceStt.enabled !== false,
|
||||
},
|
||||
voiceTts: {
|
||||
provider: providers.voiceTts.provider,
|
||||
enabled: providers.voiceTts.enabled !== false,
|
||||
},
|
||||
},
|
||||
explicitProviders: {
|
||||
dictationStt: providers.dictationStt.explicit,
|
||||
@@ -140,9 +160,18 @@ export async function initializeSpeechRuntime(params: {
|
||||
logger.warn(
|
||||
{
|
||||
requestedProviders: {
|
||||
dictationStt: providers.dictationStt.provider,
|
||||
voiceStt: providers.voiceStt.provider,
|
||||
voiceTts: providers.voiceTts.provider,
|
||||
dictationStt: {
|
||||
provider: providers.dictationStt.provider,
|
||||
enabled: providers.dictationStt.enabled !== false,
|
||||
},
|
||||
voiceStt: {
|
||||
provider: providers.voiceStt.provider,
|
||||
enabled: providers.voiceStt.enabled !== false,
|
||||
},
|
||||
voiceTts: {
|
||||
provider: providers.voiceTts.provider,
|
||||
enabled: providers.voiceTts.enabled !== false,
|
||||
},
|
||||
},
|
||||
explicitProviders: {
|
||||
dictationStt: providers.dictationStt.explicit,
|
||||
|
||||
@@ -6,6 +6,7 @@ export type SpeechProviderId = z.infer<typeof SpeechProviderIdSchema>;
|
||||
export const RequestedSpeechProviderSchema = z.object({
|
||||
provider: SpeechProviderIdSchema,
|
||||
explicit: z.boolean(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSchema>;
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
})
|
||||
);
|
||||
|
||||
connectionLogger.info(
|
||||
connectionLogger.trace(
|
||||
{ clientId, totalSessions: this.sessions.size },
|
||||
"Client connected"
|
||||
);
|
||||
@@ -319,7 +319,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
const session = this.sessions.get(ws);
|
||||
if (!session) return;
|
||||
|
||||
connectionLogger.info(
|
||||
connectionLogger.trace(
|
||||
{ clientId, totalSessions: this.sessions.size - 1 },
|
||||
"Client disconnected"
|
||||
);
|
||||
@@ -386,28 +386,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
const message = parsedMessage.data;
|
||||
|
||||
const messageSummary = {
|
||||
type: message.type,
|
||||
...(message.type === "session" && message.message
|
||||
? { sessionMessageType: message.message.type }
|
||||
: {}),
|
||||
};
|
||||
const isSessionNoise =
|
||||
message.type === "session" &&
|
||||
(message.message.type === "client_heartbeat" ||
|
||||
message.message.type === "voice_audio_chunk" ||
|
||||
message.message.type === "dictation_stream_chunk");
|
||||
if (!isSessionNoise) {
|
||||
this.logger.debug(messageSummary, "Received message");
|
||||
}
|
||||
|
||||
if (message.type === "ping") {
|
||||
this.sendToClient(ws, { type: "pong" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "recording_state") {
|
||||
this.logger.debug({ isRecording: message.isRecording }, "Recording state");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -418,17 +402,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
if (message.type === "session") {
|
||||
if (message.message.type === "create_agent_request") {
|
||||
this.logger.debug(
|
||||
{
|
||||
cwd: message.message.config.cwd,
|
||||
initialMode: message.message.config.modeId,
|
||||
worktreeName: message.message.worktreeName,
|
||||
requestId: message.message.requestId,
|
||||
},
|
||||
"create_agent_request details"
|
||||
);
|
||||
}
|
||||
await session.handleMessage(message.message);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -502,23 +475,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
} {
|
||||
const activity = session.getClientActivity();
|
||||
if (!activity) {
|
||||
this.logger.debug("getClientActivityState: no activity for session");
|
||||
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
|
||||
}
|
||||
const now = Date.now();
|
||||
const ageMs = now - activity.lastActivityAt.getTime();
|
||||
const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS;
|
||||
this.logger.debug(
|
||||
{
|
||||
deviceType: activity.deviceType,
|
||||
focusedAgentId: activity.focusedAgentId,
|
||||
lastActivityAt: activity.lastActivityAt.toISOString(),
|
||||
ageMs,
|
||||
isStale,
|
||||
appVisible: activity.appVisible,
|
||||
},
|
||||
"getClientActivityState"
|
||||
);
|
||||
return {
|
||||
deviceType: activity.deviceType,
|
||||
focusedAgentId: activity.focusedAgentId,
|
||||
@@ -603,16 +564,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
const allStates = clientEntries.map((e) => e.state);
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
agentId: params.agentId,
|
||||
reason: params.reason,
|
||||
clientCount: clientEntries.length,
|
||||
allStates,
|
||||
},
|
||||
"broadcastAgentAttention"
|
||||
);
|
||||
|
||||
const hasActiveWebClient = allStates.some(
|
||||
(state) => state.deviceType === "web" && !state.isStale
|
||||
);
|
||||
@@ -627,11 +578,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
!hasActiveWebClient &&
|
||||
!hasActiveMobileForegroundClient;
|
||||
|
||||
this.logger.debug(
|
||||
{ hasActiveWebClient, hasActiveMobileForegroundClient, shouldSendPush },
|
||||
"Push gating check"
|
||||
);
|
||||
|
||||
if (shouldSendPush) {
|
||||
const tokens = this.pushTokenStore.getAllTokens();
|
||||
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
||||
|
||||
@@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AgentStreamMessageSchema,
|
||||
AgentStreamSnapshotMessageSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
WSOutboundMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
@@ -64,4 +66,44 @@ describe("shared messages stream parsing", () => {
|
||||
});
|
||||
expect(wrapped.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects removed legacy git diff request messages", () => {
|
||||
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "git_diff_request",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-1",
|
||||
});
|
||||
expect(gitDiffParsed.success).toBe(false);
|
||||
|
||||
const highlightedParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "highlighted_diff_request",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-2",
|
||||
});
|
||||
expect(highlightedParsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects removed legacy git diff response messages", () => {
|
||||
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
diff: "",
|
||||
error: null,
|
||||
requestId: "req-1",
|
||||
},
|
||||
});
|
||||
expect(gitDiffParsed.success).toBe(false);
|
||||
|
||||
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "highlighted_diff_response",
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: "req-2",
|
||||
},
|
||||
});
|
||||
expect(highlightedParsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -422,6 +422,14 @@ export const ArchiveAgentRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const UpdateAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("update_agent_request"),
|
||||
agentId: z.string(),
|
||||
name: z.string().optional(),
|
||||
labels: z.record(z.string()).optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SetVoiceModeMessageSchema = z.object({
|
||||
type: z.literal("set_voice_mode"),
|
||||
enabled: z.boolean(),
|
||||
@@ -540,6 +548,7 @@ export const CreateAgentRequestMessageSchema = z.object({
|
||||
config: AgentSessionConfigSchema,
|
||||
worktreeName: z.string().optional(),
|
||||
initialPrompt: z.string().optional(),
|
||||
outputSchema: z.record(z.unknown()).optional(),
|
||||
images: z.array(z.object({
|
||||
data: z.string(), // base64 encoded image
|
||||
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
|
||||
@@ -556,6 +565,11 @@ export const ListProviderModelsRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ListAvailableProvidersRequestMessageSchema = z.object({
|
||||
type: z.literal("list_available_providers_request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SpeechModelsListRequestSchema = z.object({
|
||||
type: z.literal("speech_models_list_request"),
|
||||
requestId: z.string(),
|
||||
@@ -660,6 +674,16 @@ export const SetAgentThinkingResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const UpdateAgentResponseMessageSchema = z.object({
|
||||
type: z.literal("update_agent_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SetVoiceModeResponseMessageSchema = z.object({
|
||||
type: z.literal("set_voice_mode_response"),
|
||||
payload: z.object({
|
||||
@@ -678,12 +702,6 @@ export const AgentPermissionResponseMessageSchema = z.object({
|
||||
response: AgentPermissionResponseSchema,
|
||||
});
|
||||
|
||||
export const GitDiffRequestSchema = z.object({
|
||||
type: z.literal("git_diff_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const CheckoutErrorCodeSchema = z.enum([
|
||||
"NOT_GIT_REPO",
|
||||
"NOT_ALLOWED",
|
||||
@@ -819,12 +837,6 @@ const ParsedDiffFileSchema = z.object({
|
||||
status: z.enum(["ok", "too_large", "binary"]).optional(),
|
||||
});
|
||||
|
||||
export const HighlightedDiffRequestSchema = z.object({
|
||||
type: z.literal("highlighted_diff_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const FileExplorerEntrySchema = z.object({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
@@ -971,6 +983,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
UnsubscribeAgentUpdatesMessageSchema,
|
||||
DeleteAgentRequestMessageSchema,
|
||||
ArchiveAgentRequestMessageSchema,
|
||||
UpdateAgentRequestMessageSchema,
|
||||
SetVoiceModeMessageSchema,
|
||||
SendAgentMessageRequestSchema,
|
||||
WaitForFinishRequestSchema,
|
||||
@@ -980,6 +993,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
DictationStreamCancelMessageSchema,
|
||||
CreateAgentRequestMessageSchema,
|
||||
ListProviderModelsRequestMessageSchema,
|
||||
ListAvailableProvidersRequestMessageSchema,
|
||||
SpeechModelsListRequestSchema,
|
||||
SpeechModelsDownloadRequestSchema,
|
||||
ResumeAgentRequestMessageSchema,
|
||||
@@ -991,7 +1005,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentModelRequestMessageSchema,
|
||||
SetAgentThinkingRequestMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
GitDiffRequestSchema,
|
||||
CheckoutStatusRequestSchema,
|
||||
SubscribeCheckoutDiffRequestSchema,
|
||||
UnsubscribeCheckoutDiffRequestSchema,
|
||||
@@ -1004,7 +1017,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ValidateBranchRequestSchema,
|
||||
PaseoWorktreeListRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
HighlightedDiffRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
ProjectIconRequestSchema,
|
||||
FileDownloadTokenRequestSchema,
|
||||
@@ -1355,6 +1367,7 @@ export const WaitForFinishResponseMessageSchema = z.object({
|
||||
status: z.enum(["idle", "error", "permission", "timeout"]),
|
||||
final: AgentSnapshotPayloadSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
lastMessage: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1392,16 +1405,6 @@ export const AgentArchivedMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const GitDiffResponseSchema = z.object({
|
||||
type: z.literal("git_diff_response"),
|
||||
payload: z.object({
|
||||
agentId: z.string(),
|
||||
diff: z.string(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const AheadBehindSchema = z.object({
|
||||
ahead: z.number(),
|
||||
behind: z.number(),
|
||||
@@ -1545,6 +1548,7 @@ export const CheckoutPrStatusResponseSchema = z.object({
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
status: CheckoutPrStatusSchema.nullable(),
|
||||
githubFeaturesEnabled: z.boolean(),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
@@ -1586,16 +1590,6 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const HighlightedDiffResponseSchema = z.object({
|
||||
type: z.literal("highlighted_diff_response"),
|
||||
payload: z.object({
|
||||
agentId: z.string(),
|
||||
files: z.array(ParsedDiffFileSchema),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const FileExplorerResponseSchema = z.object({
|
||||
type: z.literal("file_explorer_response"),
|
||||
payload: z.object({
|
||||
@@ -1649,6 +1643,22 @@ export const ListProviderModelsResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const ProviderAvailabilitySchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
available: z.boolean(),
|
||||
error: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const ListAvailableProvidersResponseSchema = z.object({
|
||||
type: z.literal("list_available_providers_response"),
|
||||
payload: z.object({
|
||||
providers: z.array(ProviderAvailabilitySchema),
|
||||
error: z.string().nullable().optional(),
|
||||
fetchedAt: z.string(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SpeechModelsListResponseSchema = z.object({
|
||||
type: z.literal("speech_models_list_response"),
|
||||
payload: z.object({
|
||||
@@ -1809,12 +1819,12 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentModeResponseMessageSchema,
|
||||
SetAgentModelResponseMessageSchema,
|
||||
SetAgentThinkingResponseMessageSchema,
|
||||
UpdateAgentResponseMessageSchema,
|
||||
WaitForFinishResponseMessageSchema,
|
||||
AgentPermissionRequestMessageSchema,
|
||||
AgentPermissionResolvedMessageSchema,
|
||||
AgentDeletedMessageSchema,
|
||||
AgentArchivedMessageSchema,
|
||||
GitDiffResponseSchema,
|
||||
CheckoutStatusResponseSchema,
|
||||
SubscribeCheckoutDiffResponseSchema,
|
||||
CheckoutDiffUpdateSchema,
|
||||
@@ -1827,11 +1837,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ValidateBranchResponseSchema,
|
||||
PaseoWorktreeListResponseSchema,
|
||||
PaseoWorktreeArchiveResponseSchema,
|
||||
HighlightedDiffResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
ProjectIconResponseSchema,
|
||||
FileDownloadTokenResponseSchema,
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
ListAvailableProvidersResponseSchema,
|
||||
SpeechModelsListResponseSchema,
|
||||
SpeechModelsDownloadResponseSchema,
|
||||
ListCommandsResponseSchema,
|
||||
@@ -1878,6 +1888,7 @@ export type SendAgentMessageResponseMessage = z.infer<
|
||||
export type SetVoiceModeResponseMessage = z.infer<
|
||||
typeof SetVoiceModeResponseMessageSchema
|
||||
>;
|
||||
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
|
||||
export type WaitForFinishResponseMessage = z.infer<
|
||||
typeof WaitForFinishResponseMessageSchema
|
||||
>;
|
||||
@@ -1887,6 +1898,9 @@ export type AgentDeletedMessage = z.infer<typeof AgentDeletedMessageSchema>;
|
||||
export type ListProviderModelsResponseMessage = z.infer<
|
||||
typeof ListProviderModelsResponseMessageSchema
|
||||
>;
|
||||
export type ListAvailableProvidersResponse = z.infer<
|
||||
typeof ListAvailableProvidersResponseSchema
|
||||
>;
|
||||
export type SpeechModelsListResponse = z.infer<typeof SpeechModelsListResponseSchema>;
|
||||
export type SpeechModelsDownloadResponse = z.infer<typeof SpeechModelsDownloadResponseSchema>;
|
||||
export type InitializeAgentResponseMessage = z.infer<typeof InitializeAgentResponseMessageSchema>;
|
||||
@@ -1911,19 +1925,21 @@ export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessage
|
||||
export type ListProviderModelsRequestMessage = z.infer<
|
||||
typeof ListProviderModelsRequestMessageSchema
|
||||
>;
|
||||
export type ListAvailableProvidersRequestMessage = z.infer<
|
||||
typeof ListAvailableProvidersRequestMessageSchema
|
||||
>;
|
||||
export type SpeechModelsListRequestMessage = z.infer<typeof SpeechModelsListRequestSchema>;
|
||||
export type SpeechModelsDownloadRequestMessage = z.infer<
|
||||
typeof SpeechModelsDownloadRequestSchema
|
||||
>;
|
||||
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
|
||||
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
|
||||
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
|
||||
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
|
||||
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
|
||||
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
||||
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
|
||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
||||
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
||||
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||
export type SubscribeCheckoutDiffRequest = z.infer<
|
||||
@@ -1954,8 +1970,6 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
|
||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
||||
export type HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema>;
|
||||
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
|
||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
||||
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
||||
|
||||
82
packages/server/src/utils/checkout-git-batching.test.ts
Normal file
82
packages/server/src/utils/checkout-git-batching.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const spawnCounters = vi.hoisted(() => ({
|
||||
trackedTextDiffCalls: 0,
|
||||
}));
|
||||
|
||||
vi.mock("child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("child_process")>("child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawn: (...args: Parameters<typeof actual.spawn>) => {
|
||||
const [command, commandArgs] = args;
|
||||
if (command === "git" && Array.isArray(commandArgs)) {
|
||||
const normalizedArgs = commandArgs.map((arg) => String(arg));
|
||||
const isTrackedTextDiff =
|
||||
normalizedArgs[0] === "diff" &&
|
||||
normalizedArgs.includes("HEAD") &&
|
||||
!normalizedArgs.includes("--numstat") &&
|
||||
!normalizedArgs.includes("--no-index");
|
||||
if (isTrackedTextDiff) {
|
||||
spawnCounters.trackedTextDiffCalls += 1;
|
||||
}
|
||||
}
|
||||
return actual.spawn(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { getCheckoutDiff } from "./checkout-git.js";
|
||||
|
||||
function initRepoWithTrackedChanges(fileCount: number): { tempDir: string; repoDir: string } {
|
||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-")));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
|
||||
execSync(`mkdir -p ${repoDir}`);
|
||||
execSync("git init -b main", { cwd: repoDir });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir });
|
||||
|
||||
for (let i = 0; i < fileCount; i += 1) {
|
||||
writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`);
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
|
||||
|
||||
for (let i = 0; i < fileCount; i += 1) {
|
||||
writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`);
|
||||
}
|
||||
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
describe("checkout git diff batching", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const setup = initRepoWithTrackedChanges(20);
|
||||
tempDir = setup.tempDir;
|
||||
repoDir = setup.repoDir;
|
||||
spawnCounters.trackedTextDiffCalls = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses a single tracked git diff command for tracked file diffs", async () => {
|
||||
const result = await getCheckoutDiff(repoDir, {
|
||||
mode: "uncommitted",
|
||||
includeStructured: false,
|
||||
});
|
||||
|
||||
expect(result.diff).toContain("file-0.txt");
|
||||
expect(result.diff).toContain("file-19.txt");
|
||||
expect(spawnCounters.trackedTextDiffCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync, realpathSync, mkdirSync, symlinkSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import {
|
||||
commitAll,
|
||||
getCheckoutDiff,
|
||||
getPullRequestStatus,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
mergeToBase,
|
||||
@@ -141,6 +142,27 @@ describe("checkout git utilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("short-circuits tracked binary files", async () => {
|
||||
const trackedBinaryPath = join(repoDir, "tracked-blob.bin");
|
||||
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00]));
|
||||
execSync("git add tracked-blob.bin", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add tracked binary'", {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x11, 0x81, 0x00]));
|
||||
|
||||
const diff = await getCheckoutDiff(repoDir, {
|
||||
mode: "uncommitted",
|
||||
includeStructured: true,
|
||||
});
|
||||
|
||||
const entry = diff.structured?.find((file) => file.path === "tracked-blob.bin");
|
||||
expect(entry).toBeTruthy();
|
||||
expect(entry?.status).toBe("binary");
|
||||
expect(diff.diff).toContain("# tracked-blob.bin: binary diff omitted");
|
||||
});
|
||||
|
||||
it("short-circuits untracked binary files", async () => {
|
||||
const binaryPath = join(repoDir, "blob.bin");
|
||||
writeFileSync(binaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f, 0x00]));
|
||||
@@ -380,6 +402,29 @@ describe("checkout git utilities", () => {
|
||||
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
|
||||
});
|
||||
|
||||
it("disables GitHub features when gh is unavailable", async () => {
|
||||
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||
|
||||
const fakeBinDir = join(tempDir, "fake-bin");
|
||||
mkdirSync(fakeBinDir);
|
||||
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
|
||||
symlinkSync(gitPath, join(fakeBinDir, "git"));
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = fakeBinDir;
|
||||
try {
|
||||
const status = await getPullRequestStatus(repoDir);
|
||||
expect(status.githubFeaturesEnabled).toBe(false);
|
||||
expect(status.status).toBeNull();
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns typed MergeConflictError on merge conflicts", async () => {
|
||||
const conflictFile = join(repoDir, "conflict.txt");
|
||||
writeFileSync(conflictFile, "base\n");
|
||||
|
||||
@@ -184,35 +184,81 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
|
||||
|
||||
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
|
||||
|
||||
async function tryGetNumstat(
|
||||
cwd: string,
|
||||
args: string[]
|
||||
): Promise<FileStat> {
|
||||
try {
|
||||
const { text } = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args,
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: 64 * 1024,
|
||||
acceptExitCodes: [0],
|
||||
});
|
||||
const line = text.trim().split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
||||
if (!line) return null;
|
||||
const [aRaw, dRaw] = line.split(/\s+/);
|
||||
if (!aRaw || !dRaw) return null;
|
||||
if (aRaw === "-" || dRaw === "-") {
|
||||
return { additions: 0, deletions: 0, isBinary: true };
|
||||
}
|
||||
const additions = Number.parseInt(aRaw, 10);
|
||||
const deletions = Number.parseInt(dRaw, 10);
|
||||
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
|
||||
return null;
|
||||
}
|
||||
return { additions, deletions, isBinary: false };
|
||||
} catch {
|
||||
return null;
|
||||
function normalizeNumstatPath(pathField: string): string {
|
||||
const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
||||
if (braceRenameMatch) {
|
||||
const [, prefix, , renamed, suffix] = braceRenameMatch;
|
||||
return `${prefix}${renamed}${suffix}`;
|
||||
}
|
||||
|
||||
const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/);
|
||||
if (inlineRenameMatch) {
|
||||
return inlineRenameMatch[2] ?? pathField;
|
||||
}
|
||||
|
||||
return pathField;
|
||||
}
|
||||
|
||||
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const TRACKED_MAX_CHANGED_LINES = 40_000;
|
||||
|
||||
async function getTrackedNumstatByPath(
|
||||
cwd: string,
|
||||
ref: string
|
||||
): Promise<Map<string, FileStat>> {
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", "--numstat", ref],
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
||||
acceptExitCodes: [0],
|
||||
});
|
||||
|
||||
const stats = new Map<string, FileStat>();
|
||||
const lines = result.text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const additionsField = parts[0] ?? "";
|
||||
const deletionsField = parts[1] ?? "";
|
||||
const rawPath = parts.slice(2).join("\t");
|
||||
const path = normalizeNumstatPath(rawPath);
|
||||
|
||||
if (!path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (additionsField === "-" || deletionsField === "-") {
|
||||
stats.set(path, { additions: 0, deletions: 0, isBinary: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
const additions = Number.parseInt(additionsField, 10);
|
||||
const deletions = Number.parseInt(deletionsField, 10);
|
||||
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
|
||||
stats.set(path, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.set(path, { additions, deletions, isBinary: false });
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
function isTrackedDiffTooLarge(stat: FileStat): boolean {
|
||||
if (!stat || stat.isBinary) {
|
||||
return false;
|
||||
}
|
||||
return stat.additions + stat.deletions > TRACKED_MAX_CHANGED_LINES;
|
||||
}
|
||||
|
||||
export class NotGitRepoError extends Error {
|
||||
@@ -784,49 +830,32 @@ function buildPlaceholderParsedDiffFile(
|
||||
};
|
||||
}
|
||||
|
||||
async function getPerFileDiffText(
|
||||
async function getUntrackedDiffText(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
change: CheckoutFileChange
|
||||
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
||||
if (change.isUntracked) {
|
||||
try {
|
||||
const inspected = await inspectUntrackedFile(cwd, change.path);
|
||||
if (inspected.stat?.isBinary || inspected.truncated) {
|
||||
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to git diff path if metadata probing fails.
|
||||
try {
|
||||
const inspected = await inspectUntrackedFile(cwd, change.path);
|
||||
if (inspected.stat?.isBinary || inspected.truncated) {
|
||||
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
|
||||
}
|
||||
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
return {
|
||||
text: result.text,
|
||||
truncated: result.truncated,
|
||||
stat: { additions: 0, deletions: 0, isBinary: false },
|
||||
};
|
||||
}
|
||||
|
||||
const stat = await tryGetNumstat(cwd, ["diff", "--numstat", ref, "--", change.path]);
|
||||
if (stat?.isBinary) {
|
||||
return { text: "", truncated: false, stat };
|
||||
} catch {
|
||||
// Fall through to git diff path if metadata probing fails.
|
||||
}
|
||||
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", ref, "--", change.path],
|
||||
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
return { text: result.text, truncated: result.truncated, stat };
|
||||
return {
|
||||
text: result.text,
|
||||
truncated: result.truncated,
|
||||
stat: { additions: 0, deletions: 0, isBinary: false },
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCheckoutStatus(
|
||||
@@ -966,8 +995,111 @@ export async function getCheckoutDiff(
|
||||
}
|
||||
};
|
||||
|
||||
for (const change of changes) {
|
||||
const { text, truncated, stat } = await getPerFileDiffText(cwd, refForDiff, change);
|
||||
const trackedChanges = changes.filter((change) => !change.isUntracked);
|
||||
const untrackedChanges = changes.filter((change) => change.isUntracked === true);
|
||||
|
||||
const trackedNumstatByPath =
|
||||
trackedChanges.length > 0 ? await getTrackedNumstatByPath(cwd, refForDiff) : new Map<string, FileStat>();
|
||||
const trackedDiffPaths: string[] = [];
|
||||
const trackedPlaceholderByPath = new Map<
|
||||
string,
|
||||
{ status: "binary" | "too_large"; stat: FileStat }
|
||||
>();
|
||||
|
||||
for (const change of trackedChanges) {
|
||||
const stat = trackedNumstatByPath.get(change.path) ?? null;
|
||||
if (stat?.isBinary) {
|
||||
trackedPlaceholderByPath.set(change.path, { status: "binary", stat });
|
||||
continue;
|
||||
}
|
||||
if (isTrackedDiffTooLarge(stat)) {
|
||||
trackedPlaceholderByPath.set(change.path, { status: "too_large", stat });
|
||||
continue;
|
||||
}
|
||||
trackedDiffPaths.push(change.path);
|
||||
}
|
||||
|
||||
let trackedDiffText = "";
|
||||
let trackedDiffTruncated = false;
|
||||
if (trackedDiffPaths.length > 0) {
|
||||
const trackedDiffResult = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", refForDiff, "--", ...trackedDiffPaths],
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: TOTAL_DIFF_MAX_BYTES,
|
||||
});
|
||||
trackedDiffText = trackedDiffResult.text;
|
||||
trackedDiffTruncated = trackedDiffResult.truncated;
|
||||
appendDiff(trackedDiffText);
|
||||
if (trackedDiffTruncated) {
|
||||
appendDiff("# tracked diff truncated\n");
|
||||
}
|
||||
}
|
||||
|
||||
const appendTrackedPlaceholderComment = (change: CheckoutFileChange, status: "binary" | "too_large") => {
|
||||
if (status === "binary") {
|
||||
appendDiff(`# ${change.path}: binary diff omitted\n`);
|
||||
return;
|
||||
}
|
||||
appendDiff(`# ${change.path}: diff too large omitted\n`);
|
||||
};
|
||||
|
||||
if (compare.includeStructured) {
|
||||
const parsedTrackedFiles =
|
||||
trackedDiffText.length > 0 ? await parseAndHighlightDiff(trackedDiffText, cwd) : [];
|
||||
const parsedTrackedByPath = new Map(parsedTrackedFiles.map((file) => [file.path, file]));
|
||||
|
||||
for (const change of trackedChanges) {
|
||||
const placeholder = trackedPlaceholderByPath.get(change.path);
|
||||
if (placeholder) {
|
||||
structured.push(
|
||||
buildPlaceholderParsedDiffFile(change, {
|
||||
status: placeholder.status,
|
||||
stat: placeholder.stat,
|
||||
})
|
||||
);
|
||||
appendTrackedPlaceholderComment(change, placeholder.status);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = trackedNumstatByPath.get(change.path) ?? null;
|
||||
const parsedFile = parsedTrackedByPath.get(change.path);
|
||||
if (parsedFile) {
|
||||
structured.push({
|
||||
...parsedFile,
|
||||
path: change.path,
|
||||
isNew: change.isNew,
|
||||
isDeleted: change.isDeleted,
|
||||
status: "ok",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
structured.push({
|
||||
path: change.path,
|
||||
isNew: change.isNew,
|
||||
isDeleted: change.isDeleted,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
hunks: [],
|
||||
status: trackedDiffTruncated ? "too_large" : "ok",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const change of trackedChanges) {
|
||||
const placeholder = trackedPlaceholderByPath.get(change.path);
|
||||
if (placeholder) {
|
||||
appendTrackedPlaceholderComment(change, placeholder.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const change of untrackedChanges) {
|
||||
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
||||
break;
|
||||
}
|
||||
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change);
|
||||
|
||||
if (!compare.includeStructured) {
|
||||
if (stat?.isBinary) {
|
||||
@@ -977,9 +1109,6 @@ export async function getCheckoutDiff(
|
||||
} else {
|
||||
appendDiff(text);
|
||||
}
|
||||
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1268,6 +1397,11 @@ export interface PullRequestStatus {
|
||||
headRefName: string;
|
||||
}
|
||||
|
||||
export interface PullRequestStatusResult {
|
||||
status: PullRequestStatus | null;
|
||||
githubFeaturesEnabled: boolean;
|
||||
}
|
||||
|
||||
async function ensureGhAvailable(cwd: string): Promise<void> {
|
||||
try {
|
||||
await execAsync("gh --version", { cwd });
|
||||
@@ -1276,6 +1410,27 @@ async function ensureGhAvailable(cwd: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function getCommandErrorText(error: unknown): string {
|
||||
if (!(error instanceof Error)) {
|
||||
return String(error);
|
||||
}
|
||||
const stderr = typeof (error as any)?.stderr === "string" ? (error as any).stderr : "";
|
||||
const stdout = typeof (error as any)?.stdout === "string" ? (error as any).stdout : "";
|
||||
return `${error.message}\n${stderr}\n${stdout}`.toLowerCase();
|
||||
}
|
||||
|
||||
function isGhAuthError(error: unknown): boolean {
|
||||
const text = getCommandErrorText(error);
|
||||
return (
|
||||
text.includes("gh auth login") ||
|
||||
text.includes("not logged into any github hosts") ||
|
||||
text.includes("authentication failed") ||
|
||||
text.includes("authentication required") ||
|
||||
text.includes("bad credentials") ||
|
||||
text.includes("http 401")
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git config --get remote.origin.url", {
|
||||
@@ -1356,39 +1511,66 @@ export async function createPullRequest(
|
||||
return { url: parsed.url, number: parsed.number };
|
||||
}
|
||||
|
||||
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatus | null> {
|
||||
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatusResult> {
|
||||
await requireGitRepo(cwd);
|
||||
await ensureGhAvailable(cwd);
|
||||
const repo = await resolveGitHubRepo(cwd);
|
||||
const head = await getCurrentBranch(cwd);
|
||||
if (!repo || !head) {
|
||||
return null;
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await ensureGhAvailable(cwd);
|
||||
} catch {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
const owner = repo.split("/")[0];
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${repo}/pulls`,
|
||||
"-X",
|
||||
"GET",
|
||||
"-F",
|
||||
`head=${owner}:${head}`,
|
||||
"-F",
|
||||
"state=open",
|
||||
],
|
||||
{ cwd }
|
||||
);
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${repo}/pulls`,
|
||||
"-X",
|
||||
"GET",
|
||||
"-F",
|
||||
`head=${owner}:${head}`,
|
||||
"-F",
|
||||
"state=open",
|
||||
],
|
||||
{ cwd }
|
||||
));
|
||||
} catch (error) {
|
||||
if (isGhAuthError(error)) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
|
||||
if (!current) {
|
||||
return null;
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: current.html_url ?? current.url,
|
||||
title: current.title,
|
||||
state: current.state,
|
||||
baseRefName: current.base?.ref ?? "",
|
||||
headRefName: current.head?.ref ?? head,
|
||||
status: {
|
||||
url: current.html_url ?? current.url,
|
||||
title: current.title,
|
||||
state: current.state,
|
||||
baseRefName: current.base?.ref ?? "",
|
||||
headRefName: current.head?.ref ?? head,
|
||||
},
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as PrivacyRouteImport } from './routes/privacy'
|
||||
import { Route as DocsRouteImport } from './routes/docs'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as DocsIndexRouteImport } from './routes/docs/index'
|
||||
@@ -19,6 +20,11 @@ import { Route as DocsConfigurationRouteImport } from './routes/docs/configurati
|
||||
import { Route as DocsCliRouteImport } from './routes/docs/cli'
|
||||
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
|
||||
|
||||
const PrivacyRoute = PrivacyRouteImport.update({
|
||||
id: '/privacy',
|
||||
path: '/privacy',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DocsRoute = DocsRouteImport.update({
|
||||
id: '/docs',
|
||||
path: '/docs',
|
||||
@@ -68,6 +74,7 @@ const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/docs': typeof DocsRouteWithChildren
|
||||
'/privacy': typeof PrivacyRoute
|
||||
'/docs/best-practices': typeof DocsBestPracticesRoute
|
||||
'/docs/cli': typeof DocsCliRoute
|
||||
'/docs/configuration': typeof DocsConfigurationRoute
|
||||
@@ -78,6 +85,7 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/privacy': typeof PrivacyRoute
|
||||
'/docs/best-practices': typeof DocsBestPracticesRoute
|
||||
'/docs/cli': typeof DocsCliRoute
|
||||
'/docs/configuration': typeof DocsConfigurationRoute
|
||||
@@ -90,6 +98,7 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/docs': typeof DocsRouteWithChildren
|
||||
'/privacy': typeof PrivacyRoute
|
||||
'/docs/best-practices': typeof DocsBestPracticesRoute
|
||||
'/docs/cli': typeof DocsCliRoute
|
||||
'/docs/configuration': typeof DocsConfigurationRoute
|
||||
@@ -103,6 +112,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/docs'
|
||||
| '/privacy'
|
||||
| '/docs/best-practices'
|
||||
| '/docs/cli'
|
||||
| '/docs/configuration'
|
||||
@@ -113,6 +123,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/privacy'
|
||||
| '/docs/best-practices'
|
||||
| '/docs/cli'
|
||||
| '/docs/configuration'
|
||||
@@ -124,6 +135,7 @@ export interface FileRouteTypes {
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/docs'
|
||||
| '/privacy'
|
||||
| '/docs/best-practices'
|
||||
| '/docs/cli'
|
||||
| '/docs/configuration'
|
||||
@@ -136,10 +148,18 @@ export interface FileRouteTypes {
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
DocsRoute: typeof DocsRouteWithChildren
|
||||
PrivacyRoute: typeof PrivacyRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/privacy': {
|
||||
id: '/privacy'
|
||||
path: '/privacy'
|
||||
fullPath: '/privacy'
|
||||
preLoaderRoute: typeof PrivacyRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/docs': {
|
||||
id: '/docs'
|
||||
path: '/docs'
|
||||
@@ -231,6 +251,7 @@ const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren)
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
DocsRoute: DocsRouteWithChildren,
|
||||
PrivacyRoute: PrivacyRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -64,12 +64,23 @@ paseo stop <id> # Stop an agent`}</pre>
|
||||
<pre className="text-white/80">{`paseo run "implement user authentication"
|
||||
paseo run --provider codex "refactor the API layer"
|
||||
paseo run --detach "run the full test suite" # background
|
||||
paseo run --worktree feature-x "implement feature X"`}</pre>
|
||||
paseo run --worktree feature-x "implement feature X"
|
||||
paseo run --output-schema schema.json "extract release notes"
|
||||
paseo run --output-schema '{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}' "summarize release notes"`}</pre>
|
||||
</Code>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
The <code className="font-mono">--worktree</code> flag creates the agent in an isolated git
|
||||
worktree, useful for parallel feature development.
|
||||
</p>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
Use <code className="font-mono">--output-schema</code> to return only matching JSON output.
|
||||
You can pass a schema file path or an inline JSON schema object.
|
||||
This mode cannot be used with <code className="font-mono">--detach</code>.
|
||||
</p>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
By default, <code className="font-mono">paseo run</code> waits for completion. Use{' '}
|
||||
<code className="font-mono">--detach</code> to run in the background.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Listing agents */}
|
||||
@@ -187,6 +198,21 @@ paseo daemon stop # Stop the daemon`}</pre>
|
||||
paseo run --detach "implement the API" --name api-agent
|
||||
paseo wait api-agent
|
||||
paseo logs api-agent --tail 5`}</pre>
|
||||
</Code>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
Simple implement + verify loop:
|
||||
</p>
|
||||
<Code>
|
||||
<pre className="text-white/80">{`# Requires jq
|
||||
while true; do
|
||||
paseo run --provider codex "make the tests pass" >/dev/null
|
||||
|
||||
verdict=$(paseo run --provider claude --output-schema '{"type":"object","properties":{"criteria_met":{"type":"boolean"}},"required":["criteria_met"],"additionalProperties":false}' "ensure tests all pass")
|
||||
if echo "$verdict" | jq -e '.criteria_met == true' >/dev/null; then
|
||||
echo "criteria met"
|
||||
break
|
||||
fi
|
||||
done`}</pre>
|
||||
</Code>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
This pattern enables hierarchical task decomposition — a lead agent can break down work,
|
||||
|
||||
@@ -53,6 +53,16 @@ function Home() {
|
||||
<Story />
|
||||
<FAQ />
|
||||
</main>
|
||||
<footer className="p-6 md:p-20 md:pt-0 max-w-3xl mx-auto">
|
||||
<div className="border-t border-white/10 pt-6">
|
||||
<a
|
||||
href="/privacy"
|
||||
className="text-xs text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Privacy
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</CursorFieldProvider>
|
||||
)
|
||||
@@ -180,18 +190,26 @@ function Feature({
|
||||
|
||||
function GetStarted() {
|
||||
return (
|
||||
<div className="pt-10">
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<div className="pt-10 space-y-4">
|
||||
<CodeBlock>npm install -g @getpaseo/cli && paseo</CodeBlock>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<a
|
||||
href="https://github.com/getpaseo/paseo/releases/latest"
|
||||
href="https://github.com/getpaseo/paseo/releases/download/v0.1.2/Paseo_0.1.2_aarch64.dmg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 transition-colors"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Download for Mac
|
||||
</a>
|
||||
<a
|
||||
href="https://app.paseo.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Launch Web App
|
||||
</a>
|
||||
</div>
|
||||
<CodeBlock>npm install -g @getpaseo/cli && paseo</CodeBlock>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
106
packages/website/src/routes/privacy.tsx
Normal file
106
packages/website/src/routes/privacy.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/privacy')({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: 'Privacy Policy - Paseo' },
|
||||
{
|
||||
name: 'description',
|
||||
content:
|
||||
'Privacy policy for Paseo - a self-hosted agent manager with no tracking or analytics.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: Privacy,
|
||||
})
|
||||
|
||||
function Privacy() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-3xl mx-auto p-6 md:p-12">
|
||||
<h1 className="text-3xl font-medium mb-8">Privacy Policy</h1>
|
||||
|
||||
<div className="space-y-6 text-white/70 leading-relaxed">
|
||||
<p>
|
||||
Paseo is a self-hosted tool for managing coding agents. Your code and
|
||||
data stay on your machine.
|
||||
</p>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-medium text-white">What we collect</h2>
|
||||
<p>Nothing. Paseo runs on your machine and doesn't send us any data.</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-medium text-white">The relay server</h2>
|
||||
<p>
|
||||
If you use the optional encrypted relay to connect your phone to your
|
||||
daemon, the relay sees:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-4">
|
||||
<li>IP addresses and connection timing</li>
|
||||
<li>Message sizes</li>
|
||||
<li>Session IDs</li>
|
||||
</ul>
|
||||
<p>
|
||||
All messages between your phone and daemon are end-to-end encrypted
|
||||
with AES-256-GCM. The relay cannot read your messages, see your code,
|
||||
or decrypt your traffic.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-medium text-white">Analytics and tracking</h2>
|
||||
<p>
|
||||
We don't use analytics, tracking pixels, cookies, or ads. The app
|
||||
doesn't phone home.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-medium text-white">Third-party services</h2>
|
||||
<p>
|
||||
Paseo wraps agent providers like Claude Code, Codex, and OpenCode.
|
||||
Those tools communicate with their own APIs (Anthropic, OpenAI, etc.)
|
||||
using your credentials. Paseo doesn't manage or intercept those API
|
||||
calls.
|
||||
</p>
|
||||
<p>
|
||||
If you use voice features with cloud providers (OpenAI speech), your
|
||||
voice data is sent to those services according to their privacy
|
||||
policies.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-medium text-white">
|
||||
We don't sell your data
|
||||
</h2>
|
||||
<p>
|
||||
We don't have your data to sell. Paseo is self-hosted and
|
||||
local-first.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-medium text-white">Questions</h2>
|
||||
<p>
|
||||
If you have questions about privacy, open an issue on{' '}
|
||||
<a
|
||||
href="https://github.com/getpaseo/paseo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-white/90"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-sm text-white/50 pt-6">Last updated: February 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user