mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 22:02:34 +00:00
Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3910bdc0 | ||
|
|
b607bed49c | ||
|
|
ec12c5c35c | ||
|
|
1daccc4414 | ||
|
|
af140997a3 | ||
|
|
98bf569448 | ||
|
|
bdfa718c83 | ||
|
|
791939081b | ||
|
|
18ec41670f | ||
|
|
add858f3e8 | ||
|
|
92a1e3fa66 | ||
|
|
91f76c9494 | ||
|
|
d58230404b | ||
|
|
692d4777c6 | ||
|
|
3a8824ed99 | ||
|
|
6d16961d58 | ||
|
|
16e0967408 | ||
|
|
697a0002d9 | ||
|
|
1518eb09a9 | ||
|
|
3175a5ff10 | ||
|
|
eafa5f2b5c | ||
|
|
4748aad103 | ||
|
|
12c1a68956 | ||
|
|
cd354158f5 | ||
|
|
cf4d702a8e | ||
|
|
e5f3b3dc02 | ||
|
|
b500809229 | ||
|
|
642c303859 | ||
|
|
a67381795b | ||
|
|
8a37f2f5d3 | ||
|
|
54f0e636b9 | ||
|
|
cd429339a0 | ||
|
|
75bbca4a1b | ||
|
|
23853a1515 | ||
|
|
51193e7a92 | ||
|
|
d35c10a8b5 | ||
|
|
ff83ca4bdc | ||
|
|
accd2d7587 | ||
|
|
2501a46f3e | ||
|
|
e2ac23da70 | ||
|
|
f0bd2c8483 | ||
|
|
7c32fcda98 | ||
|
|
30195d3bd1 | ||
|
|
770b87a15c | ||
|
|
b03dc804eb | ||
|
|
373a98c64d | ||
|
|
599846650c | ||
|
|
36d8464068 | ||
|
|
b5610928a5 | ||
|
|
b44bb63cf4 | ||
|
|
4950d4c2ea | ||
|
|
4149eeb407 | ||
|
|
a732cc5756 | ||
|
|
6a1cc98fdf | ||
|
|
9c91dbbf42 | ||
|
|
2b9501a810 | ||
|
|
52e4060d76 | ||
|
|
a4b92768d9 | ||
|
|
0f27230746 | ||
|
|
9c195feb9b | ||
|
|
90f3d95415 | ||
|
|
1a6054138d | ||
|
|
20bccd31a3 | ||
|
|
d0d60a4052 | ||
|
|
9ca6f24315 | ||
|
|
36312352db | ||
|
|
c190fed27c | ||
|
|
cbb8295ceb | ||
|
|
9a7301d9a0 | ||
|
|
781e07f4ea | ||
|
|
44b9980a70 | ||
|
|
ed0d4e6c5b | ||
|
|
1b887e534b | ||
|
|
4426a6b11f | ||
|
|
33afa7283b | ||
|
|
fc1cb82e02 | ||
|
|
0c6dbefa40 | ||
|
|
94106a30ab | ||
|
|
635a8be437 | ||
|
|
527b252497 | ||
|
|
d03e4298ab | ||
|
|
8e916fd2dc | ||
|
|
474b4e70b4 | ||
|
|
124e512b58 | ||
|
|
0ba7251280 | ||
|
|
b58ccf9269 | ||
|
|
359d5443a9 | ||
|
|
2f6b585a3a | ||
|
|
2c5c629db8 | ||
|
|
6ed364ffe2 | ||
|
|
3a36c76733 | ||
|
|
74f2158c45 | ||
|
|
d012b6f071 | ||
|
|
5f5c585e68 | ||
|
|
23d7a8eaf9 | ||
|
|
6ca27a9b12 | ||
|
|
7b167784d7 | ||
|
|
40bdb9a043 | ||
|
|
2f54c0e309 | ||
|
|
b9d7d4b18e | ||
|
|
1c905a114e | ||
|
|
163e7d1cc4 | ||
|
|
cdb1163145 | ||
|
|
f24664b882 | ||
|
|
a39959219f | ||
|
|
db6520bf36 | ||
|
|
b2ff359b60 | ||
|
|
308e9af845 | ||
|
|
cbf59f4c96 | ||
|
|
07044136c8 | ||
|
|
b7ca1f7502 | ||
|
|
917e39495b | ||
|
|
8c003351d0 | ||
|
|
36a13601dd | ||
|
|
b599d38a77 | ||
|
|
36241cbd87 | ||
|
|
7253228dc5 | ||
|
|
0520481966 | ||
|
|
b682f56bb9 | ||
|
|
d340365245 | ||
|
|
334bf62370 | ||
|
|
6aa9f92156 | ||
|
|
4898523351 | ||
|
|
29ae25d7aa | ||
|
|
df9e8e8f06 | ||
|
|
94bda1f925 | ||
|
|
a18d9ec3d8 | ||
|
|
c5d5441c9c | ||
|
|
ca2b633286 |
@@ -18,7 +18,7 @@ Closes #
|
||||
|
||||
### Reasoning
|
||||
|
||||
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provider to Paseo users. -->
|
||||
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provided to Paseo users. -->
|
||||
|
||||
### Goals
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ hub:
|
||||
|
||||
server:
|
||||
- "packages/server/**"
|
||||
- "runtimes/fixture/**"
|
||||
- "packages/workspace-helper/**"
|
||||
- "packages/workspace-runtime-contract/**"
|
||||
- "packages/app/e2e/support/fixtures/recording.*"
|
||||
|
||||
desktop:
|
||||
|
||||
@@ -102,6 +102,9 @@ jobs:
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Enforce workspace runtime ownership boundaries
|
||||
run: npm run check:workspace-runtime-boundaries && node --test scripts/workspace-runtime-boundaries.test.mjs
|
||||
|
||||
typecheck:
|
||||
name: typecheck
|
||||
needs: changes
|
||||
@@ -129,6 +132,8 @@ jobs:
|
||||
- name: Verify public package contents
|
||||
run: |
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-runtime-contract
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-helper
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
|
||||
|
||||
@@ -163,6 +168,18 @@ jobs:
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Test workspace runtime command contract package
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm test --workspace=@getpaseo/workspace-runtime-contract && npm test --workspace=@getpaseo/workspace-helper
|
||||
|
||||
- name: Prove the command runtime fixture is independently installable
|
||||
if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
run: node --test scripts/workspace-runtime-standalone.test.mjs
|
||||
|
||||
- name: Prove clean server builds own public prerequisites
|
||||
if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
run: node --test scripts/server-clean-build.test.mjs
|
||||
|
||||
- name: Run server tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
|
||||
@@ -16,8 +16,12 @@ on:
|
||||
- "packages/app/**"
|
||||
- "packages/cli/**"
|
||||
- "packages/client/**"
|
||||
- "packages/desktop/**"
|
||||
- "packages/workspace-helper/**"
|
||||
- "packages/workspace-runtime-contract/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/server/**"
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
- "packages/app/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
|
||||
+138
@@ -84,6 +84,144 @@
|
||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/server/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"**/workspace-runtime/internal/**",
|
||||
"**/workspace-runtime/command/internal/**"
|
||||
],
|
||||
"message": "Workspace runtime internals are owned by their public entry points."
|
||||
},
|
||||
{
|
||||
"group": ["**/workspace-helper/internal/**"],
|
||||
"message": "Normal callers consume WorkspaceFiles from workspace-helper/index. Only the workspace-runtime parent integration and workspace-helper tests may assemble helper clients."
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"**/workspace-runtime/integration/**",
|
||||
"**/workspace-runtime/git-observation/internal/**"
|
||||
],
|
||||
"message": "Git callers consume workspace-runtime/git-observation/index. Only runtime internals may assemble shared observation identity, placement, helpers, and lifecycle."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/server/src/server/workspace-runtime/index.ts",
|
||||
"packages/server/src/server/workspace-runtime/command/index.ts",
|
||||
"packages/server/src/server/workspace-runtime/command/internal/**/*.{ts,tsx}",
|
||||
"packages/server/src/server/workspace-runtime/internal/**/*.{ts,tsx}",
|
||||
"packages/server/src/server/workspace-helper/**/*.{ts,tsx}",
|
||||
"packages/server/src/server/workspace-runtime/git-observation/**/*.{ts,tsx}",
|
||||
"packages/server/src/server/workspace-runtime/internal/service.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/server/src/server/session/files/**/*.{ts,tsx}",
|
||||
"packages/server/src/server/session/git-mutation/**/*.{ts,tsx}",
|
||||
"packages/server/src/server/session/provider/**/*.{ts,tsx}",
|
||||
"packages/server/src/server/session/workspace-git-observer/**/*.{ts,tsx}",
|
||||
"packages/server/src/server/session/workspace-provisioning/**/*.{ts,tsx}",
|
||||
"packages/server/src/server/session/workspace-scripts/**/*.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{ "name": "child_process", "message": "Use the bound workspace runtime." },
|
||||
{ "name": "node:child_process", "message": "Use the bound workspace runtime." },
|
||||
{ "name": "fs", "message": "Use the bound workspace runtime." },
|
||||
{ "name": "node:fs", "message": "Use the bound workspace runtime." },
|
||||
{ "name": "fs/promises", "message": "Use the bound workspace runtime." },
|
||||
{ "name": "node:fs/promises", "message": "Use the bound workspace runtime." },
|
||||
{ "name": "module", "message": "Dynamic loaders bypass workspace ownership." },
|
||||
{
|
||||
"name": "node:module",
|
||||
"message": "Dynamic loaders bypass workspace ownership."
|
||||
},
|
||||
{ "name": "which", "message": "Use the bound workspace runtime." }
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/utils/run-git-command.{js,ts}"],
|
||||
"message": "Use the bound workspace runtime."
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"**/workspace-runtime/internal/**",
|
||||
"**/workspace-runtime/command/internal/**",
|
||||
"**/workspace-runtime/git-observation/internal/**",
|
||||
"**/workspace-helper/internal/**"
|
||||
],
|
||||
"message": "Use the owning public entry point."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/server/src/server/session/files/**/*.test.{ts,tsx}",
|
||||
"packages/server/src/server/session/git-mutation/**/*.test.{ts,tsx}",
|
||||
"packages/server/src/server/session/provider/**/*.test.{ts,tsx}",
|
||||
"packages/server/src/server/session/workspace-git-observer/**/*.test.{ts,tsx}",
|
||||
"packages/server/src/server/session/workspace-provisioning/**/*.test.{ts,tsx}",
|
||||
"packages/server/src/server/session/workspace-scripts/**/*.test.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/fixture-workspace-runtime/**/*.{js,mjs,ts,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "module",
|
||||
"message": "The external runtime may not construct dynamic module loaders."
|
||||
},
|
||||
{
|
||||
"name": "node:module",
|
||||
"message": "The external runtime may not construct dynamic module loaders."
|
||||
},
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"message": "The external runtime may consume only the published command contract."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"@getpaseo/server/**",
|
||||
"**/packages/server/**",
|
||||
"**/workspace-runtime/**/internal/**",
|
||||
"**/workspace-helper/**/internal/**"
|
||||
],
|
||||
"message": "The external runtime may consume only the published command contract."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.0 - 2026-08-13
|
||||
|
||||
### Breaking
|
||||
|
||||
- Removed `paseo chat` and `paseo loop` ([#3053](https://github.com/getpaseo/paseo/pull/3053))
|
||||
|
||||
### Added
|
||||
|
||||
- Added host-wide agent profiles for reusable provider, model, mode, thinking, and feature settings ([#3208](https://github.com/getpaseo/paseo/pull/3208))
|
||||
- Added workspace file search to Cmd/Ctrl+P ([#3059](https://github.com/getpaseo/paseo/pull/3059))
|
||||
- Added file and folder actions to Files and Changes ([#3027](https://github.com/getpaseo/paseo/pull/3027) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Added interactive Mermaid diagrams to chats and Markdown previews ([#2306](https://github.com/getpaseo/paseo/pull/2306) by [@dmeledon](https://github.com/dmeledon))
|
||||
- Added live task progress above the composer and in the timeline ([#3227](https://github.com/getpaseo/paseo/pull/3227))
|
||||
- Added a model setting for generated workspace titles, branch names, commits, and pull request drafts ([#3215](https://github.com/getpaseo/paseo/pull/3215))
|
||||
- Added the supported `@getpaseo/client` TypeScript SDK ([#3141](https://github.com/getpaseo/paseo/pull/3141))
|
||||
- Added provider installation diagnostics to the CLI ([#3243](https://github.com/getpaseo/paseo/pull/3243))
|
||||
- Added workspace rename to the CLI ([#3209](https://github.com/getpaseo/paseo/pull/3209) by [@martinhanzik](https://github.com/martinhanzik))
|
||||
|
||||
### Improved
|
||||
|
||||
- Added a centered reading layout and formatted YAML front matter to Markdown previews ([#3240](https://github.com/getpaseo/paseo/pull/3240))
|
||||
- Added sidebar grouping to the Command Center ([#3063](https://github.com/getpaseo/paseo/pull/3063) by [@cleiter](https://github.com/cleiter))
|
||||
- Added every built-in theme to theme shortcuts ([#3214](https://github.com/getpaseo/paseo/pull/3214))
|
||||
- Removed periodic Git polling for idle workspaces ([#3323](https://github.com/getpaseo/paseo/pull/3323))
|
||||
- Set provider catalog refreshes to a configurable two-minute deadline ([#3322](https://github.com/getpaseo/paseo/pull/3322))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed terminal sessions being lost after host sleep or daemon worker stalls ([#3235](https://github.com/getpaseo/paseo/pull/3235), [#3263](https://github.com/getpaseo/paseo/pull/3263))
|
||||
- Fixed daemon hang when archiving a workspace ([#3107](https://github.com/getpaseo/paseo/pull/3107))
|
||||
- Fixed New Workspace crash on Android when projects span multiple hosts ([#3241](https://github.com/getpaseo/paseo/pull/3241))
|
||||
- Fixed provider cache exhausting Android local storage ([#3234](https://github.com/getpaseo/paseo/pull/3234))
|
||||
- Fixed dictation recordings being lost during connection interruptions ([#3159](https://github.com/getpaseo/paseo/pull/3159))
|
||||
- Fixed new workspaces reusing an existing worktree ([#3224](https://github.com/getpaseo/paseo/pull/3224))
|
||||
- Fixed new worktrees ignoring the setup command saved in Project Settings ([#3233](https://github.com/getpaseo/paseo/pull/3233))
|
||||
- Fixed new worktrees becoming dirty when the selected base contained a different `paseo.json` ([#3311](https://github.com/getpaseo/paseo/pull/3311))
|
||||
- Fixed removed hosts continuing to receive push notifications ([#3176](https://github.com/getpaseo/paseo/pull/3176))
|
||||
- Fixed delegated-agent notifications stopping after permission prompts or workspace closure ([#3177](https://github.com/getpaseo/paseo/pull/3177), [#3192](https://github.com/getpaseo/paseo/pull/3192) by [@thomasvan](https://github.com/thomasvan), [@wilgon456](https://github.com/wilgon456))
|
||||
- Fixed OMP agent startup when model catalogs exceed the protocol-v1 frame limit ([#3184](https://github.com/getpaseo/paseo/pull/3184) by [@pi3123](https://github.com/pi3123))
|
||||
- Fixed OpenCode models being unable to return to their default variant ([#3281](https://github.com/getpaseo/paseo/pull/3281))
|
||||
- Fixed completed Codex subagents remaining marked active ([#3188](https://github.com/getpaseo/paseo/pull/3188) by [@Strainy](https://github.com/Strainy))
|
||||
- Fixed Codex compaction leaving turns marked as working ([#3211](https://github.com/getpaseo/paseo/pull/3211) by [@edihasaj](https://github.com/edihasaj))
|
||||
- Fixed Cursor usage with current Cursor logins ([#2704](https://github.com/getpaseo/paseo/pull/2704) by [@QuteSaltyFish](https://github.com/QuteSaltyFish))
|
||||
- Fixed SVG project icons rendering as blank on mobile ([#2579](https://github.com/getpaseo/paseo/pull/2579) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed crash when persisted cache was incompatible ([#3289](https://github.com/getpaseo/paseo/pull/3289))
|
||||
- Fixed open delegated agents being archived with their parent ([#3279](https://github.com/getpaseo/paseo/pull/3279))
|
||||
- Fixed agent creation when profiles carried modes unsupported by the selected provider ([#3331](https://github.com/getpaseo/paseo/pull/3331))
|
||||
- Fixed Claude agents waiting on Paseo's ten-minute MCP timeout ([#3315](https://github.com/getpaseo/paseo/pull/3315))
|
||||
- Fixed Copy resume command for Hermes agents ([#3300](https://github.com/getpaseo/paseo/pull/3300) by [@desmond-rai](https://github.com/desmond-rai))
|
||||
|
||||
## 0.3.1 - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ Thank you for taking the time to contribute to Paseo.
|
||||
|
||||
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
|
||||
|
||||
Given Paseo's scope, contributing to to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
|
||||
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
|
||||
|
||||
@@ -72,8 +72,8 @@ Here is the criteria I use to decide:
|
||||
- PRs that were explicitly approved in a discussion are preferred.
|
||||
- Unsolicited PR can be closed without a detailed review.
|
||||
- Your PR can be narrowed, refactored, or redesigned.
|
||||
- Your PR might be accpeted but not merged immediately.
|
||||
- You wil be attributed for your work in the changelog, even if I redesign your PR
|
||||
- Your PR might be accepted but not merged immediately.
|
||||
- You will be attributed for your work in the changelog, even if I redesign your PR
|
||||
|
||||
## QA evidence
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@ npx skills add getpaseo/paseo
|
||||
どのエージェントとの会話でも使用できます。
|
||||
|
||||
- `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。
|
||||
- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせます(Ralph loops とも呼ばれます)。検証役を追加することもできます。
|
||||
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
|
||||
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ npx skills add getpaseo/paseo
|
||||
그런 다음 어떤 에이전트 대화에서든 아래 명령을 사용할 수 있습니다.
|
||||
|
||||
- `/paseo-handoff` — 에이전트 간에 작업을 넘깁니다. Claude로 계획을 세운 뒤 Codex에 구현을 넘길 때 이 기능을 씁니다.
|
||||
- `/paseo-loop` — 명확한 완료 조건을 기준으로 에이전트를 반복 실행합니다(Ralph loop). 검증 에이전트를 추가할 수도 있습니다.
|
||||
- `/paseo-advisor` — 작업 자체를 넘기지 않고, 에이전트 하나를 조언자로 띄워 두 번째 의견을 받습니다.
|
||||
- `/paseo-committee` — 서로 다른 관점의 에이전트 두 개로 위원회를 구성해, 한 발 물러나 근본 원인을 분석하고 계획을 세웁니다.
|
||||
|
||||
|
||||
@@ -152,7 +152,6 @@ npx skills add getpaseo/paseo
|
||||
Then use them in any agent conversation:
|
||||
|
||||
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
|
||||
- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
|
||||
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
|
||||
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@ npx skills add getpaseo/paseo
|
||||
然后在任意 agent 对话中使用:
|
||||
|
||||
- `/paseo-handoff` — 在 agents 之间交接工作。我会用它先和 Claude 规划,再交给 Codex 实现。
|
||||
- `/paseo-loop` — 让 agent 按明确验收标准循环工作(也叫 Ralph loops),也可以加 verifier。
|
||||
- `/paseo-advisor` — 启动单个 agent 作为 advisor,提供第二意见,但不把工作委托出去。
|
||||
- `/paseo-committee` — 组建两个风格互补的 agents,让它们后退一步做根因分析并产出计划。
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ RUN set -eux; \
|
||||
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/workspace-runtime-contract --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/workspace-helper --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||
|
||||
|
||||
+28
-12
@@ -44,11 +44,26 @@ Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Age
|
||||
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
|
||||
|
||||
Parent archive detaches a subagent instead of archiving it when either condition holds:
|
||||
|
||||
- The child belongs to another workspace.
|
||||
- The child is currently open in an agent tab.
|
||||
|
||||
All other children archive with the parent. After the workspace layout hydrates, the client marks
|
||||
every managed subagent present in its tabs with `paseo.open-agent-tab.<client-id>=true` through the
|
||||
generic agent metadata update. This includes background and restored tabs; navigation does not own
|
||||
the marker. Closing a tab sets that client's label to `false`. Any `true` client label keeps the child
|
||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||
normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
|
||||
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
|
||||
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
|
||||
|
||||
## Provider-managed child agents
|
||||
|
||||
@@ -71,7 +86,7 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
|
||||
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
|
||||
3. Notify subscribers
|
||||
4. Close the runtime (kills the process if still running)
|
||||
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
|
||||
5. **Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively
|
||||
|
||||
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
||||
|
||||
@@ -106,7 +121,7 @@ These are two distinct concepts that used to be conflated:
|
||||
|
||||
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
|
||||
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy.
|
||||
|
||||
The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.
|
||||
|
||||
@@ -150,9 +165,9 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
|
||||
|
||||
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
|
||||
|
||||
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
|
||||
The track header's **Archive finished** action covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.
|
||||
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
|
||||
## Why this shape
|
||||
|
||||
@@ -170,7 +185,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
|
||||
|
||||
### Subagent accumulation under long-lived parents
|
||||
|
||||
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
|
||||
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
|
||||
|
||||
### Cross-client tab dismissal
|
||||
|
||||
@@ -186,11 +201,12 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
| Field | Type | Meaning |
|
||||
| -------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
||||
| `labels["paseo.open-agent-tab.<client-id>"]` | `string?` | `"true"` protects an open tab on that client; detach clears every matching label |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
+32
-14
@@ -69,15 +69,16 @@ not retain non-Git directories.
|
||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
|
||||
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/workspace-runtime/` | Runtime-neutral workspace lifecycle, execution, files, Git observation |
|
||||
| `server/provider-probe/` | Invisible runtime-bound workspaces for pre-creation provider discovery |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
|
||||
### `packages/protocol` — Wire schemas and shared protocol types
|
||||
|
||||
@@ -86,6 +87,19 @@ agent timeline types, provider config schemas, and other values shared by daemon
|
||||
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
|
||||
it does not depend on the server.
|
||||
|
||||
### Workspace runtime packages
|
||||
|
||||
`packages/workspace-runtime-contract` owns the versioned command-runtime lifecycle and exec wire
|
||||
contract. `packages/workspace-helper` owns the official confined files/watch/command-resolution
|
||||
executable and its typed binding. The daemon depends on those two packages and registers command
|
||||
runtimes without importing an implementation.
|
||||
|
||||
`runtimes/fixture` is a private generic contract fixture. Runtime implementations live in their own
|
||||
repositories and depend on published versions of the two public workspace packages. CLI, Desktop,
|
||||
server, release workflows, and the daemon image do not depend on, bundle, publish, or register an
|
||||
implementation. Tests invoke registered runtimes through their public command, never through source
|
||||
imports.
|
||||
|
||||
### `packages/client` — Daemon client library and SDK facade
|
||||
|
||||
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
|
||||
@@ -99,19 +113,27 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `runtime/replica-cache` keeps a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization path.
|
||||
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
|
||||
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
The replica cache exists only to paint stale data immediately while the host connects. It does not
|
||||
own mutations, infer deletions, or replace daemon reconciliation. Pending permission requests are
|
||||
not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source
|
||||
code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision.
|
||||
Its serialized payload has a 1 MiB byte budget and evicts whole host snapshots in least-recently-
|
||||
written order; a single oversized host is omitted rather than partially restored.
|
||||
The replica cache paints stale data immediately while the host connects. Directory cursors are
|
||||
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
|
||||
Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
|
||||
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
|
||||
separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
|
||||
host snapshots in least-recently-written order; a single oversized host is omitted rather than
|
||||
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
|
||||
AsyncStorage, and Android reserves 64 MiB for that database.
|
||||
|
||||
The three directory entity types have independent monotonic sequences and share one daemon
|
||||
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
|
||||
event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are
|
||||
independent records; a project with no workspaces does not need a workspace placeholder.
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
|
||||
@@ -119,13 +141,11 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
|
||||
|
||||
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
|
||||
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||
- `paseo chat ls/create/inspect/post/read/wait/delete`
|
||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||
- `paseo script ls/start/stop`
|
||||
- `paseo loop run/ls/inspect/logs/stop`
|
||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||
- `paseo heartbeat create/update/delete`
|
||||
- `paseo workspace create/ls/archive`
|
||||
- `paseo workspace create/ls/rename/archive`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
|
||||
@@ -384,9 +404,7 @@ $PASEO_HOME/
|
||||
├── projects/projects.json # Project registry
|
||||
├── projects/workspaces.json # Workspace registry
|
||||
├── projects/icons/ # Custom project icon images
|
||||
├── chat/ # Chat rooms
|
||||
├── schedules/ # Scheduled-agent definitions and runs
|
||||
├── loops/ # Loop runs and logs
|
||||
├── config.json # Daemon config (mutable)
|
||||
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
||||
├── push-tokens.json # Mobile push tokens
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
|
||||
|
||||
All provider configuration lives under `agents.providers` in config.json:
|
||||
Provider definitions live under `agents.providers` in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -17,6 +17,20 @@ All provider configuration lives under `agents.providers` in config.json:
|
||||
|
||||
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
|
||||
|
||||
Each provider catalog refresh waits up to 2 minutes. If a provider loads many plugins or a large
|
||||
agent catalog during startup, raise the limit in milliseconds:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"catalogRefreshTimeoutMs": 180000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The limit applies independently to every provider refresh and covers availability plus the entire
|
||||
catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
@@ -245,6 +259,8 @@ requires_openai_auth = false
|
||||
|
||||
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
|
||||
|
||||
"Profile" here means a provider alias, and it is not an **Agent profile** — that is a named bundle of provider, model, mode, thinking option and features, stored under `daemon.agentProfiles`. See [glossary.md](glossary.md) for all four senses of the word.
|
||||
|
||||
Example: two different Anthropic accounts as separate profiles:
|
||||
|
||||
```json
|
||||
|
||||
+59
-154
@@ -16,19 +16,23 @@ names, and workspace foreign keys. Attached workspaces are independently refresh
|
||||
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
|
||||
are observed too.
|
||||
|
||||
The workspace registry model defines placement once: initial directory/worktree construction,
|
||||
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
|
||||
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding
|
||||
registry writes, so directory opens, agent imports, and worktree creation all enter through that
|
||||
service instead of constructing records independently. The workspace record is then the durable
|
||||
placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing
|
||||
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
|
||||
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
|
||||
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
|
||||
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
|
||||
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
|
||||
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
|
||||
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
|
||||
The workspace registry model defines public placement once: initial directory/worktree
|
||||
construction, mutable reconciliation fields, and the persisted-to-wire projection.
|
||||
`WorkspaceProvisioningService` owns the corresponding registry writes, so directory opens, agent
|
||||
imports, and worktree creation do not construct records independently.
|
||||
|
||||
For a runtime-selected workspace, `runtime.runtimeId` is the only persisted runtime identity. `cwd`
|
||||
is required compatibility and presentation data: an adopted local directory, the actual host
|
||||
worktree path, or a runtime-local path such as Docker's `/workspace`. It is not globally unique and
|
||||
never selects a runtime. `hostVisiblePath` is the separate optional capability used by editor and
|
||||
file-manager actions. Private roots, revisions, execution domains, container and volume IDs,
|
||||
labels, supervisor state, and ownership metadata are recovered from driver `inspect(workspaceId)`
|
||||
and are never written to the workspace record.
|
||||
|
||||
Records without `runtime` remain the narrow legacy placement boundary. Their `cwd`, `worktreeRoot`,
|
||||
and `mainRepoRoot` continue to support old local/worktree behavior until classified. Selected
|
||||
worktree records retain user-visible worktree placement metadata, but lifecycle and deletion are
|
||||
owned by the runtime driver.
|
||||
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
|
||||
|
||||
@@ -54,10 +58,6 @@ $PASEO_HOME/
|
||||
│ └── {agentId}.json # One file per agent
|
||||
├── schedules/
|
||||
│ └── {scheduleId}.json # One file per schedule
|
||||
├── chat/
|
||||
│ └── rooms.json # All rooms + messages
|
||||
├── loops/
|
||||
│ └── loops.json # All loop records
|
||||
├── projects/
|
||||
│ ├── projects.json # Project registry
|
||||
│ ├── workspaces.json # Workspace registry
|
||||
@@ -89,7 +89,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
@@ -100,7 +100,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
|
||||
### Nested: SerializableConfig
|
||||
@@ -179,6 +179,13 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
|
||||
|
||||
Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
|
||||
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
|
||||
fields and their removal/default semantics; session handlers and the CLI only relay the structured
|
||||
result. Normal config patches persist only the requested fields, so launch overrides and resolved
|
||||
defaults never leak into the file. Startup-only fields remain compared with the daemon's launch
|
||||
snapshot so a mixed edit can apply its live subset and still name the paths that require restart.
|
||||
|
||||
```
|
||||
{
|
||||
version: 1,
|
||||
@@ -189,6 +196,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
git: { maxProcessesPerSecond: number, maxProcessConcurrency: number },
|
||||
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||
terminalProfiles: TerminalProfile[], // named shell commands; omitted means DEFAULT_TERMINAL_PROFILES
|
||||
agentProfiles: AgentProfile[], // named agent launch bundles; omitted means none
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false
|
||||
auth: { password: string } // bcrypt hash, optional
|
||||
@@ -235,6 +244,21 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
### Profile lists
|
||||
|
||||
`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the
|
||||
array, never merges entries, so a client sends the complete next list on every add, edit, reorder
|
||||
and remove. List order is the display order.
|
||||
|
||||
Absent and empty mean different things for terminal profiles — omitting the key falls back to
|
||||
`DEFAULT_TERMINAL_PROFILES`, while `[]` means the user removed them all. Agent profiles have no
|
||||
defaults, so both mean none.
|
||||
|
||||
`PersistedConfigSchema` parses strictly, so a daemon that predates a field drops it on write
|
||||
rather than storing something it cannot describe. That is why the client gates the agent profiles
|
||||
UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an
|
||||
older daemon.
|
||||
|
||||
### Git process limits
|
||||
|
||||
Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per
|
||||
@@ -265,9 +289,9 @@ Environment variables override `config.json`:
|
||||
| `PASEO_GIT_MAX_PROCESS_CONCURRENCY` | `maxProcessConcurrency` |
|
||||
| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias |
|
||||
|
||||
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Restart the
|
||||
daemon after changing the file or environment. Run `paseo daemon restart` for a standalone daemon.
|
||||
For a desktop-managed daemon, fully quit and reopen Paseo Desktop.
|
||||
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Run `paseo reload`
|
||||
after changing `config.json`. Environment changes require a daemon restart; the launch environment
|
||||
remains authoritative during reload.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
@@ -358,133 +382,7 @@ One file per schedule. ID is 8 hex characters.
|
||||
|
||||
---
|
||||
|
||||
## 4. Chat
|
||||
|
||||
**Path:** `$PASEO_HOME/chat/rooms.json`
|
||||
|
||||
Single file containing all rooms and messages.
|
||||
|
||||
```json
|
||||
{
|
||||
"rooms": [ ... ],
|
||||
"messages": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### ChatRoom
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------- | ----------------------------------- |
|
||||
| `id` | `string` (UUID) | |
|
||||
| `name` | `string` | Unique room name (case-insensitive) |
|
||||
| `purpose` | `string?` | Room description |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
||||
|
||||
### ChatMessage
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------ | ------------------- | ----------------------------------- |
|
||||
| `id` | `string` (UUID) | |
|
||||
| `roomId` | `string` | FK to ChatRoom.id |
|
||||
| `authorAgentId` | `string` | Agent ID of the author |
|
||||
| `body` | `string` | Message text (supports `@mentions`) |
|
||||
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
||||
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Loop
|
||||
|
||||
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||
|
||||
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
|
||||
| `id` | `string` | 8-char UUID prefix |
|
||||
| `name` | `string?` | Human-readable name |
|
||||
| `prompt` | `string` | Worker prompt |
|
||||
| `cwd` | `string` | Working directory |
|
||||
| `provider` | `string` | Default provider |
|
||||
| `model` | `string?` | Default model |
|
||||
| `modeId` | `string?` | Default mode ID |
|
||||
| `workerProvider` | `string?` | Override provider for workers |
|
||||
| `workerModel` | `string?` | Override model for workers |
|
||||
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||
| `verifierModel` | `string?` | Override model for verifiers |
|
||||
| `verifierModeId` | `string?` | Override mode ID for verifiers |
|
||||
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||
| `sleepMs` | `number` | Delay between iterations (ms) |
|
||||
| `maxIterations` | `number?` | Cap on iterations |
|
||||
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `startedAt` | `string` (ISO 8601) | |
|
||||
| `completedAt` | `string?` (ISO 8601) | |
|
||||
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
||||
| `iterations` | `LoopIteration[]` | |
|
||||
| `logs` | `LoopLogEntry[]` | |
|
||||
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
||||
| `activeIteration` | `number?` | Currently executing iteration index |
|
||||
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
||||
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
||||
|
||||
### Nested: LoopIteration
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------- | --------------------------------------------------- | ------------------------ |
|
||||
| `index` | `number` | 1-based iteration index |
|
||||
| `workerAgentId` | `string?` | Agent ID of the worker |
|
||||
| `workerStartedAt` | `string` (ISO 8601) | |
|
||||
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
||||
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
||||
| `failureReason` | `string?` | |
|
||||
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
||||
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
||||
|
||||
### Nested: LoopLogEntry
|
||||
|
||||
| Field | Type |
|
||||
| ----------- | ---------------------------------------------------- |
|
||||
| `seq` | `number` (monotonic) |
|
||||
| `timestamp` | `string` (ISO 8601) |
|
||||
| `iteration` | `number?` |
|
||||
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
||||
| `level` | `"info" \| "error"` |
|
||||
| `text` | `string` |
|
||||
|
||||
### Nested: LoopVerifyCheckResult
|
||||
|
||||
| Field | Type |
|
||||
| ------------- | ------------------- |
|
||||
| `command` | `string` |
|
||||
| `exitCode` | `number` |
|
||||
| `passed` | `boolean` |
|
||||
| `stdout` | `string` |
|
||||
| `stderr` | `string` |
|
||||
| `startedAt` | `string` (ISO 8601) |
|
||||
| `completedAt` | `string` (ISO 8601) |
|
||||
|
||||
### Nested: LoopVerifyPromptResult
|
||||
|
||||
| Field | Type |
|
||||
| ----------------- | ------------------- |
|
||||
| `passed` | `boolean` |
|
||||
| `reason` | `string` |
|
||||
| `verifierAgentId` | `string?` |
|
||||
| `startedAt` | `string` (ISO 8601) |
|
||||
| `completedAt` | `string` (ISO 8601) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Project Registry
|
||||
## 4. Project Registry
|
||||
|
||||
**Path:** `$PASEO_HOME/projects/projects.json`
|
||||
|
||||
@@ -519,7 +417,7 @@ workspace together with its owning project.
|
||||
|
||||
---
|
||||
|
||||
## 7. Workspace Registry
|
||||
## 5. Workspace Registry
|
||||
|
||||
**Path:** `$PASEO_HOME/projects/workspaces.json`
|
||||
|
||||
@@ -529,7 +427,8 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||
| `cwd` | `string` | Client compatibility projection. Runtime-selected server operations address the workspace by `workspaceId`; the driver owns its execution root. |
|
||||
| `hostVisiblePath` | `string \| null` | Explicit host-path capability for editor and file-manager integrations. Null for Docker and remote placement. |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
@@ -538,13 +437,19 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `runtime` | `{ runtimeId: string } \| undefined` | Immutable selected workspace runtime. Runtime roots, revisions, execution domains, and resource identifiers are never stored in the workspace record. |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Runtime-selected filesystem, Git, terminal, provider, and script operations use it as their server-side address. `cwd` remains on the wire for compatibility and display. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
|
||||
|
||||
For a runtime-selected workspace, archive pauses the runtime and sets `archivedAt`; it does not remove
|
||||
the runtime's files. Restore resumes the same runtime before clearing `archivedAt`. Permanent project
|
||||
removal destroys each selected runtime before removing its workspace record. Records without
|
||||
`runtime` remain on the legacy local/worktree lifecycle path until they are explicitly re-created.
|
||||
|
||||
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
|
||||
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
|
||||
@@ -553,7 +458,7 @@ than treating it as valid.
|
||||
|
||||
---
|
||||
|
||||
## 8. Push Token Store
|
||||
## 6. Push Token Store
|
||||
|
||||
**Path:** `$PASEO_HOME/push-tokens.json`
|
||||
|
||||
@@ -567,7 +472,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
|
||||
|
||||
---
|
||||
|
||||
## 9. Daemon meta files
|
||||
## 7. Daemon meta files
|
||||
|
||||
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
|
||||
|
||||
|
||||
+16
-10
@@ -23,6 +23,10 @@ Desktop dev launches its desktop-managed daemon with `PASEO_NODE_ENV=development
|
||||
so development-only providers such as Mock Load Test are available. Packaged
|
||||
desktop launches always force the daemon to production mode.
|
||||
|
||||
The web and desktop dev launchers pass the current Git branch to Metro as
|
||||
`EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL`. The expanded desktop sidebar shows it in
|
||||
the titlebar row. Production builds leave the variable unset and show no label.
|
||||
|
||||
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
|
||||
|
||||
## Nix desktop package
|
||||
@@ -127,9 +131,11 @@ desktop-only environment inherited by terminals opened inside Paseo from couplin
|
||||
a new worktree instance to the parent desktop instance's profile or single-instance
|
||||
lock.
|
||||
|
||||
Electron remains in the desktop dev runner's process group. Closing or stopping the
|
||||
workspace-script terminal must terminate Electron with Metro; detaching Electron
|
||||
leaves an orphan holding the worktree's single-instance lock and broken output pipes.
|
||||
The desktop workspace script `exec`s the dev runner so the terminal owns the runner
|
||||
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and
|
||||
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or
|
||||
detach Electron: either change leaves an orphan holding the worktree's single-instance
|
||||
lock and broken output pipes.
|
||||
|
||||
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
|
||||
transition, and 751-pixel settings split with:
|
||||
@@ -260,8 +266,8 @@ the daemon-global Git process limits in `$PASEO_HOME/config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Restart the daemon with `paseo daemon restart`. If Paseo Desktop manages the daemon, fully quit and
|
||||
reopen the desktop app. Lower values reduce machine pressure but make Git-backed workspace state and
|
||||
Reload the daemon with `paseo reload`. Environment-variable overrides still require a restart because
|
||||
the launch environment remains authoritative. Lower values reduce machine pressure but make Git-backed workspace state and
|
||||
Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults,
|
||||
semantics, and environment-variable overrides.
|
||||
|
||||
@@ -298,13 +304,13 @@ Worktrees inherit committed Git state only; uncommitted source-checkout changes
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
of commands. Both run sequentially.
|
||||
|
||||
Lifecycle commands run in the worktree through a stable script shell: `bash`
|
||||
For runtime-selected workspaces, lifecycle commands run inside the selected runtime through a stable script shell. Legacy worktrees use `bash`
|
||||
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
|
||||
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
|
||||
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
|
||||
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
|
||||
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||
hook is unset. ACP single-string terminal commands use the same non-login Bash
|
||||
behavior on macOS/Linux, but preserve their existing `cmd.exe /c` string semantics
|
||||
on Windows. Service scripts are separate:
|
||||
they launch in a terminal and receive the service environment described below.
|
||||
|
||||
Because the shell differs per platform, a lifecycle command that must run
|
||||
@@ -456,7 +462,7 @@ install.
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
Canonical automation uses `paseo workspace create/ls/rename/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ The image source lives in [`docker/`](../docker/).
|
||||
|
||||
The official image:
|
||||
|
||||
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
|
||||
- builds `@getpaseo/server`, `@getpaseo/cli`, and their public workspace packages from source-built workspace tarballs
|
||||
- runs the daemon as the non-root `paseo` user
|
||||
- listens on `0.0.0.0:6767` inside the container
|
||||
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
||||
|
||||
@@ -16,4 +16,6 @@ Read aggregate health from the owning observer service. Runtime metrics include
|
||||
|
||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
||||
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
|
||||
|
||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
||||
|
||||
+9
-5
@@ -5,8 +5,10 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Runtime**, which chooses where commands and files live.
|
||||
- **Runtime** — The selected environment where a workspace's files, terminals, Git operations, scripts, provider discovery, and agents run. UI: "Runtime" on New Workspace. Configured runtimes use their configured label. Code: `WorkspaceRuntimeService`; the remembered choice is `FormPreferences.runtimeId`.
|
||||
- **Local** — Built-in runtime that adopts the selected host directory. UI: "Local". It does not mean “the current client device”; it means the daemon host.
|
||||
- **Docker** — Built-in runtime that materializes committed Git content into a runtime-owned Docker volume. UI: "Docker". Host bind mounts may provide configuration outside `/workspace`, but never replace project materialization.
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
@@ -17,21 +19,23 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
|
||||
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Worktree** — Built-in runtime that creates a Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
|
||||
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
|
||||
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Workspace script** — A named command a workspace can run, declared in `paseo.json`. Two types: `service` and `script` (a command that runs and finishes). UI: "Scripts" for the panel and the menu, both of which list either type. Code: `WorkspaceScriptPayload` (`packages/protocol/src/messages.ts:3126`). Don't confuse with **Service**, which is one of the two types rather than a synonym for the pair.
|
||||
- **Service** — A workspace script of type `service`: long-running, may bind a port, and carries `health` of `healthy | unhealthy | null` once the daemon has observed it. The only type a sidebar workspace row reports, because a running plain script says nothing about whether the workspace is up; the row names the service and turns danger when its health fails, and where several run the failing one takes the row's single slot. UI: "Services" in the sidebar's display preferences (**Show → Services**). Code: `selectWorkspaceServiceSummary` (`packages/app/src/components/sidebar/workspace-meta-row/service-summary.ts`), `WorkspaceScriptHealthSchema` (`packages/protocol/src/messages.ts:3124`). Forbidden: "Service" as a label for the Scripts panel, which lists both types.
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent).
|
||||
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
|
||||
+5
-5
@@ -95,11 +95,11 @@ Hub authentication rejection or close code `4403` permanently revokes the local
|
||||
daemon deletes its credential, stops reconnecting, and retains only the relationship ID, Hub origin,
|
||||
scopes, and a sanitized reason for status reporting.
|
||||
|
||||
`paseo hub disconnect` disables socket reconnect before requesting remote revocation. If the Hub is
|
||||
offline, the daemon persists `disconnecting` and retries revocation across daemon restarts without
|
||||
opening a Hub socket. This also covers an enrollment whose request may have succeeded but whose
|
||||
response was lost. `--force` removes local authority immediately and warns that remote revocation may
|
||||
still be pending.
|
||||
`paseo hub disconnect` disables socket reconnect and execution authority before making one bounded
|
||||
remote revocation request. The daemon then removes the local relationship whether the request
|
||||
succeeds or fails. A failed request returns a warning that server-side revocation may remain pending.
|
||||
`--force` skips the remote request. Legacy persisted `disconnecting` records are removed on startup;
|
||||
the daemon does not retry revocation in the background.
|
||||
|
||||
`paseo hub logout` removes only the active human CLI credential and preserves credentials for other origins. Interactive logout inspects and optionally disconnects a same-origin daemon before deleting the login; a failed requested disconnect preserves the login. JSON and noninteractive logout never prompt or disconnect implicitly.
|
||||
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# Local plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one
|
||||
`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client
|
||||
contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it.
|
||||
|
||||
## Install a directory source
|
||||
|
||||
Create a typecheckable plugin project, install its development dependencies, then install it into
|
||||
the daemon. `init` only writes the project files; it does not run the package manager.
|
||||
|
||||
```bash
|
||||
paseo plugin init /absolute/path/to/my-plugin
|
||||
cd /absolute/path/to/my-plugin
|
||||
npm install
|
||||
npm run typecheck
|
||||
paseo plugin install /absolute/path/to/my-plugin
|
||||
paseo plugin install /absolute/path/to/my-plugin --id another-runtime-id
|
||||
paseo plugin ls
|
||||
```
|
||||
|
||||
The daemon stores directory sources under the root `plugins` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"pluginsEnabled": true,
|
||||
"plugins": {
|
||||
"my-plugin": {
|
||||
"source": "directory",
|
||||
"path": "/absolute/path/to/my-plugin",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The plugin system is disabled unless `pluginsEnabled` is `true`.
|
||||
|
||||
The directory contains an identity-only manifest, one entry point, and local typechecking support:
|
||||
|
||||
```text
|
||||
my-plugin/
|
||||
paseo-plugin.json
|
||||
index.tsx
|
||||
paseo-plugin.d.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
Paseo compiles TSX when loading the plugin, so these packages are development dependencies only.
|
||||
The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a
|
||||
public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin"
|
||||
}
|
||||
```
|
||||
|
||||
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
|
||||
`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the
|
||||
runtime does not compare the two IDs. The same directory can be installed under several config
|
||||
keys.
|
||||
|
||||
Source changes are explicit. Run `paseo plugin reload <id>` to stop and fully tear down the old
|
||||
plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore
|
||||
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its
|
||||
configuration, never its source directory. The global `pluginsEnabled` switch remains available.
|
||||
|
||||
## Contribute behavior and UI
|
||||
|
||||
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
|
||||
removes UI registrations from the server bundle and RPC registrations from the client bundle before
|
||||
resolving dependencies.
|
||||
|
||||
```tsx
|
||||
import { Text } from "react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
|
||||
|
||||
const greetRpc = defineRpc({
|
||||
name: "greet",
|
||||
input: z.object({ name: z.string() }),
|
||||
output: z.object({ message: z.string() }),
|
||||
});
|
||||
|
||||
function Greeting() {
|
||||
const greet = useRpc(greetRpc);
|
||||
const query = useQuery({
|
||||
queryKey: ["greeting", "Paseo"],
|
||||
queryFn: () => greet({ name: "Paseo" }),
|
||||
});
|
||||
return <Text>{query.data?.message}</Text>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
|
||||
plugin.addSurface("main", Greeting);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Greeting",
|
||||
icon: "MessageCircle",
|
||||
surface: "main",
|
||||
});
|
||||
return () => undefined;
|
||||
}
|
||||
```
|
||||
|
||||
The contribution function must return cleanup. Paseo invokes it once when the plugin is reloaded,
|
||||
disabled, removed, disconnected, or shut down. Cleanup is for resources created by plugin code.
|
||||
Paseo removes registered contributions, unmounts surfaces, clears query state, rejects pending RPCs,
|
||||
and stops the subprocess. Cleanup errors are logged and do not interrupt host teardown.
|
||||
|
||||
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
|
||||
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
|
||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
||||
Paseo gives each plugin installation its own query client.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Plugin code cannot address another host.
|
||||
|
||||
## Contribute composer attachments
|
||||
|
||||
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
|
||||
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
|
||||
credentials and vendor API calls stay in the daemon handler.
|
||||
|
||||
```tsx
|
||||
const searchIssues = defineRpc({
|
||||
name: "issues.search",
|
||||
input: z.object({ query: z.string() }),
|
||||
output: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
url: z.string().url(),
|
||||
text: z.string(),
|
||||
resourceType: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const issues = defineAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Acme issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach Acme issue",
|
||||
searchPlaceholder: "Search by identifier or title",
|
||||
search: searchIssues,
|
||||
});
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
|
||||
plugin.addAttachmentSource(issues);
|
||||
return () => undefined;
|
||||
}
|
||||
```
|
||||
|
||||
Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources
|
||||
on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral
|
||||
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
||||
drops the optional presentation fields.
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
|
||||
attachment-source example.
|
||||
+1
-1
@@ -12,7 +12,7 @@ The development workflow is shifting from manually editing files to orchestratin
|
||||
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in.
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
|
||||
+14
-3
@@ -85,6 +85,8 @@ Rewind accepts the canonical wire `messageId` and resolves it to the provider id
|
||||
|
||||
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
||||
|
||||
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
@@ -101,17 +103,26 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
|
||||
The daemon keeps provider snapshots per bound workspace, with separate legacy cwd and semantic global scopes. New Workspace first ensures one invisible provider-probe workspace for the selected `(projectId, runtimeId)`, then reads the snapshot through that workspace binding. The probe is created through `WorkspaceRuntimeService`, so availability, models, and modes come from the selected runtime. Probe creation never runs repository setup. Old clients and bare-cwd callers keep the cwd snapshot path; do not replace or remove it.
|
||||
|
||||
Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` after the workspace runtime has bound its private placement. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
|
||||
|
||||
`ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the
|
||||
availability check and covers that check plus the complete catalog probe. Providers that make
|
||||
multiple catalog requests must not apply this deadline separately to each request. The manager
|
||||
aborts the shared refresh signal at the deadline. Providers name active catalog operations and
|
||||
finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations
|
||||
that were still active when the deadline expired.
|
||||
|
||||
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
|
||||
|
||||
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
|
||||
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and workspace identity, or per cwd for the legacy path, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
|
||||
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
|
||||
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
|
||||
Boundary tests should assert observable behavior: New Workspace never falls back to a host cwd after selecting a runtime; cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one workspace; settings refresh wipes all scopes but immediately refreshes only global.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ Evidence is something someone else can look at:
|
||||
- A video of the interaction
|
||||
- Logs, requests, responses
|
||||
|
||||
Workspace-runtime changes include the selected Local/Worktree parity cases, the shared common-Git watcher assertion, the fixture Desktop journey, and the blocking Docker matrix when Docker behavior changes. Provider-probe changes also include lifecycle/restart evidence and proof that probe records, processes, watchers, containers, and volumes do not leak.
|
||||
|
||||
Redact what you need to, keep the technical details. If an agent did the work, submit its raw output. A summary drops the details someone else needs to check it.
|
||||
|
||||
## Does it work well
|
||||
|
||||
@@ -19,23 +19,22 @@ The first carves shipped as **deep modules with a narrow Host seam**, not the `d
|
||||
|
||||
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
|
||||
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
|
||||
- **ScheduleSession** (`session/schedule/schedule-session.ts`, seam `ScheduleSessionHost`): scheduled-agent request handling.
|
||||
|
||||
**Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split.
|
||||
|
||||
### Why this is safe at the dispatch seam (verified)
|
||||
|
||||
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
|
||||
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
|
||||
|
||||
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
|
||||
|
||||
## Slice ordering (least-coupled first)
|
||||
|
||||
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
|
||||
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The safer first cut is **provider-catalog** (one shared collaborator + injected predicates).
|
||||
|
||||
| # | Slice | Effort | Risk |
|
||||
| --- | ----------------------------------------------------------------------------- | ------ | ------ |
|
||||
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
|
||||
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
|
||||
| 2 | ProviderCatalogController | M | medium |
|
||||
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
|
||||
| 4 | GitCheckoutController | L | medium |
|
||||
@@ -47,34 +46,6 @@ The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verific
|
||||
|
||||
---
|
||||
|
||||
## Slice 0 — Test net + disjointness tripwire (prerequisite)
|
||||
|
||||
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
|
||||
|
||||
Write RED-then-GREEN against the **current in-place** Session:
|
||||
|
||||
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
|
||||
- one `schedule/*` and one `loop/*` round-trip.
|
||||
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
|
||||
- a handler that **rejects async** emits the SAME pair.
|
||||
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
|
||||
|
||||
**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
|
||||
|
||||
**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError` — **kept separate, not merged**), `toScheduleSummary` → `packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
|
||||
|
||||
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
|
||||
|
||||
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
|
||||
|
||||
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 2 — ProviderCatalogController
|
||||
|
||||
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead` → `packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
|
||||
|
||||
+28
-16
@@ -464,26 +464,37 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
|
||||
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
|
||||
## Changelog voice
|
||||
## Changelog wording
|
||||
|
||||
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
|
||||
The changelog is shown on the Paseo homepage. Each bullet is a compact factual record of
|
||||
product behavior that changed.
|
||||
|
||||
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
|
||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.
|
||||
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
|
||||
- **Name the exact change.** Prefer `Added <capability>`, `Removed <behavior>`,
|
||||
`Changed <behavior>`, or `Fixed <failure> when <condition>`.
|
||||
- **Keep the scope exact.** A conditional bug is not a general reliability problem. Do not
|
||||
broaden one failure into claims that Paseo is now faster, smoother, responsive, or reliable.
|
||||
- **Use concrete product and runtime terms.** Git polling, persisted cache, provider catalog,
|
||||
and WebSocket reconnects can identify the affected behavior. Component names, internal
|
||||
modules, code symbols, and implementation techniques cannot: omit `WorkingIndicator`,
|
||||
`reconcileAndEmitWorkspaceUpdates`, remounts, memoization, and controlled inputs.
|
||||
- **State the consequence only when the change itself is unclear.** Keep the condition that
|
||||
makes the consequence true. Do not replace a precise change with a broad benefit claim.
|
||||
- **Do not invent context.** Mention an upgrade, platform, workload, or user action only when
|
||||
the PR or linked issue establishes that scope.
|
||||
|
||||
| Wrong (implementation-facing) | Right (user-facing) |
|
||||
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position |
|
||||
| Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward |
|
||||
| Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers |
|
||||
| Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss |
|
||||
| Reduced re-renders in the agent list | Agent list scrolls smoothly |
|
||||
| Added debouncing to the search input | Search results no longer lag behind typing |
|
||||
| Avoid | Write |
|
||||
| ------------------------------------------------------------ | ----------------------------------------------------- |
|
||||
| Paseo stays responsive with many idle Git workspaces | Removed periodic Git polling for idle workspaces |
|
||||
| Incompatible saved app data no longer crashes after upgrades | Fixed crash when persisted cache was incompatible |
|
||||
| Splitting layouts no longer remounts the active agent | Fixed scroll position resetting when splitting a pane |
|
||||
| Mobile model selector is faster and more straightforward | Added search to the mobile model selector |
|
||||
|
||||
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
|
||||
Test each bullet against the source PR and issue: can a reviewer point to the exact behavior
|
||||
that changed, the failure that was fixed, or the capability that was added? If the bullet only
|
||||
claims a general improvement, rewrite it with the concrete change.
|
||||
|
||||
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
|
||||
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
|
||||
@@ -498,8 +509,7 @@ Every bullet must be scannable at a glance. The changelog is not release documen
|
||||
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
|
||||
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
|
||||
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
|
||||
- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
|
||||
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
|
||||
- **Stop after identifying the change.** Do not explain LAN/WAN topology, TLS handshakes, IPC, or other architecture in a changelog bullet. Put necessary background in product docs.
|
||||
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
|
||||
|
||||
## Changelog attribution
|
||||
@@ -567,6 +577,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
### Beta release
|
||||
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
@@ -587,6 +598,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
|
||||
|
||||
+15
-9
@@ -85,16 +85,22 @@ recomposition while the runtime still owns the same directory snapshot and timel
|
||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||
session and host-scoped setup state together.
|
||||
|
||||
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
|
||||
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
|
||||
older-history availability, authority status, or sync generation because those facts would describe
|
||||
the complete source dataset rather than the truncated display dataset.
|
||||
The durable replica cache persists synchronization authority only when it can store the complete
|
||||
current canonical window losslessly. The stored range describes those exact items: `startSeq` drives
|
||||
older pagination and `endSeq` drives forward catch-up. Restore paints the items immediately, requests
|
||||
`after endSeq`, and requests `before startSeq` when the user loads older history.
|
||||
|
||||
Restoring that cache produces a painted timeline: the items may render immediately, but the first
|
||||
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
|
||||
items, range, and older-history availability. Live rows received between cache paint and that tail
|
||||
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
|
||||
reconciled with the authoritative tail and subsequent catch-up.
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
||||
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
||||
|
||||
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
||||
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
||||
range and keeps a display-only tail. Restore then uses the ordinary bounded `tail` bootstrap. Never
|
||||
slice items while retaining the pre-slice range; that falsely certifies discarded source rows.
|
||||
|
||||
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
|
||||
the authoritative range through the existing forward-page path. The cache does not persist sync
|
||||
generation or unreconciled local submissions.
|
||||
|
||||
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
|
||||
replacement keeps only positioned rows newer than the page it installs, while unresolved local
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-tB0WZG6F6VFMS5zFqo73giSAn7SqfkJISF5fMjDioJQ=
|
||||
sha256-YJpDgaoaBS1lCJIVhxBIfeNelyYRsJMcN/HgEO4KehM=
|
||||
|
||||
Generated
+1681
-398
File diff suppressed because it is too large
Load Diff
+21
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -23,6 +23,7 @@
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
"packages/protocol",
|
||||
"packages/client",
|
||||
"packages/server",
|
||||
@@ -30,7 +31,10 @@
|
||||
"packages/relay",
|
||||
"packages/website",
|
||||
"packages/desktop",
|
||||
"packages/cli"
|
||||
"packages/cli",
|
||||
"packages/workspace-runtime-contract",
|
||||
"packages/workspace-helper",
|
||||
"runtimes/fixture"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev:server",
|
||||
@@ -46,19 +50,24 @@
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
|
||||
"build:plugin": "npm run build --workspace=@paseo/plugin",
|
||||
"build:plugin:clean": "npm run build:clean --workspace=@paseo/plugin",
|
||||
"build:relay": "npm run build --workspace=@getpaseo/relay",
|
||||
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build --workspace=@getpaseo/client",
|
||||
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
|
||||
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:workspace-runtime-contract": "npm run build --workspace=@getpaseo/workspace-runtime-contract",
|
||||
"build:workspace-helper": "npm run build --workspace=@getpaseo/workspace-helper",
|
||||
"build:workspace-runtime-fixture": "npm run build --workspace=@getpaseo/fixture-workspace-runtime",
|
||||
"build:server-deps": "npm run build:workspace-runtime-contract && npm run build:workspace-helper && concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"",
|
||||
"build:server-deps:clean": "npm run build:clean --workspace=@getpaseo/workspace-runtime-contract && npm run build:clean --workspace=@getpaseo/workspace-helper && npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
|
||||
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:plugin && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
@@ -69,6 +78,7 @@
|
||||
"format:check": "oxfmt --check .",
|
||||
"format:check:files": "oxfmt --check",
|
||||
"lint": "oxlint",
|
||||
"check:workspace-runtime-boundaries": "node scripts/check-workspace-runtime-boundaries.mjs",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"knip": "knip",
|
||||
"acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs",
|
||||
@@ -99,11 +109,11 @@
|
||||
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
|
||||
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/workspace-runtime-contract && npm run typecheck --workspace=@getpaseo/workspace-helper && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/workspace-runtime-contract && npm pack --dry-run --workspace=@getpaseo/workspace-helper && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --workspace=@getpaseo/workspace-helper --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push",
|
||||
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const withPasteInput = require("./plugins/with-paste-input");
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
@@ -138,6 +140,8 @@ export default {
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...buildProfile.cameraPlugins,
|
||||
[
|
||||
"expo-splash-screen",
|
||||
|
||||
@@ -809,6 +809,7 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
|
||||
);
|
||||
});
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
@@ -816,14 +817,20 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
|
||||
const prompt = "Delay synthetic user message by 300ms.";
|
||||
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||
await recordTurnFrames(page, prompt);
|
||||
await submitMessage(page, prompt);
|
||||
|
||||
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
|
||||
await expect(submittedRow).toBeVisible();
|
||||
await gate.waitForServerMessage("fetch_agent_timeline_response");
|
||||
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
|
||||
gate.truncateHeldTimelineAfterLast("user_message");
|
||||
gate.setServerMessageSuppressed("fetch_agent_timeline_response", true);
|
||||
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
|
||||
await recordPaintsFor(page, 80);
|
||||
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
|
||||
gate.setServerMessageSuppressed("fetch_agent_timeline_response", false);
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { selectModel } from "../support/helpers/app";
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
import {
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
@@ -336,6 +342,64 @@ async function expectInterruptedTurnOrderAfterReconnect(
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const target = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`,
|
||||
title: "Hidden streaming submission",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const evictionAgents = await Promise.all(
|
||||
Array.from({ length: 3 }, (_unused, index) =>
|
||||
seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
|
||||
title: `Workspace eviction ${index + 1}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const prompt = "Keep this hidden image prompt before its streaming output.";
|
||||
const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
const userMessageCount = gate.getAgentStreamItemCount("user_message");
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
const promptRow = await submitMessageWithImage(page, prompt);
|
||||
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
|
||||
|
||||
for (const evictionAgent of evictionAgents) {
|
||||
await openAgentRoute(page, evictionAgent);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
await expect(targetDeckEntry).toHaveCount(0);
|
||||
await subscriptions.waitForSubscribedAgents([evictionAgents[2]!.agentId]);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
|
||||
await target.client.waitForFinish(target.agentId, 30_000);
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
|
||||
await expect(promptRow).toBeVisible();
|
||||
await expect(response).toBeVisible();
|
||||
await expectRenderedBefore(promptRow, response);
|
||||
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
|
||||
} finally {
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
gate.restore();
|
||||
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
@@ -1013,6 +1077,13 @@ test.describe("Agent message submission", () => {
|
||||
await expectInterruptedTurnOrderAfterReconnect(page, testInfo);
|
||||
});
|
||||
|
||||
test("keeps a streaming hidden submission before its output after workspace eviction", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(page, testInfo);
|
||||
});
|
||||
|
||||
test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import path from "node:path";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import {
|
||||
applyProfileFromPicker,
|
||||
drillIntoProvider,
|
||||
openModelPicker,
|
||||
seedAgentProfiles,
|
||||
seedModelProvider,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
const MODELESS_PROVIDER = "modeless-profile-e2e";
|
||||
const MODELESS_MODEL = "pi-profile-model";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreateAgentRequestMessage {
|
||||
type: "create_agent_request";
|
||||
config?: {
|
||||
provider?: unknown;
|
||||
modeId?: unknown;
|
||||
featureValues?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
|
||||
waitForRequest(): Promise<CreateAgentRequestMessage>;
|
||||
}> {
|
||||
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
|
||||
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
|
||||
return { waitForRequest: () => request };
|
||||
}
|
||||
|
||||
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, provider, model }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
provider,
|
||||
providerPreferences: {
|
||||
[provider]: { model, mode: "full-access" },
|
||||
mock: { model: "ten-second-stream", mode: "load-test" },
|
||||
},
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{
|
||||
preferencesKey: CREATE_AGENT_PREFERENCES_KEY,
|
||||
provider: MODELESS_PROVIDER,
|
||||
model: MODELESS_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function readProviderModePreference(page: Page, provider: string): Promise<unknown> {
|
||||
return page.evaluate(
|
||||
({ preferencesKey, providerId }) => {
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
if (!raw) return null;
|
||||
const preferences = JSON.parse(raw) as FormPreferences;
|
||||
return preferences.providerPreferences?.[providerId]?.mode ?? null;
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("applying a profile heals a stale mode before returning to Pi", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "profile-modeless-preferences-" });
|
||||
const profiles = await seedAgentProfiles([
|
||||
{
|
||||
id: "agent_profile_modeless_repair",
|
||||
name: "Approval work",
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
modeId: "approval-test",
|
||||
},
|
||||
]);
|
||||
const provider = await seedModelProvider({
|
||||
id: MODELESS_PROVIDER,
|
||||
label: "Pi profile test",
|
||||
extends: "pi",
|
||||
command: [process.execPath, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")],
|
||||
models: [
|
||||
{
|
||||
id: MODELESS_MODEL,
|
||||
label: "Pi profile model",
|
||||
description: "Modeless profile regression model",
|
||||
},
|
||||
],
|
||||
});
|
||||
await seedPoisonedModelessPreference(page);
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await openModelPicker(page);
|
||||
await applyProfileFromPicker(page, "Approval work");
|
||||
|
||||
await expect
|
||||
.poll(() => readProviderModePreference(page, MODELESS_PROVIDER), { timeout: 10_000 })
|
||||
.toBeNull();
|
||||
await expect
|
||||
.poll(() => readProviderModePreference(page, "mock"), { timeout: 10_000 })
|
||||
.toBe("approval-test");
|
||||
|
||||
await openModelPicker(page);
|
||||
await drillIntoProvider(page, MODELESS_PROVIDER);
|
||||
await page.getByRole("button", { name: /Pi profile model/ }).click();
|
||||
|
||||
await submitNewWorkspacePrompt(page, "Create a Pi agent after repairing preferences.");
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
expect(createAgentRequest).toMatchObject({
|
||||
config: { provider: MODELESS_PROVIDER },
|
||||
});
|
||||
expect(createAgentRequest.config).not.toHaveProperty("modeId");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await provider.restore();
|
||||
await profiles.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("cross-provider profile features survive immediate workspace creation", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "profile-feature-transition-" });
|
||||
const featureProviderId = "profile-feature-e2e";
|
||||
const featureModelId = "claude-opus-5";
|
||||
const profiles = await seedAgentProfiles([
|
||||
{
|
||||
id: "agent_profile_feature_transition",
|
||||
name: "Fast profile",
|
||||
provider: featureProviderId,
|
||||
model: featureModelId,
|
||||
modeId: "bypassPermissions",
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
]);
|
||||
const provider = await seedModelProvider({
|
||||
id: featureProviderId,
|
||||
label: "Profile feature test",
|
||||
models: [
|
||||
{
|
||||
id: featureModelId,
|
||||
label: "Profile feature model",
|
||||
description: "Cross-provider feature regression model",
|
||||
},
|
||||
],
|
||||
});
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await openModelPicker(page);
|
||||
await applyProfileFromPicker(page, "Fast profile");
|
||||
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
|
||||
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
expect(createAgentRequest).toMatchObject({
|
||||
config: {
|
||||
provider: featureProviderId,
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await provider.restore();
|
||||
await profiles.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
applyProfileFromPicker,
|
||||
closeModelPicker,
|
||||
expectComposerDoesNotName,
|
||||
expectAgentProfilesEmptyPrompt,
|
||||
expectProfileEditTooltip,
|
||||
expectComposerMode,
|
||||
expectComposerModel,
|
||||
expectModelRowSelected,
|
||||
expectProfileEditIsPencilOnly,
|
||||
expectProfileVisibleForProvider,
|
||||
openModelPicker,
|
||||
openAgentProfilesFromEmptyPrompt,
|
||||
seedAgentProfiles,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const PROFILE = {
|
||||
id: "agent_profile_e2e_ui_work",
|
||||
name: "UI work",
|
||||
icon: "🎨",
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
modeId: "approval-test",
|
||||
notes: "Use for UI work.",
|
||||
};
|
||||
|
||||
const PROFILE_SUMMARY = "Mock Load Test · One minute stream · Approval test";
|
||||
|
||||
test.describe("Agent profiles in the model picker", () => {
|
||||
test("an empty host still exposes agent profile settings from the picker", async ({ page }) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-empty-",
|
||||
title: "Agent profiles empty",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expectAgentProfilesEmptyPrompt(page);
|
||||
await openAgentProfilesFromEmptyPrompt(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("applying a pinned profile materializes it into the composer and is then forgotten", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seed = await seedAgentProfiles([PROFILE]);
|
||||
// A live agent is one provider's process, so the profile has to name that
|
||||
// same provider or the picker will not offer it at all.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-picker-",
|
||||
title: "Agent profiles picker",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
|
||||
try {
|
||||
await test.step("the agent starts on its seeded model and mode", async () => {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await expectComposerMode(page, "Load test");
|
||||
});
|
||||
|
||||
await test.step("the sole provider opens directly", async () => {
|
||||
await openModelPicker(page);
|
||||
await expect(page.getByTestId("model-search-input").first()).toBeVisible();
|
||||
await expect(page.getByTestId("sheet-header-back")).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="model-provider-"]')).toHaveCount(0);
|
||||
await expectProfileVisibleForProvider(page, {
|
||||
name: PROFILE.name,
|
||||
summary: PROFILE_SUMMARY,
|
||||
});
|
||||
await expectProfileEditIsPencilOnly(page);
|
||||
await expectProfileEditTooltip(page);
|
||||
});
|
||||
|
||||
await test.step("applying it writes its model and mode into the composer", async () => {
|
||||
await applyProfileFromPicker(page, PROFILE.name);
|
||||
await expectComposerModel(page, "One minute stream");
|
||||
await expectComposerMode(page, "Approval test");
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
modeId: "approval-test",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("the composer names the model, never the profile", async () => {
|
||||
await expectComposerDoesNotName(page, PROFILE.name);
|
||||
});
|
||||
|
||||
await test.step("reopening returns directly to the provider models", async () => {
|
||||
await openModelPicker(page);
|
||||
await expect(page.getByTestId("model-search-input").first()).toBeVisible();
|
||||
await expectProfileVisibleForProvider(page, {
|
||||
name: PROFILE.name,
|
||||
summary: PROFILE_SUMMARY,
|
||||
});
|
||||
await expectModelRowSelected(page, { provider: "mock", modelId: "one-minute-stream" });
|
||||
await closeModelPicker(page);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
createAgentProfile,
|
||||
createAgentProfileFromEmptyState,
|
||||
editAgentProfile,
|
||||
expectAgentProfile,
|
||||
expectAgentProfileForm,
|
||||
expectAgentProfileOrder,
|
||||
expectAgentProfileTagsGone,
|
||||
expectHostAgentProfiles,
|
||||
expectNoAgentProfiles,
|
||||
moveAgentProfileUp,
|
||||
openAgentProfileSettings,
|
||||
removeAgentProfile,
|
||||
seedAgentProfiles,
|
||||
stageLegacyFavoritesForHostMigration,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
|
||||
const MOCK_PROVIDER_LABEL = "Mock Load Test";
|
||||
|
||||
test.describe("Agent profiles settings", () => {
|
||||
test("legacy model favourites migrate into provider-and-model-only host profiles", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const migratedProfile = {
|
||||
id: "legacy_favorite:mock:one-minute-stream",
|
||||
name: "One minute stream",
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
};
|
||||
|
||||
try {
|
||||
await stageLegacyFavoritesForHostMigration(page, [
|
||||
{ provider: "mock", modelId: "one-minute-stream" },
|
||||
]);
|
||||
|
||||
await expectHostAgentProfiles([migratedProfile]);
|
||||
await openAgentProfileSettings(page);
|
||||
await expectAgentProfile(page, {
|
||||
name: migratedProfile.name,
|
||||
tags: [MOCK_PROVIDER_LABEL, "One minute stream"],
|
||||
});
|
||||
} finally {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("host owner creates, edits, reorders and removes agent profiles", async ({ page }) => {
|
||||
// The journey owns the list, so it starts from an empty one whatever the
|
||||
// worker ran before, and hands it back untouched.
|
||||
const seed = await seedAgentProfiles([]);
|
||||
|
||||
try {
|
||||
await test.step("the section starts empty", async () => {
|
||||
await openAgentProfileSettings(page);
|
||||
await expectNoAgentProfiles(page);
|
||||
});
|
||||
|
||||
await test.step("create a profile from the modal", async () => {
|
||||
await createAgentProfileFromEmptyState(page, {
|
||||
name: "UI work",
|
||||
provider: MOCK_PROVIDER_LABEL,
|
||||
model: "Ten second stream",
|
||||
thinking: "High",
|
||||
mode: "Approval test",
|
||||
notes: "Use for UI work — components, layout and design tokens.",
|
||||
});
|
||||
await expectAgentProfile(page, {
|
||||
name: "UI work",
|
||||
tags: [MOCK_PROVIDER_LABEL, "Ten second stream", "Approval test", "High"],
|
||||
notes: "Use for UI work — components, layout and design tokens.",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("editing to a model without thinking levels drops the level", async () => {
|
||||
await editAgentProfile(page, "UI work", {
|
||||
model: "One minute stream",
|
||||
notes: "Now for quick checks.",
|
||||
});
|
||||
await expectAgentProfile(page, {
|
||||
name: "UI work",
|
||||
tags: [MOCK_PROVIDER_LABEL, "One minute stream", "Approval test"],
|
||||
notes: "Now for quick checks.",
|
||||
});
|
||||
await expectAgentProfileTagsGone(page, {
|
||||
name: "UI work",
|
||||
tags: ["Ten second stream", "High"],
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("the edit form reopens on what was stored", async () => {
|
||||
await expectAgentProfileForm(page, "UI work", {
|
||||
provider: MOCK_PROVIDER_LABEL,
|
||||
model: "One minute stream",
|
||||
mode: "Approval test",
|
||||
notes: "Now for quick checks.",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("a second profile lands below the first", async () => {
|
||||
await createAgentProfile(page, {
|
||||
name: "Deep review",
|
||||
provider: MOCK_PROVIDER_LABEL,
|
||||
model: "Five minute stream",
|
||||
});
|
||||
await expectAgentProfileOrder(page, ["UI work", "Deep review"]);
|
||||
});
|
||||
|
||||
await test.step("reorder puts the second profile on top", async () => {
|
||||
await moveAgentProfileUp(page, "Deep review");
|
||||
await expectAgentProfileOrder(page, ["Deep review", "UI work"]);
|
||||
});
|
||||
|
||||
await test.step("removing a profile confirms by name", async () => {
|
||||
await removeAgentProfile(page, "UI work");
|
||||
await expectAgentProfileOrder(page, ["Deep review"]);
|
||||
});
|
||||
|
||||
await test.step("removing the last profile returns the empty state", async () => {
|
||||
await removeAgentProfile(page, "Deep review");
|
||||
await expectNoAgentProfiles(page);
|
||||
});
|
||||
} finally {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ test("does not repeat an assistant block when the current app paginates a publis
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences(serverId) },
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
|
||||
const history = await holdOlderHistoryPages(page, agent, daemon.port);
|
||||
|
||||
@@ -26,9 +26,41 @@ import {
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
waitForPersistedCanonicalTimelineRange,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test.describe("Agent timeline pagination", () => {
|
||||
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
|
||||
const prompt = "timeline persisted range resumes without tail replay";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-persisted-range-",
|
||||
title: "Persisted timeline range",
|
||||
initialPrompt: prompt,
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
|
||||
|
||||
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
|
||||
const request = await tracker.nextRequest();
|
||||
expect(request).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: range.epoch, seq: range.endSeq },
|
||||
});
|
||||
await tracker.waitForResponse();
|
||||
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
import {
|
||||
commitTimelineTurnsWhileDisconnected,
|
||||
disconnectViewedTimeline,
|
||||
expectOneTailCheckWithoutCatchUpPages,
|
||||
expectOneResumeCheckWithoutTail,
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
restoreViewedTimelineWithHeldTail,
|
||||
restoreViewedTimelineWithHeldResponse,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
|
||||
test.describe("Agent timeline resume", () => {
|
||||
@@ -29,11 +30,11 @@ test.describe("Agent timeline resume", () => {
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
const background = await commitTimelineTurnsWhileDisconnected(agent, 24);
|
||||
await restoreViewedTimelineWithHeldTail(page, gate);
|
||||
await restoreViewedTimelineWithHeldResponse(page, gate);
|
||||
|
||||
await expectTimelinePromptVisible(page, background.newestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, background.firstPrompt);
|
||||
expectOneTailCheckWithoutCatchUpPages(gate, requests);
|
||||
expectResumeOverflowFallsBackToOneTail(gate, requests);
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt);
|
||||
} finally {
|
||||
gate.restore();
|
||||
@@ -53,10 +54,10 @@ test.describe("Agent timeline resume", () => {
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
await restoreViewedTimelineWithHeldTail(page, gate);
|
||||
await restoreViewedTimelineWithHeldResponse(page, gate);
|
||||
|
||||
await expectTimelinePresentationUnchanged(page, presentation);
|
||||
expectOneTailCheckWithoutCatchUpPages(gate, requests);
|
||||
expectOneResumeCheckWithoutTail(gate, requests);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
test("shows Pure black in the appearance picker", async ({ page }, testInfo) => {
|
||||
@@ -15,6 +18,33 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the selected workspace visible in Pure black", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "pure-black-selected-workspace-",
|
||||
title: "Selected workspace",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(22, 22, 22)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("pure-black-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("applies the interface font size to settings text", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 }));
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
archiveFinishedSubagents,
|
||||
expectArchiveFinishedInProgress,
|
||||
expectArchiveFinishedRetry,
|
||||
expectManagedSubagentArchived,
|
||||
expectManagedSubagentUnarchived,
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
holdManagedSubagentArchiveRequest,
|
||||
openSubagentsTrack,
|
||||
rejectNextManagedSubagentArchiveRequest,
|
||||
seedParentWithSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
|
||||
test.describe("Archive finished subagents", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "archive-finished-subagents-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("archives a finished managed child from the track", async ({ page }) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Archive parent",
|
||||
childTitle: "Finished child",
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agents.child.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
);
|
||||
|
||||
const archiveGate = await holdManagedSubagentArchiveRequest(page, agents.child.id);
|
||||
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
await archiveFinishedSubagents(page);
|
||||
await archiveGate.waitForRequest();
|
||||
|
||||
await expectSubagentRowGone(page, agents.child.id);
|
||||
await expectArchiveFinishedInProgress(page, 0, 1);
|
||||
await expectManagedSubagentUnarchived(workspace, agents.child.id);
|
||||
archiveGate.release();
|
||||
await expectManagedSubagentArchived(workspace, agents.child.id);
|
||||
});
|
||||
|
||||
test("restores a failed child and retries the archive", async ({ page }) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Retry parent",
|
||||
childTitle: "Retry child",
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agents.child.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
);
|
||||
|
||||
const rejection = await rejectNextManagedSubagentArchiveRequest(page, agents.child.id);
|
||||
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
await archiveFinishedSubagents(page);
|
||||
await rejection.waitForRejection();
|
||||
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
await expectArchiveFinishedRetry(page, 1, 1);
|
||||
await archiveFinishedSubagents(page);
|
||||
|
||||
await expectSubagentRowGone(page, agents.child.id);
|
||||
await expectManagedSubagentArchived(workspace, agents.child.id);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { unlink, writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
@@ -15,6 +17,9 @@ interface DirtyWorkspace {
|
||||
|
||||
interface WorkspaceFixtureOptions {
|
||||
includeDeletedFile?: boolean;
|
||||
includeNestedFolders?: boolean;
|
||||
includeRenamedFile?: boolean;
|
||||
includeUntrackedFile?: boolean;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
@@ -23,6 +28,39 @@ interface CleanupTask {
|
||||
|
||||
const cleanupTasks: CleanupTask[] = [];
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function failNextDiscardRequest(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { type?: string; cwd?: string; requestId?: string };
|
||||
};
|
||||
if (envelope.message?.type === "checkout.discard_changes.request") {
|
||||
browserSocket.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "checkout.discard_changes.response",
|
||||
payload: {
|
||||
cwd: envelope.message.cwd,
|
||||
success: false,
|
||||
error: { code: "UNKNOWN", message: "Injected revert failure" },
|
||||
requestId: envelope.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
serverSocket.send(message);
|
||||
});
|
||||
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||
});
|
||||
}
|
||||
|
||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||
|
||||
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -209,9 +247,15 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
|
||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -230,6 +274,183 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||
await expect(page.getByText("root.ts", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||
await expect(page.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes context menus expose folder revert and restore a file after confirmation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
||||
await expect(folderRevert).toBeVisible();
|
||||
const revertLabelColor = await folderRevert
|
||||
.getByText("Discard changes", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.dismiss();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts");
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const confirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
expect(await confirmation).toContain("src/use-mounted-tab-set.ts");
|
||||
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(BEFORE);
|
||||
});
|
||||
|
||||
test("discarding a staged rename restores its source path", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const renamedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-renamed.ts" });
|
||||
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||
await renamedToggle.click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByTestId(`${rowTestId}-revert`).click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("zz-renamed.ts", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/rename-source.ts"), "utf8"))
|
||||
.toBe("export const renamed = true;\n");
|
||||
});
|
||||
|
||||
test("discarding an untracked file removes it from the working tree", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const untrackedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-untracked.txt" });
|
||||
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||
await untrackedToggle.click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByTestId(`${rowTestId}-revert`).click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("zz-untracked.txt", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
readFile(path.join(workspace.repoPath, "zz-untracked.txt"), "utf8"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("shows a revert error returned by the daemon", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await failNextDiscardRequest(page);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("Injected revert failure", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -327,8 +548,48 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
|
||||
"user-select",
|
||||
"none",
|
||||
);
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
|
||||
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
|
||||
const folderToggleBounds = await page.getByTestId("diff-folder-src-toggle").boundingBox();
|
||||
const folderChevronBounds = await page
|
||||
.getByTestId("diff-folder-src-toggle")
|
||||
.locator("svg")
|
||||
.boundingBox();
|
||||
expect(folderToggleBounds).not.toBeNull();
|
||||
expect(folderChevronBounds).not.toBeNull();
|
||||
expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo(
|
||||
folderToggleBounds!.y + folderToggleBounds!.height / 2,
|
||||
0,
|
||||
);
|
||||
const folderLabelBounds = await page
|
||||
.getByTestId("diff-folder-src")
|
||||
.getByText("src", { exact: true })
|
||||
.boundingBox();
|
||||
const fileLabelBounds = await page
|
||||
.getByTestId("diff-file-0")
|
||||
.getByText("use-mounted-tab-set.ts", { exact: true })
|
||||
.boundingBox();
|
||||
expect(folderLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(16, 0);
|
||||
|
||||
const folderToggle = page.getByTestId("diff-folder-src-toggle");
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
|
||||
const fileToggle = page.getByTestId("diff-file-0-toggle");
|
||||
await fileToggle.click({ button: "right" });
|
||||
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
|
||||
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -488,6 +749,15 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
if (options.includeDeletedFile) {
|
||||
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||
}
|
||||
if (options.includeRenamedFile) {
|
||||
files.push({ path: "src/rename-source.ts", content: "export const renamed = true;\n" });
|
||||
}
|
||||
if (options.includeNestedFolders) {
|
||||
files.push(
|
||||
{ path: "src/zz-folder/root.ts", content: "export const root = 1;\n" },
|
||||
{ path: "src/zz-folder/nested/changed.ts", content: "export const nested = 1;\n" },
|
||||
);
|
||||
}
|
||||
const repo = await createTempGitRepo("changes-pane-", { files });
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
@@ -498,9 +768,24 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
});
|
||||
|
||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
if (options.includeUntrackedFile) {
|
||||
await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n");
|
||||
}
|
||||
if (options.includeDeletedFile) {
|
||||
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||
}
|
||||
if (options.includeRenamedFile) {
|
||||
execFileSync("git", ["mv", "src/rename-source.ts", "src/zz-renamed.ts"], {
|
||||
cwd: repo.path,
|
||||
});
|
||||
}
|
||||
if (options.includeNestedFolders) {
|
||||
await writeFile(path.join(repo.path, "src/zz-folder/root.ts"), "export const root = 2;\n");
|
||||
await writeFile(
|
||||
path.join(repo.path, "src/zz-folder/nested/changed.ts"),
|
||||
"export const nested = 2;\n",
|
||||
);
|
||||
}
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
applyCommandCenterAgentControls,
|
||||
@@ -12,17 +13,15 @@ import {
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId }) => {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId,
|
||||
provider: "mock",
|
||||
providerPreferences: {
|
||||
mock: {
|
||||
@@ -30,10 +29,10 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page):
|
||||
mode: "load-test",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() },
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const GROUP_BY_STATUS = "Group by status";
|
||||
const GROUP_BY_PROJECT = "Group by project";
|
||||
|
||||
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
|
||||
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("group");
|
||||
|
||||
const entry = panel.getByText(label, { exact: true });
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
// The entry always names the mode you are not in, so its opposite must never be listed.
|
||||
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
|
||||
|
||||
await entry.click();
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Command center sidebar grouping", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const statusList = page.getByTestId("sidebar-status-list-scroll");
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Query-only: the grouping entry stays out of the default empty-query list.
|
||||
const panel = await openCommandCenter(page);
|
||||
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).not.toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectList).toHaveCount(0);
|
||||
|
||||
// The only assertion that catches a flip which never reached persisted storage.
|
||||
await page.reload();
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Grouping persists, so the run must leave the sidebar back in project mode.
|
||||
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(statusList).toHaveCount(0);
|
||||
} finally {
|
||||
await seeded.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
|
||||
const MINIMAL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
@@ -294,6 +295,28 @@ test.describe("Composer attachments", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Escape cancels file creation without interrupting the running agent", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "file-create-escape-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stay running while a file draft is cancelled.",
|
||||
});
|
||||
try {
|
||||
await openFileExplorer(page);
|
||||
await page.getByTestId("files-new-file").click();
|
||||
const nameInput = page.getByTestId("file-explorer-name-input");
|
||||
await expect(nameInput).toBeVisible();
|
||||
|
||||
await nameInput.press("Escape");
|
||||
|
||||
await expect(nameInput).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
async function failNextDeleteRequest(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { type?: string; cwd?: string; path?: string; requestId?: string };
|
||||
};
|
||||
if (envelope.message?.type === "fs.entry.delete.request") {
|
||||
browserSocket.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "fs.entry.delete.response",
|
||||
payload: {
|
||||
cwd: envelope.message.cwd,
|
||||
path: envelope.message.path,
|
||||
success: false,
|
||||
error: "Injected delete failure",
|
||||
requestId: envelope.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
serverSocket.send(message);
|
||||
});
|
||||
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||
});
|
||||
}
|
||||
|
||||
async function hideContextActionCapabilities(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (typeof message !== "string") {
|
||||
browserSocket.send(message);
|
||||
return;
|
||||
}
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: {
|
||||
type?: string;
|
||||
payload?: { status?: string; features?: Record<string, unknown> };
|
||||
};
|
||||
};
|
||||
if (
|
||||
envelope.message?.type === "status" &&
|
||||
envelope.message.payload?.status === "server_info"
|
||||
) {
|
||||
envelope.message.payload.features = {
|
||||
...envelope.message.payload.features,
|
||||
fsEntryOps: false,
|
||||
fsEntryDuplicate: false,
|
||||
checkoutDiscardChanges: false,
|
||||
};
|
||||
}
|
||||
browserSocket.send(JSON.stringify(envelope));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function disconnectNextContextAction(page: Page): Promise<{ wait: () => Promise<void> }> {
|
||||
let resolveRequest: (() => void) | undefined;
|
||||
const requestSeen = new Promise<void>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message) as { message?: { type?: string } };
|
||||
const type = envelope.message?.type;
|
||||
if (type?.startsWith("fs.entry.") || type === "checkout.discard_changes.request") {
|
||||
resolveRequest?.();
|
||||
void browserSocket.close({
|
||||
code: 1008,
|
||||
reason: "Context action connection failed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
serverSocket.send(message);
|
||||
});
|
||||
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||
});
|
||||
|
||||
return { wait: () => requestSeen };
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "file-explorer-context-actions-" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("creates, renames, copies, and deletes entries through the file explorer", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
|
||||
const nameInput = page.getByTestId("file-explorer-name-input");
|
||||
|
||||
await page.getByTestId("files-new-folder").click();
|
||||
await expect(nameInput).toBeVisible();
|
||||
const draftRow = nameInput.locator("xpath=..");
|
||||
const placeholderColor = await nameInput.evaluate(
|
||||
(input) => getComputedStyle(input, "::placeholder").color,
|
||||
);
|
||||
const extraMutedChevronColor = await draftRow
|
||||
.locator("svg")
|
||||
.evaluate((icon) => getComputedStyle(icon).stroke);
|
||||
expect(placeholderColor).toBe(extraMutedChevronColor);
|
||||
await nameInput.press("Tab");
|
||||
await expect(nameInput).toBeHidden();
|
||||
|
||||
await page.getByTestId("files-new-file").click();
|
||||
await nameInput.fill("README.md");
|
||||
await nameInput.press("Enter");
|
||||
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
|
||||
|
||||
const emptyArea = page.getByTestId("files-empty-area");
|
||||
await expect(emptyArea).toBeVisible();
|
||||
const emptyAreaBounds = await emptyArea.boundingBox();
|
||||
expect(emptyAreaBounds).not.toBeNull();
|
||||
await emptyArea.click({
|
||||
button: "right",
|
||||
position: { x: emptyAreaBounds!.width / 2, y: emptyAreaBounds!.height - 24 },
|
||||
});
|
||||
await expect(page.getByTestId("files-empty-area-new-file")).toBeVisible();
|
||||
await page.getByTestId("files-empty-area-new-folder").click();
|
||||
await nameInput.fill("folder");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("folder")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("New file", { exact: true }).click();
|
||||
await nameInput.fill("child.txt");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("New folder", { exact: true }).click();
|
||||
await nameInput.fill("nested");
|
||||
await nameInput.press("Enter");
|
||||
await entry("nested").click({ button: "right" });
|
||||
await page.getByText("New folder", { exact: true }).click();
|
||||
await nameInput.fill("deep");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("deep")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("Collapse folder", { exact: true }).click();
|
||||
await expect(entry("child.txt")).toBeHidden();
|
||||
await expect(entry("nested")).toBeHidden();
|
||||
await expect(entry("deep")).toBeHidden();
|
||||
|
||||
await entry("folder").click();
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
await expect(entry("nested")).toBeVisible();
|
||||
await expect(entry("deep")).toBeHidden();
|
||||
await entry("nested").click();
|
||||
await expect(entry("deep")).toBeVisible();
|
||||
|
||||
await page.getByTestId("files-new-file").click();
|
||||
await nameInput.fill("created.txt");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("created.txt")).toBeVisible();
|
||||
await expect(entry("created.txt")).toHaveCSS("user-select", "none");
|
||||
await entry("created.txt").dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
|
||||
const folderRow = entry("folder").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
const fileRow = entry("created.txt").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
await expect(folderRow.locator("svg")).toHaveCount(1);
|
||||
await expect(fileRow.locator("svg")).toHaveCount(1);
|
||||
const gitRow = entry(".git").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox();
|
||||
const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox();
|
||||
const collapsedChevronSlotBounds = await gitRow
|
||||
.locator("svg")
|
||||
.first()
|
||||
.locator("xpath=../..")
|
||||
.boundingBox();
|
||||
const expandedChevronSlotBounds = await folderRow
|
||||
.locator("svg")
|
||||
.first()
|
||||
.locator("xpath=../..")
|
||||
.boundingBox();
|
||||
expect(collapsedChevronBounds).not.toBeNull();
|
||||
expect(expandedChevronBounds).not.toBeNull();
|
||||
expect(collapsedChevronSlotBounds).not.toBeNull();
|
||||
expect(expandedChevronSlotBounds).not.toBeNull();
|
||||
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
|
||||
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
|
||||
expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0);
|
||||
expect(collapsedChevronCenter).toBeCloseTo(
|
||||
collapsedChevronSlotBounds!.x + collapsedChevronSlotBounds!.width / 2,
|
||||
0,
|
||||
);
|
||||
expect(expandedChevronCenter).toBeCloseTo(
|
||||
expandedChevronSlotBounds!.x + expandedChevronSlotBounds!.width / 2,
|
||||
0,
|
||||
);
|
||||
const folderLabelBounds = await entry("folder").boundingBox();
|
||||
const fileLabelBounds = await entry("created.txt").boundingBox();
|
||||
expect(folderLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds!.x).toBeCloseTo(folderLabelBounds!.x, 0);
|
||||
|
||||
await folderRow.click();
|
||||
await expect(folderRow).toHaveAttribute("aria-selected", "true");
|
||||
await expect(entry("child.txt")).toBeHidden();
|
||||
await folderRow.click();
|
||||
await expect(folderRow).toHaveAttribute("aria-selected", "true");
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await nameInput.fill("renamed-folder");
|
||||
await nameInput.press("Tab");
|
||||
await expect(entry("renamed-folder")).toBeVisible();
|
||||
await expect(entry("folder")).toBeHidden();
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
const renamedFolderRow = entry("renamed-folder").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await expect(fileRow).toHaveAttribute("aria-selected", "true");
|
||||
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "false");
|
||||
const menu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const backdrop = page.getByTestId(/file-explorer-row-\d+-context-menu-backdrop$/);
|
||||
await backdrop.click({ button: "right" });
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await expect(nameInput).toHaveValue("created.txt");
|
||||
await nameInput.press("Escape");
|
||||
await expect(entry("created.txt")).toBeVisible();
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await nameInput.fill("README.md");
|
||||
await nameInput.press("Enter");
|
||||
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
|
||||
await expect(entry("created.txt")).toBeVisible();
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await nameInput.fill("renamed.txt");
|
||||
await nameInput.press("Tab");
|
||||
await expect(entry("renamed.txt")).toBeVisible();
|
||||
await expect(entry("created.txt")).toBeHidden();
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
await page.getByText("Copy relative path", { exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("renamed.txt");
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
|
||||
const deleteLabelColor = await deleteAction
|
||||
.getByText("Delete", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
|
||||
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.dismiss();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
expect(await cancelledConfirmation).toContain("renamed.txt");
|
||||
await expect(entry("renamed.txt")).toBeVisible();
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
const confirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
expect(await confirmation).toContain("renamed.txt");
|
||||
await expect(entry("renamed.txt")).toBeHidden();
|
||||
|
||||
await entry("renamed-folder").click({ button: "right" });
|
||||
const folderConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
expect(await folderConfirmation).toContain("renamed-folder");
|
||||
await expect(entry("renamed-folder")).toBeHidden();
|
||||
await expect(entry("child.txt")).toBeHidden();
|
||||
});
|
||||
|
||||
test("duplicates files and folders with collision-free names", async ({ page }) => {
|
||||
const sourceFilePath = path.join(workspace.repoPath, "duplicate-source.txt");
|
||||
const sourceFolderPath = path.join(workspace.repoPath, "duplicate-folder");
|
||||
await writeFile(sourceFilePath, "duplicate contents", "utf8");
|
||||
await mkdir(sourceFolderPath);
|
||||
await writeFile(path.join(sourceFolderPath, "nested.txt"), "nested contents", "utf8");
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
|
||||
|
||||
await entry("duplicate-source.txt").click({ button: "right" });
|
||||
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||
await expect(entry("duplicate-source copy.txt")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "duplicate-source copy.txt"), "utf8"))
|
||||
.toBe("duplicate contents");
|
||||
|
||||
await entry("duplicate-source.txt").click({ button: "right" });
|
||||
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||
await expect(entry("duplicate-source copy 2.txt")).toBeVisible();
|
||||
|
||||
await entry("duplicate-folder").click({ button: "right" });
|
||||
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||
await expect(entry("duplicate-folder copy")).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
readFile(path.join(workspace.repoPath, "duplicate-folder copy", "nested.txt"), "utf8"),
|
||||
)
|
||||
.toBe("nested contents");
|
||||
} finally {
|
||||
await Promise.all([
|
||||
rm(sourceFilePath, { force: true }),
|
||||
rm(path.join(workspace.repoPath, "duplicate-source copy.txt"), { force: true }),
|
||||
rm(path.join(workspace.repoPath, "duplicate-source copy 2.txt"), { force: true }),
|
||||
rm(sourceFolderPath, { recursive: true, force: true }),
|
||||
rm(path.join(workspace.repoPath, "duplicate-folder copy"), { recursive: true, force: true }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an actionable error when the connection drops during creation", async ({ page }) => {
|
||||
const disconnect = await disconnectNextContextAction(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
await page.getByTestId("files-new-file").click();
|
||||
const nameInput = page.getByTestId("file-explorer-name-input");
|
||||
await nameInput.fill("disconnected.txt");
|
||||
await nameInput.press("Enter");
|
||||
await disconnect.wait();
|
||||
|
||||
await expect(page.getByText(/Context action connection failed/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("file-explorer-tree-scroll").getByText("disconnected.txt", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps an entry visible when deletion fails", async ({ page }) => {
|
||||
await failNextDeleteRequest(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const readme = page
|
||||
.getByTestId("file-explorer-tree-scroll")
|
||||
.getByText("README.md", { exact: true });
|
||||
await readme.click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("Injected delete failure", { exact: true })).toBeVisible();
|
||||
await expect(readme).toBeVisible();
|
||||
});
|
||||
|
||||
test("hides unsupported file operations and revert actions", async ({ page }) => {
|
||||
await hideContextActionCapabilities(page);
|
||||
await writeFile(path.join(workspace.repoPath, "README.md"), "# Changed while unsupported\n");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
await expect(page.getByTestId("files-new-file")).toHaveCount(0);
|
||||
await expect(page.getByTestId("files-new-folder")).toHaveCount(0);
|
||||
await page
|
||||
.getByTestId("file-explorer-tree-scroll")
|
||||
.getByText("README.md", { exact: true })
|
||||
.click({ button: "right" });
|
||||
const filesMenu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
|
||||
await expect(filesMenu.getByText("Rename", { exact: true })).toHaveCount(0);
|
||||
await expect(filesMenu.getByText("Duplicate", { exact: true })).toHaveCount(0);
|
||||
await expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("explorer-tab-changes").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectCompletedDiagram,
|
||||
expectDiagramRemainsRenderedWhileStreaming,
|
||||
expectDiagramWithLabels,
|
||||
reloadConversation,
|
||||
requestDiagram,
|
||||
waitForDiagramTurnToComplete,
|
||||
} from "../support/helpers/diagram";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const STREAMED_MERMAID = [
|
||||
"```mermaid",
|
||||
"flowchart LR",
|
||||
" Start --> Middle",
|
||||
' Middle --> Done["<i>Done</i>"]',
|
||||
" Middle --> Review",
|
||||
" Review --> Verify",
|
||||
" Verify --> Ship",
|
||||
" Ship --> Package",
|
||||
" Package --> Sign",
|
||||
" Sign --> Upload",
|
||||
" Upload --> Publish",
|
||||
" Publish --> Deploy",
|
||||
" Deploy --> Observe",
|
||||
" Observe --> Validate",
|
||||
" Validate --> Announce",
|
||||
" Announce --> Document",
|
||||
" Document --> Archive",
|
||||
" Archive --> Release",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-streaming-",
|
||||
title: "Streaming Mermaid",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: STREAMED_MERMAID,
|
||||
mockStreamingAssistantIntervalMs: 150,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await test.step("Open the conversation and request a diagram", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
});
|
||||
|
||||
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
|
||||
await expectDiagramWithLabels(page, ["Start", "Middle"]);
|
||||
await expectDiagramRemainsRenderedWhileStreaming(page);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram shows the final streamed content", async () => {
|
||||
await waitForDiagramTurnToComplete(agent);
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram remains rendered after reload", async () => {
|
||||
await reloadConversation(page);
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openHostSection,
|
||||
openSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
async function openMetadataGenerationSettings(page: Page) {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "metadata");
|
||||
await expectSettingsHeader(page, "Metadata");
|
||||
}
|
||||
|
||||
async function openManualMetadataModelPicker(page: Page) {
|
||||
await page.getByRole("button", { name: "Manual", exact: true }).click();
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
}
|
||||
|
||||
test("chooses a metadata model and can return to automatic selection", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openMetadataGenerationSettings(page);
|
||||
|
||||
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-automatic.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await openManualMetadataModelPicker(page);
|
||||
await page.getByText("Mock Load Test", { exact: true }).click();
|
||||
await expect(page.getByText("Ten second stream", { exact: true })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-model-picker.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByText("Ten second stream", { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-manual-persisted.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Automatic", exact: true }).click();
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("replaces only the first configured metadata model", async ({ page }) => {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: [
|
||||
{ provider: "mock", model: "five-minute-stream" },
|
||||
{ provider: "mock", model: "thirty-minute-stream" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await openMetadataGenerationSettings(page);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await openManualMetadataModelPicker(page);
|
||||
await page.getByText("Ten second stream", { exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await client.getDaemonConfig()).config.metadataGeneration.providers)
|
||||
.toEqual([
|
||||
{ provider: "mock", model: "ten-second-stream" },
|
||||
{ provider: "mock", model: "thirty-minute-stream" },
|
||||
]);
|
||||
} finally {
|
||||
try {
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: previousConfig.config.metadataGeneration.providers,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeModelPicker,
|
||||
expectModelSearchEmptyState,
|
||||
expectModelPickerHeight,
|
||||
expectModelPickerWidth,
|
||||
expectModelSearchResult,
|
||||
expectPinnedProfilesHidden,
|
||||
openModelPicker,
|
||||
readModelPickerHeight,
|
||||
readModelPickerWidth,
|
||||
searchAllModels,
|
||||
seedAgentProfiles,
|
||||
seedModelProvider,
|
||||
expectSearchResultsVirtualized,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const MOCK_PROVIDER_LABEL = "Mock Load Test";
|
||||
|
||||
// Its fast model deliberately carries the same label as the mock provider's, so
|
||||
// the only thing telling the two result rows apart is the provider name.
|
||||
const STUDIO = {
|
||||
id: "mock-studio",
|
||||
label: "Mock Studio",
|
||||
models: [
|
||||
{ id: "studio-fast", label: "Ten second stream", description: "Studio quick pass" },
|
||||
{ id: "studio-deep", label: "Studio deep think", description: "Studio long pass" },
|
||||
],
|
||||
};
|
||||
|
||||
// The cross-provider search lives on the picker's root view, and a profile is
|
||||
// what keeps the picker opening there.
|
||||
const ROOT_VIEW_PROFILE = {
|
||||
id: "agent_profile_e2e_search_root",
|
||||
name: "Search anchor",
|
||||
provider: "mock",
|
||||
};
|
||||
|
||||
const LARGE_CATALOG_SIZE = 200;
|
||||
const LARGE_CATALOG = {
|
||||
id: "mock-bulk",
|
||||
label: "Mock Bulk",
|
||||
models: Array.from({ length: LARGE_CATALOG_SIZE }, (_, index) => ({
|
||||
id: `bulk-${index}`,
|
||||
label: `Bulk model ${index}`,
|
||||
description: `Bulk search result ${index}`,
|
||||
})),
|
||||
};
|
||||
|
||||
test.describe("Cross-provider model search", () => {
|
||||
test("one query over the picker root reaches every provider and names each result's provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
const provider = await seedModelProvider(STUDIO);
|
||||
const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "model-search-providers-" });
|
||||
|
||||
try {
|
||||
await test.step("open a draft composer that can reach every provider", async () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
});
|
||||
const restingWidth = await readModelPickerWidth(page);
|
||||
|
||||
await test.step("one query returns the same model label from two providers", async () => {
|
||||
await searchAllModels(page, "ten second stream");
|
||||
await expectModelSearchResult(page, {
|
||||
provider: "mock",
|
||||
modelId: "ten-second-stream",
|
||||
modelLabel: "Ten second stream",
|
||||
providerLabel: MOCK_PROVIDER_LABEL,
|
||||
});
|
||||
await expectModelSearchResult(page, {
|
||||
provider: STUDIO.id,
|
||||
modelId: "studio-fast",
|
||||
modelLabel: "Ten second stream",
|
||||
providerLabel: STUDIO.label,
|
||||
});
|
||||
await expectModelPickerWidth(page, restingWidth);
|
||||
});
|
||||
|
||||
await test.step("searching by provider name reaches that provider's own models", async () => {
|
||||
await searchAllModels(page, "studio deep");
|
||||
await expectModelSearchResult(page, {
|
||||
provider: STUDIO.id,
|
||||
modelId: "studio-deep",
|
||||
modelLabel: "Studio deep think",
|
||||
providerLabel: STUDIO.label,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("results replace the pinned profiles", async () => {
|
||||
await expectPinnedProfilesHidden(page);
|
||||
});
|
||||
|
||||
// Search falls back to subsequence matching, so "no matches" needs letters
|
||||
// that cannot be picked out of a model label or description in order.
|
||||
await test.step("a query with no matches repeats the query back", async () => {
|
||||
await searchAllModels(page, "zzz");
|
||||
await expectModelSearchEmptyState(page, "zzz");
|
||||
await closeModelPicker(page);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await profile.restore();
|
||||
await provider.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("desktop search keeps its frame stable and virtualizes a host-wide catalog", async ({
|
||||
page,
|
||||
}) => {
|
||||
const provider = await seedModelProvider(LARGE_CATALOG);
|
||||
const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "model-search-large-catalog-" });
|
||||
|
||||
try {
|
||||
await test.step("open the host-wide model picker", async () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
});
|
||||
|
||||
const restingHeight = await readModelPickerHeight(page);
|
||||
const restingWidth = await readModelPickerWidth(page);
|
||||
|
||||
await test.step("a broad query renders only the visible result window", async () => {
|
||||
await searchAllModels(page, "bulk model");
|
||||
await expectSearchResultsVirtualized(page, {
|
||||
provider: LARGE_CATALOG.id,
|
||||
total: LARGE_CATALOG_SIZE,
|
||||
});
|
||||
await expectModelPickerHeight(page, restingHeight);
|
||||
await expectModelPickerWidth(page, restingWidth);
|
||||
});
|
||||
|
||||
await test.step("narrowing to one result does not resize the picker", async () => {
|
||||
await searchAllModels(page, "bulk model 199");
|
||||
await expectModelSearchResult(page, {
|
||||
provider: LARGE_CATALOG.id,
|
||||
modelId: "bulk-199",
|
||||
modelLabel: "Bulk model 199",
|
||||
providerLabel: LARGE_CATALOG.label,
|
||||
});
|
||||
await expectModelPickerHeight(page, restingHeight);
|
||||
await expectModelPickerWidth(page, restingWidth);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await profile.restore();
|
||||
await provider.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
import { expectNoTruncation } from "../support/helpers/no-truncation";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
@@ -49,13 +49,12 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> {
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId: seededServerId }) => {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId: seededServerId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
@@ -69,10 +68,10 @@ async function seedCodexDefaultPermissionPreferences(page: Page, serverId: strin
|
||||
model: "ten-second-stream",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,10 +154,9 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
|
||||
await seedCodexDefaultPermissionPreferences(page, serverId);
|
||||
await seedCodexDefaultPermissionPreferences(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -194,9 +192,8 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
});
|
||||
|
||||
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
|
||||
await seedCodexDefaultPermissionPreferences(page, serverId);
|
||||
await seedCodexDefaultPermissionPreferences(page);
|
||||
|
||||
try {
|
||||
const agent = await seeded.client.createAgent({
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
selectWorkspaceIsolation,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { openNewWorkspaceComposer, selectWorkspaceRuntime } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
@@ -177,7 +174,7 @@ test.describe("New Workspace dictation submit", () => {
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "local");
|
||||
await selectWorkspaceRuntime(page, "local");
|
||||
|
||||
await dictateAndSend(page, harness.waitForAudio);
|
||||
await harness.waitForCreateRequest();
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceControlsEnabled,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
expectWorkspaceRuntimeChoices,
|
||||
expectWorkspaceRuntimeSelected,
|
||||
expectNewWorkspaceTriggerLabelsAligned,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openMissingProjectNewWorkspaceComposer,
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
// (preselects that project) — shown for git projects and for non-git projects on
|
||||
// a multiplicity-capable host. These specs prove the global entry opens the
|
||||
// screen, the project icon preselects the right project across the reused 'new'
|
||||
// screen, and non-git projects never offer the worktree Isolation control.
|
||||
// screen, and non-git projects offer only the Local runtime.
|
||||
|
||||
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
|
||||
return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
|
||||
@@ -134,10 +136,11 @@ test.describe("New workspace entry points", () => {
|
||||
sourceDirectory: "/tmp/missing-project",
|
||||
});
|
||||
|
||||
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible({ timeout: 30_000 });
|
||||
await expectNewWorkspaceControlsEnabled(page);
|
||||
});
|
||||
|
||||
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
|
||||
test("the project shortcut opens the project picker with search focused", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
|
||||
|
||||
try {
|
||||
@@ -246,9 +249,7 @@ test.describe("New workspace entry points", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("the Isolation control is hidden for a non-git project and shown for a git project", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("the Runtime control hides Git runtimes for a non-git project", async ({ page }) => {
|
||||
const gitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-git-" });
|
||||
const nonGitProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "entry-iso-nongit-",
|
||||
@@ -274,11 +275,10 @@ test.describe("New workspace entry points", () => {
|
||||
await nonGitOption.click();
|
||||
await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName);
|
||||
|
||||
// No git checkout means no worktree isolation choice: the Isolation row is
|
||||
// absent entirely.
|
||||
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
|
||||
await expectWorkspaceRuntimeSelected(page, "local");
|
||||
await expectWorkspaceRuntimeChoices(page, ["local"]);
|
||||
|
||||
// Switching to the git project on the same screen reveals the Isolation row.
|
||||
// Switching to the git project on the same screen adds Worktree.
|
||||
await trigger.click();
|
||||
const gitOption = page.getByTestId(
|
||||
`new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`,
|
||||
@@ -287,9 +287,8 @@ test.describe("New workspace entry points", () => {
|
||||
await gitOption.click();
|
||||
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName);
|
||||
|
||||
await expect(page.getByTestId("workspace-create-isolation-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceRuntimeSelected(page, "local");
|
||||
await expectWorkspaceRuntimeChoices(page, ["local", "worktree"]);
|
||||
} finally {
|
||||
await gitProject.cleanup();
|
||||
await nonGitProject.cleanup();
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
archiveWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectWorkspaceIsolationSelected,
|
||||
expectWorkspaceRuntimeSelected,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
gotoNewWorkspaceForRuntime,
|
||||
seedGitProjectForRuntime,
|
||||
} from "../support/helpers/new-workspace-runtime";
|
||||
import { expectNoTruncation } from "../support/helpers/no-truncation";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
// Regression for "the local / worktree selection in the new workspace is not
|
||||
// remembered." The isolation choice persists in the create-form preferences
|
||||
// (FormPreferences.isolation), so it must survive the create→reopen remount:
|
||||
// creating a worktree workspace navigates away from /new and unmounts it, and
|
||||
// reopening New Workspace has to still show "New worktree".
|
||||
test.describe("New workspace isolation memory", () => {
|
||||
// Regression for "the Local / Worktree runtime selection in New Workspace is not
|
||||
// remembered." The runtime choice persists in the create-form preferences, so it
|
||||
// must survive the create→reopen remount: creating a Worktree workspace
|
||||
// navigates away from /new and unmounts it.
|
||||
test.describe("New workspace runtime memory", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
@@ -33,45 +28,26 @@ test.describe("New workspace isolation memory", () => {
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (client) {
|
||||
for (const workspaceDirectory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceDirectory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
localWorkspaceIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
|
||||
test("remembers the Worktree runtime after creating a workspace", async ({ page }) => {
|
||||
const project = await seedGitProjectForRuntime();
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
// First visit: the screen opens on Local, switch it to New worktree and create.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "local");
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(isolationPopup);
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
// First visit: the screen opens on Local, switch it to Worktree and create.
|
||||
await gotoNewWorkspaceForRuntime(page, project);
|
||||
await expectWorkspaceRuntimeSelected(page, "local");
|
||||
await page.getByRole("button", { name: "Runtime", exact: true }).click();
|
||||
const runtimePopup = page.getByRole("dialog").last();
|
||||
await expect(runtimePopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(runtimePopup);
|
||||
await runtimePopup.getByRole("button", { name: "Worktree", exact: true }).click();
|
||||
await expectWorkspaceRuntimeSelected(page, "worktree");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
@@ -80,21 +56,21 @@ test.describe("New workspace isolation memory", () => {
|
||||
await createButton.click();
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
previousWorkspaceId: "",
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
// Second visit (fresh mount of /new): the worktree choice must stick.
|
||||
// Second visit (fresh mount of /new): the runtime choice must stick.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
projectKey: project.projectKey,
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
await expectWorkspaceRuntimeSelected(page, "worktree");
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,13 +21,13 @@ import {
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
// `sleep` keeps the terminal alive long enough for the UI to attach and
|
||||
// render output before the process exits — see new-workspace-launch-terminal.spec.ts.
|
||||
// Loaded CI workers can take several seconds to materialize the runtime and attach the UI.
|
||||
// Keep the fixture bounded but alive through that assertion window.
|
||||
const PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-memory-profile",
|
||||
name: "Memory Profile",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"],
|
||||
args: ["-c", 'echo remembered: "$0"; sleep 60', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
selectWorkspaceIsolation,
|
||||
selectWorkspaceRuntime,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
gotoNewWorkspaceForRuntime,
|
||||
seedGitProjectForRuntime,
|
||||
type SeededRuntimeProject,
|
||||
} from "../support/helpers/new-workspace-runtime";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const LONG_HOST_NAME =
|
||||
"development-macbook-pro.local-connected-through-a-very-long-private-hostname";
|
||||
@@ -27,17 +28,14 @@ function measureControlRightEdges(controls: HTMLElement[]) {
|
||||
}
|
||||
|
||||
test.describe("New workspace metadata row layout", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let project: SeededRuntimeProject;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-layout-",
|
||||
repo: { branches: [LONG_BRANCH_NAME] },
|
||||
});
|
||||
project = await seedGitProjectForRuntime({ branches: [LONG_BRANCH_NAME] });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
await project?.cleanup();
|
||||
});
|
||||
|
||||
test("long host and branch names stay inside the composer's right rail", async ({ page }) => {
|
||||
@@ -55,13 +53,8 @@ test.describe("New workspace metadata row layout", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await gotoNewWorkspaceForRuntime(page, project);
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, LONG_BRANCH_NAME);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
selectNewWorkspaceProject,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
@@ -62,13 +62,12 @@ async function recordSetAgentModeRequests(page: Page): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise<void> {
|
||||
async function seedCodexDefaultPreferences(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId: seededServerId }) => {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId: seededServerId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
@@ -78,10 +77,10 @@ async function seedCodexDefaultPreferences(page: Page, serverId: string): Promis
|
||||
},
|
||||
mock: { model: "ten-second-stream" },
|
||||
},
|
||||
}),
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,9 +103,8 @@ test.describe("New Workspace mode cycle safety", () => {
|
||||
// control and silently change that (possibly running) agent's mode — e.g. into a
|
||||
// permissive/bypass mode. See use-keyboard-action-handler.ts.
|
||||
test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" });
|
||||
await seedCodexDefaultPreferences(page, serverId);
|
||||
await seedCodexDefaultPreferences(page);
|
||||
const modeRequests = await recordSetAgentModeRequests(page);
|
||||
|
||||
try {
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
selectWorkspaceIsolation,
|
||||
selectWorkspaceRuntime,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
@@ -723,7 +723,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
|
||||
@@ -771,7 +771,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
return openedProject;
|
||||
}
|
||||
|
||||
@@ -905,7 +905,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
@@ -930,7 +930,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
@@ -960,7 +960,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, pr.number);
|
||||
|
||||
@@ -996,7 +996,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "main");
|
||||
|
||||
@@ -1054,7 +1054,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await pasteGithubPrUrl(page, context, pr.url);
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
@@ -1102,7 +1102,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, pr.number);
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openHostSection,
|
||||
openSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
function observePluginCatalog(page: Page) {
|
||||
let responses = 0;
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
if (typeof payload !== "string") return;
|
||||
try {
|
||||
const envelope = JSON.parse(payload) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown };
|
||||
};
|
||||
const message = envelope.type === "session" ? envelope.message : envelope;
|
||||
if (message?.type === "plugin.catalog.get.response") responses += 1;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForInitialFetch: async () => {
|
||||
await expect.poll(() => responses).toBeGreaterThan(0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pluginSource(title: string, renderError?: string): string {
|
||||
return `import React from "react";
|
||||
import { Text } from "react-native";
|
||||
export default function contribute(plugin) {
|
||||
function Surface() {
|
||||
${renderError ? `throw new Error(${JSON.stringify(renderError)});` : ""}
|
||||
const cleanups = Number(globalThis.sessionStorage?.getItem("paseo-plugin-cleanups") || "0");
|
||||
return <Text>${title} cleanup {cleanups}</Text>;
|
||||
}
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
|
||||
${
|
||||
renderError
|
||||
? `function HealthySurface() { return <Text>Healthy contribution</Text>; }
|
||||
plugin.addSurface("healthy", HealthySurface);
|
||||
plugin.addSidebarItem({ id: "healthy", title: "Healthy surface", icon: "Blocks", surface: "healthy" });`
|
||||
: ""
|
||||
}
|
||||
return () => {
|
||||
const storage = globalThis.sessionStorage;
|
||||
if (storage) {
|
||||
const cleanups = Number(storage.getItem("paseo-plugin-cleanups") || "0");
|
||||
storage.setItem("paseo-plugin-cleanups", String(cleanups + 1));
|
||||
}
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
async function openPluginSettings(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "plugins");
|
||||
await expectSettingsHeader(page, "Plugins");
|
||||
}
|
||||
|
||||
async function reloadPlugin(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
|
||||
const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true });
|
||||
await expect(body).toHaveCount(1);
|
||||
await expect(body).toBeVisible();
|
||||
}
|
||||
|
||||
async function openContribution(page: Page, title: string, expectedBody: string): Promise<void> {
|
||||
await page.getByRole("button", { name: title, exact: true }).click();
|
||||
await expectContributionBody(page, expectedBody);
|
||||
}
|
||||
|
||||
async function leavePluginSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expect(page).not.toHaveURL(/\/settings\//);
|
||||
}
|
||||
|
||||
async function openContributionFromSettings(
|
||||
page: Page,
|
||||
title: string,
|
||||
expectedBody: string,
|
||||
): Promise<void> {
|
||||
await leavePluginSettings(page);
|
||||
await openContribution(page, title, expectedBody);
|
||||
}
|
||||
|
||||
async function reloadActiveContribution(
|
||||
page: Page,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
expectedBody: string,
|
||||
): Promise<void> {
|
||||
await client.reloadPlugin("e2e-plugin");
|
||||
await expectContributionBody(page, expectedBody);
|
||||
}
|
||||
|
||||
async function expectContributionRemoved(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
|
||||
|
||||
try {
|
||||
const catalog = observePluginCatalog(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
await catalog.waitForInitialFetch();
|
||||
await client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
await page.getByRole("switch", { name: "Enable plugins" }).click();
|
||||
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
|
||||
await page.getByLabel("Plugin directory").fill(directory);
|
||||
await page.getByLabel("Plugin installation ID").focus();
|
||||
await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin");
|
||||
await page.getByRole("button", { name: "Install directory" }).click();
|
||||
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2"));
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
|
||||
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
|
||||
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource("Broken surface", "render exploded"),
|
||||
);
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
|
||||
await openContribution(page, "Healthy surface", "Healthy contribution");
|
||||
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface"));
|
||||
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Broken surface", exact: true }),
|
||||
).not.toBeVisible();
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
|
||||
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Recovered surface");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3"));
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Disable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Plugin v3");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Enable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
|
||||
|
||||
await openPluginSettings(page);
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Remove", exact: true }).click();
|
||||
await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByLabel("Plugin directory").fill(directory);
|
||||
await page.getByRole("button", { name: "Install directory" }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await gotoAppShell(page);
|
||||
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
|
||||
} finally {
|
||||
await client.removePlugin("e2e-plugin").catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
clickReloadProjectSettings,
|
||||
clickRetryProjectSettingsSave,
|
||||
clickSaveProjectSettings,
|
||||
commitPaseoConfig,
|
||||
corruptPaseoConfig,
|
||||
editWorktreeSetup,
|
||||
expectEmptyScriptList,
|
||||
expectProjectHostContextHidden,
|
||||
expectNoEditableTarget,
|
||||
expectNoProjectSettingsError,
|
||||
expectNoUncommittedSetupWarning,
|
||||
expectProjectEditFailed,
|
||||
expectProjectEditName,
|
||||
expectProjectEditSaved,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
expectSaveButtonDisabled,
|
||||
expectScriptRowCount,
|
||||
expectWriteFailedCalloutActions,
|
||||
expectUncommittedSetupWarning,
|
||||
fillProjectIconUrl,
|
||||
fillProjectName,
|
||||
installDaemonConnectionGate,
|
||||
@@ -216,9 +219,16 @@ test.describe("Projects settings", () => {
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectProjectConfigSaved(editableProject);
|
||||
await expectUncommittedSetupWarning(page);
|
||||
|
||||
commitPaseoConfig(editableProject.path);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
|
||||
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
|
||||
@@ -439,5 +449,7 @@ test.describe("Projects settings — error UX", () => {
|
||||
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
type RewindFlowProvider,
|
||||
} from "../support/helpers/rewind-flow";
|
||||
import {
|
||||
expectCompletedTaskActivity,
|
||||
expectTaskListEntries,
|
||||
openCompletedTaskList,
|
||||
} from "../support/helpers/task-list";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
|
||||
interface TaskScenario {
|
||||
provider: RewindFlowProvider;
|
||||
entries: [string, string];
|
||||
prompt: string;
|
||||
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
||||
}
|
||||
|
||||
const scenarios: TaskScenario[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
entries: ["Claude inventory alpha", "Claude inventory beta"],
|
||||
providerConfig: { model: "opus" },
|
||||
prompt:
|
||||
"Use TaskCreate to create exactly two tasks named Claude inventory alpha and Claude inventory beta. Use TaskUpdate to mark each in_progress and then completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
entries: ["Codex inventory alpha", "Codex inventory beta"],
|
||||
prompt:
|
||||
"Use update_plan to track exactly two steps named Codex inventory alpha and Codex inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
entries: ["OpenCode inventory alpha", "OpenCode inventory beta"],
|
||||
prompt:
|
||||
"Use TodoWrite to track exactly two tasks named OpenCode inventory alpha and OpenCode inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("real provider composer task lists", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.provider} shows native task progress above the composer`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-task-list-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await sendMessage(handle, scenario.prompt);
|
||||
await openCompletedTaskList(page, scenario.entries.length);
|
||||
await expectTaskListEntries(page, scenario.entries);
|
||||
await expectCompletedTaskActivity(page, scenario.entries);
|
||||
await expect(page.getByText("TaskUpdate", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
||||
|
||||
const screenshot = testInfo.outputPath(`${scenario.provider}-composer-tasks.png`);
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach(`${scenario.provider} composer tasks`, {
|
||||
path: screenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("Codex genuine Plan mode keeps the existing plan card", async ({ page }, testInfo) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-genuine-codex-plan-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { featureValues: { plan_mode: true } },
|
||||
});
|
||||
await submitMessage(
|
||||
page,
|
||||
"Produce a concise implementation plan with exactly these steps: Inspect genuine plan boundary; Implement genuine plan boundary; Verify genuine plan boundary. Do not implement it.",
|
||||
);
|
||||
|
||||
const planCard = page.getByTestId("permission-plan-card");
|
||||
await expect(planCard).toBeVisible({ timeout: 120_000 });
|
||||
await expect(planCard).toContainText("Inspect genuine plan boundary");
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
||||
|
||||
const screenshot = testInfo.outputPath("codex-genuine-plan.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("Codex genuine plan", { path: screenshot, contentType: "image/png" });
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import {
|
||||
beginReplicaCacheMeasurement,
|
||||
observeReplicaCacheWrites,
|
||||
readReplicaCacheMeasurement,
|
||||
} from "../support/helpers/replica-cache-perf";
|
||||
|
||||
const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1";
|
||||
const MAX_STREAM_WRITES = 12;
|
||||
const MAX_SERIALIZED_CHARS = 190_000;
|
||||
|
||||
const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({
|
||||
replicaCacheAgent: async ({ page: _page }, provide) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "replica-cache-perf-",
|
||||
title: "Replica cache performance",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await provide(agent);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
const replicaCachePerfDescribe = RUN_REPLICA_CACHE_PERF ? test.describe : test.describe.skip;
|
||||
|
||||
replicaCachePerfDescribe("Replica cache performance", () => {
|
||||
test("streaming writes only persisted replica changes", async ({
|
||||
page,
|
||||
replicaCacheAgent: agent,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
await observeReplicaCacheWrites(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await beginReplicaCacheMeasurement(page);
|
||||
|
||||
await submitMessage(page, "Measure replica persistence during a sustained stream.");
|
||||
await agent.client.waitForFinish(agent.agentId, 20_000);
|
||||
await expectAgentIdle(page);
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
const report = await readReplicaCacheMeasurement(page);
|
||||
await testInfo.attach("replica-cache-performance", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[perf] Replica cache: ${JSON.stringify(report)}`);
|
||||
|
||||
expect(
|
||||
report.writes,
|
||||
"streaming should not continuously persist transient state",
|
||||
).toBeLessThanOrEqual(MAX_STREAM_WRITES);
|
||||
expect(report.redundantWrites).toBe(0);
|
||||
expect(report.serializedChars).toBeLessThanOrEqual(MAX_SERIALIZED_CHARS);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "../support/helpers/composer";
|
||||
import { readReplicaCache, writeReplicaCache } from "../support/helpers/replica-cache-storage";
|
||||
|
||||
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
|
||||
|
||||
@@ -27,39 +28,28 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
|
||||
|
||||
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
for (const host of cache.hosts ?? []) {
|
||||
for (const item of host.timeline?.items ?? []) {
|
||||
if (item.kind === "user_message" && item.text === messageText && item.messageId) {
|
||||
return true;
|
||||
}
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) return false;
|
||||
for (const host of cache.hosts ?? []) {
|
||||
for (const item of host.timeline?.items ?? []) {
|
||||
if (item.kind === "user_message" && item.text === prompt && item.messageId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, prompt),
|
||||
)
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await page.evaluate((messageText) => {
|
||||
const key = "@paseo:replica-cache";
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) throw new Error("Replica cache was not persisted");
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
const cachedMessage = cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.find((item) => item.kind === "user_message" && item.text === messageText);
|
||||
if (!cachedMessage) throw new Error("Cached user message was not found");
|
||||
delete cachedMessage.messageId;
|
||||
localStorage.setItem(key, JSON.stringify(cache));
|
||||
}, prompt);
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) throw new Error("Replica cache was not persisted");
|
||||
const cachedMessage = cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.find((item) => item.kind === "user_message" && item.text === prompt);
|
||||
if (!cachedMessage) throw new Error("Cached user message was not found");
|
||||
delete cachedMessage.messageId;
|
||||
await writeReplicaCache(page, cache);
|
||||
}
|
||||
|
||||
async function waitForCurrentSubmissionExcludedFromCache(
|
||||
@@ -67,46 +57,34 @@ async function waitForCurrentSubmissionExcludedFromCache(
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
return !cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.text === messageText &&
|
||||
typeof item.clientMessageId === "string" &&
|
||||
item.messageId === undefined,
|
||||
);
|
||||
}, prompt),
|
||||
)
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) return false;
|
||||
return !cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.text === prompt &&
|
||||
typeof item.clientMessageId === "string" &&
|
||||
item.messageId === undefined,
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
return cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.text === messageText &&
|
||||
item.messageId === undefined,
|
||||
);
|
||||
}, prompt),
|
||||
)
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) return false;
|
||||
return cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" && item.text === prompt && item.messageId === undefined,
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
@@ -148,16 +126,13 @@ test.describe("Rewind sheet", () => {
|
||||
title: "Rewind cache upgrade e2e",
|
||||
initialPrompt: prompt,
|
||||
});
|
||||
let heldTimelineRequest = false;
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectUserMessageVisible(page, prompt);
|
||||
await gate.drop();
|
||||
await rewriteCachedMessageAsLegacyRow(page, prompt);
|
||||
gate.holdNextClientRequest("fetch_agent_timeline_request");
|
||||
await page.reload();
|
||||
await gate.waitForHeldClientRequest();
|
||||
heldTimelineRequest = true;
|
||||
|
||||
const restoredMessage = userMessage(page, prompt);
|
||||
await expect(restoredMessage).toBeVisible();
|
||||
@@ -165,7 +140,6 @@ test.describe("Rewind sheet", () => {
|
||||
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
|
||||
await waitForCachedMessageWithoutProviderId(page, prompt);
|
||||
} finally {
|
||||
if (heldTimelineRequest) gate.releaseHeldClientRequest();
|
||||
gate.restore();
|
||||
await session.cleanup();
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ test("Sessions shows an empty placeholder when the host has no history", async (
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences(serverId) },
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -18,7 +18,25 @@ async function visibleBoundingBox(row: Locator) {
|
||||
return box;
|
||||
}
|
||||
|
||||
async function quickDragFirstRowAfterSecond(rows: Locator) {
|
||||
async function pressProjectRow(rows: Locator) {
|
||||
await rows.page().mouse.down();
|
||||
}
|
||||
|
||||
async function pressWorkspaceRow(rows: Locator) {
|
||||
const solidScrimStop = rows
|
||||
.nth(0)
|
||||
.getByTestId("sidebar-workspace-trailing-scrim")
|
||||
.locator("stop")
|
||||
.nth(1);
|
||||
const hoverScrimColor = await solidScrimStop.getAttribute("stop-color");
|
||||
await rows.page().mouse.down();
|
||||
await expect.poll(() => solidScrimStop.getAttribute("stop-color")).not.toBe(hoverScrimColor);
|
||||
}
|
||||
|
||||
async function quickDragFirstRowAfterSecond(
|
||||
rows: Locator,
|
||||
pressRow: (rows: Locator) => Promise<void>,
|
||||
) {
|
||||
await expect(rows).toHaveCount(2);
|
||||
const before = await rowTestIds(rows);
|
||||
const sourceBox = await visibleBoundingBox(rows.nth(0));
|
||||
@@ -29,15 +47,19 @@ async function quickDragFirstRowAfterSecond(rows: Locator) {
|
||||
const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
|
||||
|
||||
await page.mouse.move(source.x, source.y);
|
||||
await page.mouse.down();
|
||||
const trailingScrim = rows.nth(0).getByTestId("sidebar-workspace-trailing-scrim");
|
||||
await pressRow(rows);
|
||||
await page.mouse.move(source.x, source.y + 7);
|
||||
await expect(trailingScrim).toHaveCount(0);
|
||||
await page.mouse.move(target.x, target.y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
||||
}
|
||||
|
||||
test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => {
|
||||
test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({
|
||||
page,
|
||||
}) => {
|
||||
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
|
||||
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" });
|
||||
|
||||
@@ -61,6 +83,7 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa
|
||||
const secondProjectTestId = `sidebar-project-row-${projectEquivalenceViewKey(secondProject.projectKey)}`;
|
||||
await quickDragFirstRowAfterSecond(
|
||||
page.locator(`[data-testid="${firstProjectTestId}"], [data-testid="${secondProjectTestId}"]`),
|
||||
pressProjectRow,
|
||||
);
|
||||
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
|
||||
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
|
||||
@@ -68,6 +91,17 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa
|
||||
page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
|
||||
),
|
||||
pressWorkspaceRow,
|
||||
);
|
||||
|
||||
await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true);
|
||||
await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true);
|
||||
const secondProjectWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondProject.workspaceId}`;
|
||||
await quickDragFirstRowAfterSecond(
|
||||
page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondProjectWorkspaceTestId}"]`,
|
||||
),
|
||||
pressWorkspaceRow,
|
||||
);
|
||||
} finally {
|
||||
await firstProject.cleanup();
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectWorkspaceIsolation,
|
||||
selectWorkspaceRuntime,
|
||||
submitNewWorkspaceEmpty,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
@@ -271,7 +271,7 @@ test.describe("Workspace model regressions", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceIsolation(page, "local");
|
||||
await selectWorkspaceRuntime(page, "local");
|
||||
await submitNewWorkspacePrompt(page, "Fix login bug");
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
@@ -356,7 +356,7 @@ test.describe("Workspace model regressions", () => {
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceIsolation(page, "local");
|
||||
await selectWorkspaceRuntime(page, "local");
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
@@ -472,7 +472,7 @@ test.describe("Workspace model regressions", () => {
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceIsolation(page, "local");
|
||||
await selectWorkspaceRuntime(page, "local");
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { metroTest as test } from "../support/fixtures";
|
||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { loadDaemonClientConstructor } from "../support/helpers/daemon-client-loader";
|
||||
@@ -348,12 +349,14 @@ async function seedBrowserForDaemon(page: Page, input: { serverId: string; port:
|
||||
{
|
||||
daemon: host,
|
||||
preferences: {
|
||||
serverId: input.serverId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
codex: {
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingByModel: { "gpt-5.4-mini": "low" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies FormPreferences,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
selectWorkspaceIsolation,
|
||||
selectWorkspaceRuntime,
|
||||
submitNewWorkspaceEmpty,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
@@ -15,8 +15,8 @@ import { getServerId } from "../support/helpers/server-id";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
// Model B reshape: a workspace is the unit, its isolation (local checkout or
|
||||
// worktree) is a CHOICE at creation, and creation NEVER dedupes by
|
||||
// Model B reshape: a workspace is the unit, its runtime (Local or Worktree) is
|
||||
// a CHOICE at creation, and creation NEVER dedupes by
|
||||
// directory. These specs drive the real creation UI (workspace-create-* test
|
||||
// IDs) to prove a single directory can back any number of workspaces.
|
||||
|
||||
@@ -39,17 +39,16 @@ async function createWorkspaceViaUi(
|
||||
page: Page,
|
||||
input: {
|
||||
project: { projectKey: string; projectDisplayName: string };
|
||||
// null when the project has no git checkout: there is no Isolation control to
|
||||
// touch, the isolation is implicitly local.
|
||||
isolation: "local" | "worktree" | null;
|
||||
// null when the project has no git checkout: Local is the only available runtime.
|
||||
runtime: "local" | "worktree" | null;
|
||||
previousWorkspaceId: string;
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
},
|
||||
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, input.project);
|
||||
if (input.isolation !== null) {
|
||||
await selectWorkspaceIsolation(page, input.isolation);
|
||||
if (input.runtime !== null) {
|
||||
await selectWorkspaceRuntime(page, input.runtime);
|
||||
}
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
@@ -97,7 +96,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
const second = await createWorkspaceViaUi(page, {
|
||||
project,
|
||||
isolation: "local",
|
||||
runtime: "local",
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
client,
|
||||
});
|
||||
@@ -128,9 +127,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("New worktree isolation creates a worktree-backed workspace in a distinct directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("the Worktree runtime creates a workspace in a distinct directory", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "multiplicity-worktree-",
|
||||
});
|
||||
@@ -149,7 +146,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
const worktree = await createWorkspaceViaUi(page, {
|
||||
project,
|
||||
isolation: "worktree",
|
||||
runtime: "worktree",
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
client,
|
||||
});
|
||||
@@ -200,8 +197,8 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
const second = await createWorkspaceViaUi(page, {
|
||||
project,
|
||||
// Non-git project: no Isolation control, isolation is implicitly local.
|
||||
isolation: null,
|
||||
// Non-git project: Local is the only available runtime.
|
||||
runtime: null,
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
client,
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
label: "restart daemon",
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(serverId),
|
||||
preferences: buildCreateAgentPreferences(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,15 +59,16 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const agents = await Promise.all(
|
||||
Array.from({ length: options.agentCount ?? 1 }, () =>
|
||||
createIdleAgent(client, {
|
||||
const agents = [];
|
||||
for (let index = 0; index < (options.agentCount ?? 1); index += 1) {
|
||||
agents.push(
|
||||
await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
const agent = agents[0];
|
||||
if (!agent) {
|
||||
throw new Error("Expected at least one archived-worktree agent");
|
||||
@@ -369,10 +370,7 @@ test.describe("Worktree restore", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The archived workspace directory no longer exists and cannot be recreated.",
|
||||
{ exact: true },
|
||||
),
|
||||
page.getByText("The archived workspace runtime is missing.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import readline from "node:readline";
|
||||
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
|
||||
for await (const line of lines) {
|
||||
const command = JSON.parse(line);
|
||||
let data = {};
|
||||
if (command.type === "get_available_models") {
|
||||
data = {
|
||||
models: [
|
||||
{
|
||||
provider: "e2e",
|
||||
id: "pi-profile-model",
|
||||
name: "Pi profile model",
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (command.type === "get_state") {
|
||||
data = {
|
||||
sessionId: "e2e-pi-session",
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
messageCount: 0,
|
||||
pendingMessageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
@@ -132,7 +132,7 @@ const test = metroTest.extend<
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
const createAgentPreferences = buildCreateAgentPreferences();
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { openSettingsHost } from "./settings";
|
||||
|
||||
// ─── Daemon-side seeding ───────────────────────────────────────────────────
|
||||
|
||||
interface AgentProfilesDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
|
||||
patchDaemonConfig(config: {
|
||||
agentProfiles?: AgentProfile[];
|
||||
providers?: Record<string, Record<string, unknown>>;
|
||||
removeProviders?: string[];
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface HostSeed {
|
||||
/** Puts the host's config back the way the test found it and drops the client. */
|
||||
restore(): Promise<void>;
|
||||
}
|
||||
|
||||
async function connectAgentProfilesClient(): Promise<AgentProfilesDaemonClient> {
|
||||
return connectDaemonClient<AgentProfilesDaemonClient>({ clientIdPrefix: "agent-profiles-e2e" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes profiles into host config directly. Journeys that are *about* the
|
||||
* settings UI create theirs through it; every other journey starts from a host
|
||||
* that already has them.
|
||||
*/
|
||||
export async function seedAgentProfiles(profiles: AgentProfile[]): Promise<HostSeed> {
|
||||
const client = await connectAgentProfilesClient();
|
||||
const previous = (await client.getDaemonConfig()).config.agentProfiles ?? [];
|
||||
await client.patchDaemonConfig({ agentProfiles: profiles });
|
||||
return {
|
||||
async restore() {
|
||||
await client.patchDaemonConfig({ agentProfiles: previous }).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function stageLegacyFavoritesForHostMigration(
|
||||
page: Page,
|
||||
favorites: Array<{ provider: string; modelId: string }>,
|
||||
): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await page.evaluate(
|
||||
({ favorites: storedFavorites, serverId: hostId }) => {
|
||||
const preferencesKey = "@paseo:create-agent-preferences";
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
const preferences = raw ? JSON.parse(raw) : {};
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({ ...preferences, favoriteModels: storedFavorites }),
|
||||
);
|
||||
localStorage.removeItem(`@paseo:legacy-favorites-to-agent-profiles:v1:${hostId}`);
|
||||
|
||||
// Preserve this upgrade-shaped storage across the fixture's next-page
|
||||
// default seed; a reload is the startup boundary under test.
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
|
||||
if (nonce) {
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
}
|
||||
},
|
||||
{ favorites, serverId },
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function expectHostAgentProfiles(expected: AgentProfile[]): Promise<void> {
|
||||
const client = await connectAgentProfilesClient();
|
||||
try {
|
||||
await expect
|
||||
.poll(async () => (await client.getDaemonConfig()).config.agentProfiles ?? [], {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toEqual(expected);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SeededProviderModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a second provider that reports a fixed catalog. The E2E daemon has
|
||||
* exactly one provider with models (`mock`), so nothing cross-provider can be
|
||||
* proven without one more.
|
||||
*
|
||||
* `extends: "mock"` is rejected — the config validator only accepts the six
|
||||
* shipped providers plus `acp`. Extending `claude` with a replacement `models`
|
||||
* list is what avoids running anything: replacement models skip catalog
|
||||
* discovery, and Claude's static modes mean the provider never spawns. The
|
||||
* command only has to resolve for the availability probe, hence `node`.
|
||||
* Providers with dynamic catalogs can pass a small RPC fixture as `command`.
|
||||
*/
|
||||
export async function seedModelProvider(input: {
|
||||
id: string;
|
||||
label: string;
|
||||
models: SeededProviderModel[];
|
||||
extends?: "claude" | "pi";
|
||||
command?: string[];
|
||||
}): Promise<HostSeed> {
|
||||
const client = await connectAgentProfilesClient();
|
||||
await client.patchDaemonConfig({
|
||||
providers: {
|
||||
[input.id]: {
|
||||
extends: input.extends ?? "claude",
|
||||
label: input.label,
|
||||
description: `${input.label} test provider`,
|
||||
enabled: true,
|
||||
command: input.command ?? ["node"],
|
||||
models: input.models,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
async restore() {
|
||||
await client.patchDaemonConfig({ removeProviders: [input.id] }).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Settings: navigation ──────────────────────────────────────────────────
|
||||
|
||||
/** Reach agent profiles through the same visible Settings path a person uses. */
|
||||
export async function openAgentProfileSettings(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await page.getByRole("button", { name: "Agents", exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "agents"));
|
||||
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectNoAgentProfiles(page: Page): Promise<void> {
|
||||
const card = page.getByTestId("agent-profiles-card");
|
||||
await expect(card.getByTestId("agent-profiles-empty")).toBeVisible();
|
||||
await expect(card.getByRole("button", { name: "New profile", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Settings: the profile list ────────────────────────────────────────────
|
||||
|
||||
export function agentProfileRow(page: Page, name: string): Locator {
|
||||
return page.locator('[data-testid^="agent-profile-row-"]').filter({ hasText: name });
|
||||
}
|
||||
|
||||
export async function expectAgentProfile(
|
||||
page: Page,
|
||||
expected: { name: string; tags: string[]; notes?: string },
|
||||
): Promise<void> {
|
||||
const row = agentProfileRow(page, expected.name);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(expected.tags.join(" · "), { exact: true })).toBeVisible();
|
||||
if (expected.notes !== undefined) {
|
||||
await expect(row.getByText(expected.notes, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectAgentProfileTagsGone(
|
||||
page: Page,
|
||||
input: { name: string; tags: string[] },
|
||||
): Promise<void> {
|
||||
const row = agentProfileRow(page, input.name);
|
||||
for (const tag of input.tags) {
|
||||
await expect(row.getByText(tag, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Order is what the user sees, so it is measured on screen rather than in the DOM. */
|
||||
export async function expectAgentProfileOrder(page: Page, names: string[]): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const measured = await Promise.all(
|
||||
names.map(async (name) => ({ name, top: await boxTop(agentProfileRow(page, name)) })),
|
||||
);
|
||||
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.name);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual(names);
|
||||
}
|
||||
|
||||
export async function moveAgentProfileUp(page: Page, name: string): Promise<void> {
|
||||
await agentProfileRow(page, name).getByRole("button", { name: "Move up", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function moveAgentProfileDown(page: Page, name: string): Promise<void> {
|
||||
await agentProfileRow(page, name).getByRole("button", { name: "Move down", exact: true }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* The remove confirmation is `window.confirm` on browser web, so the dialog is
|
||||
* consumed here — and its text is asserted, since naming the profile is the
|
||||
* whole point of confirming.
|
||||
*/
|
||||
export async function removeAgentProfile(page: Page, name: string): Promise<void> {
|
||||
const dialogMessage = page.waitForEvent("dialog").then(async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
return message;
|
||||
});
|
||||
await agentProfileRow(page, name).getByRole("button", { name: "Remove", exact: true }).click();
|
||||
expect(await dialogMessage).toContain(`Remove "${name}"?`);
|
||||
await expect(agentProfileRow(page, name)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ─── Settings: the edit modal ──────────────────────────────────────────────
|
||||
|
||||
export interface AgentProfileDraft {
|
||||
name?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
mode?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
function editModal(page: Page): Locator {
|
||||
return page.getByTestId("agent-profile-edit-modal");
|
||||
}
|
||||
|
||||
async function chooseSelectFieldOption(
|
||||
page: Page,
|
||||
input: { field: "provider" | "model" | "mode" | "thinking"; option: string },
|
||||
): Promise<void> {
|
||||
await editModal(page).getByTestId(`agent-profile-${input.field}-trigger`).click();
|
||||
const option = page
|
||||
.getByTestId("combobox-desktop-container")
|
||||
.getByRole("button", { name: input.option, exact: true });
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
await option.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${capitalize(input.field)} (${input.option})` }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promise<void> {
|
||||
const modal = editModal(page);
|
||||
if (draft.name !== undefined) {
|
||||
await modal.getByRole("textbox", { name: "Name", exact: true }).fill(draft.name);
|
||||
}
|
||||
// Provider first: it resets every provider-scoped field below it.
|
||||
if (draft.provider !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "provider", option: draft.provider });
|
||||
}
|
||||
// Thinking after model: thinking levels hang off the model, and switching the
|
||||
// model drops a level the new one does not offer.
|
||||
if (draft.model !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "model", option: draft.model });
|
||||
}
|
||||
if (draft.thinking !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "thinking", option: draft.thinking });
|
||||
}
|
||||
if (draft.mode !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
||||
}
|
||||
if (draft.notes !== undefined) {
|
||||
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAgentProfile(page: Page): Promise<void> {
|
||||
await editModal(page).getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function createAgentProfile(page: Page, draft: AgentProfileDraft): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add agent profile", exact: true }).click();
|
||||
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
|
||||
await fillAgentProfileForm(page, draft);
|
||||
await saveAgentProfile(page);
|
||||
}
|
||||
|
||||
export async function createAgentProfileFromEmptyState(
|
||||
page: Page,
|
||||
draft: AgentProfileDraft,
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId("agent-profiles-card")
|
||||
.getByRole("button", { name: "New profile", exact: true })
|
||||
.click();
|
||||
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
|
||||
await fillAgentProfileForm(page, draft);
|
||||
await saveAgentProfile(page);
|
||||
}
|
||||
|
||||
export async function editAgentProfile(
|
||||
page: Page,
|
||||
name: string,
|
||||
changes: AgentProfileDraft,
|
||||
): Promise<void> {
|
||||
await agentProfileRow(page, name)
|
||||
.getByRole("button", { name: "Edit profile", exact: true })
|
||||
.click();
|
||||
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
|
||||
await fillAgentProfileForm(page, changes);
|
||||
await saveAgentProfile(page);
|
||||
}
|
||||
|
||||
/** Opens the modal for a profile purely to read back what it stored. */
|
||||
export async function expectAgentProfileForm(
|
||||
page: Page,
|
||||
name: string,
|
||||
expected: { provider?: string; model?: string; mode?: string; notes?: string },
|
||||
): Promise<void> {
|
||||
await agentProfileRow(page, name)
|
||||
.getByRole("button", { name: "Edit profile", exact: true })
|
||||
.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
||||
for (const [field, value] of Object.entries(expected)) {
|
||||
if (field === "notes") {
|
||||
await expect(
|
||||
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
|
||||
).toHaveValue(value);
|
||||
continue;
|
||||
}
|
||||
await expect(
|
||||
modal.getByRole("button", { name: `${capitalize(field)} (${value})` }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ─── The model picker ──────────────────────────────────────────────────────
|
||||
|
||||
/** Desktop web renders the model browser inside the combobox popover. */
|
||||
function pickerViewport(page: Page): Locator {
|
||||
return page.getByTestId("combobox-desktop-container");
|
||||
}
|
||||
|
||||
export async function openModelPicker(page: Page): Promise<void> {
|
||||
await page.getByTestId("combined-model-selector").filter({ visible: true }).first().click();
|
||||
await expect(pickerViewport(page)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function closeModelPicker(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export function profilePickerRow(page: Page, name: string): Locator {
|
||||
return pickerViewport(page)
|
||||
.locator('[data-testid^="model-profile-row-"]')
|
||||
.filter({ hasText: name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Profiles are pinned above the provider list, so "pinned" is an ordering claim:
|
||||
* the profile row's top edge sits above every provider row's.
|
||||
*/
|
||||
export async function expectProfilePinnedAboveProviders(
|
||||
page: Page,
|
||||
input: { name: string; summary: string },
|
||||
): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
const row = profilePickerRow(page, input.name);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
|
||||
|
||||
const profileTop = await boxTop(row);
|
||||
const providerRows = viewport.locator('[data-testid^="model-provider-"]');
|
||||
await expect(providerRows.first()).toBeVisible();
|
||||
const providerCount = await providerRows.count();
|
||||
for (let index = 0; index < providerCount; index += 1) {
|
||||
expect(profileTop).toBeLessThan(await boxTop(providerRows.nth(index)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectProfileVisibleForProvider(
|
||||
page: Page,
|
||||
input: { name: string; summary: string },
|
||||
): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
const row = profilePickerRow(page, input.name);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectProfileEditIsPencilOnly(page: Page): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(
|
||||
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(viewport.getByText("Edit", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectAgentProfilesEmptyPrompt(page: Page): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(viewport.getByText("Profiles", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(viewport.getByRole("button", { name: "Create profile", exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectProfileEditTooltip(page: Page): Promise<void> {
|
||||
const edit = pickerViewport(page).getByRole("button", {
|
||||
name: "Edit agent profiles",
|
||||
exact: true,
|
||||
});
|
||||
await edit.hover();
|
||||
await expect(page.getByText("Edit agent profiles", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openAgentProfilesFromEmptyPrompt(page: Page): Promise<void> {
|
||||
await pickerViewport(page).getByRole("button", { name: "Create profile", exact: true }).click();
|
||||
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function boxTop(locator: Locator): Promise<number> {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected a laid-out element to measure");
|
||||
}
|
||||
return box.y;
|
||||
}
|
||||
|
||||
export async function applyProfileFromPicker(page: Page, name: string): Promise<void> {
|
||||
await profilePickerRow(page, name).click();
|
||||
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Applying a profile materializes it and forgets it: nothing in the root view
|
||||
* claims selection. Model rows do carry `aria-selected`, so a count of zero here
|
||||
* is the absence of a checkmark, not the absence of the attribute everywhere.
|
||||
*/
|
||||
export async function expectNothingSelectedInPickerRoot(page: Page): Promise<void> {
|
||||
await expect(pickerViewport(page).locator("[aria-selected]")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectAgentProfilesEditShortcut(page: Page): Promise<void> {
|
||||
await expect(
|
||||
pickerViewport(page).getByRole("button", { name: "Edit agent profiles", exact: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function drillIntoProvider(page: Page, providerId: string): Promise<void> {
|
||||
await pickerViewport(page).getByTestId(`model-provider-${providerId}`).click();
|
||||
}
|
||||
|
||||
export async function expectModelRowSelected(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`),
|
||||
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ─── Cross-provider model search ───────────────────────────────────────────
|
||||
|
||||
export async function searchAllModels(page: Page, query: string): Promise<void> {
|
||||
const input = page.getByTestId("model-search-all-input");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(query);
|
||||
}
|
||||
|
||||
export async function expectModelSearchResult(
|
||||
page: Page,
|
||||
expected: { provider: string; modelId: string; providerLabel: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
const row = pickerViewport(page).getByTestId(
|
||||
`model-row-${expected.provider}-${expected.modelId}`,
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(expected.modelLabel, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
row.getByText(new RegExp(`^${escapeForRegex(expected.providerLabel)}\\b`)),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
function escapeForRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Search results replace the whole root view, pinned profiles included. */
|
||||
export async function expectPinnedProfilesHidden(page: Page): Promise<void> {
|
||||
await expect(pickerViewport(page).locator('[data-testid^="model-profile-row-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectModelSearchEmptyState(page: Page, query: string): Promise<void> {
|
||||
const empty = page.getByTestId("model-search-empty");
|
||||
await expect(empty).toBeVisible({ timeout: 30_000 });
|
||||
await expect(empty.getByText(`No models match "${query}"`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function readModelPickerHeight(page: Page): Promise<number> {
|
||||
const box = await pickerViewport(page).boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected the model picker to be laid out");
|
||||
}
|
||||
return box.height;
|
||||
}
|
||||
|
||||
export async function readModelPickerWidth(page: Page): Promise<number> {
|
||||
const box = await pickerViewport(page).boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected the model picker to be laid out");
|
||||
}
|
||||
return box.width;
|
||||
}
|
||||
|
||||
export async function expectModelPickerHeight(page: Page, expectedHeight: number): Promise<void> {
|
||||
await expect.poll(() => readModelPickerHeight(page)).toBe(expectedHeight);
|
||||
}
|
||||
|
||||
export async function expectModelPickerWidth(page: Page, expectedWidth: number): Promise<void> {
|
||||
await expect.poll(() => readModelPickerWidth(page)).toBe(expectedWidth);
|
||||
}
|
||||
|
||||
export async function expectSearchResultsVirtualized(
|
||||
page: Page,
|
||||
input: { provider: string; total: number },
|
||||
): Promise<void> {
|
||||
const mountedRows = pickerViewport(page).locator(`[data-testid^="model-row-${input.provider}-"]`);
|
||||
await expect.poll(() => mountedRows.count()).toBeGreaterThan(0);
|
||||
expect(await mountedRows.count()).toBeLessThan(input.total);
|
||||
}
|
||||
|
||||
// ─── Composer agent controls ───────────────────────────────────────────────
|
||||
|
||||
export async function expectComposerModel(page: Page, modelLabel: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Select model (${modelLabel})`, exact: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerMode(page: Page, modeLabel: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Select agent mode (${modeLabel})`, exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** The composer never shows the profile's name — a profile is not a selection. */
|
||||
export async function expectComposerDoesNotName(page: Page, profileName: string): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-testid="message-input-root"]:visible').getByText(profileName),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
@@ -31,6 +31,82 @@ export interface PromptJumpRequestTracker {
|
||||
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
|
||||
}
|
||||
|
||||
export interface TimelineRequestTracker {
|
||||
nextRequest(): Promise<{
|
||||
direction: string | null;
|
||||
cursor: { epoch: string; seq: number } | null;
|
||||
}>;
|
||||
requests(): Array<{
|
||||
direction: string | null;
|
||||
cursor: { epoch: string; seq: number } | null;
|
||||
}>;
|
||||
waitForResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function trackAgentTimelineRequests(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<TimelineRequestTracker> {
|
||||
type Request = ReturnType<TimelineRequestTracker["requests"]>[number];
|
||||
const seen: Request[] = [];
|
||||
const waiters: Array<(request: Request) => void> = [];
|
||||
let responseSeen = false;
|
||||
let resolveResponse: (() => void) | null = null;
|
||||
const response = new Promise<void>((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_request" &&
|
||||
sessionMessage.agentId === agentId
|
||||
) {
|
||||
const rawCursor = sessionMessage.cursor;
|
||||
const cursor =
|
||||
rawCursor &&
|
||||
typeof rawCursor === "object" &&
|
||||
typeof (rawCursor as { epoch?: unknown }).epoch === "string" &&
|
||||
typeof (rawCursor as { seq?: unknown }).seq === "number"
|
||||
? {
|
||||
epoch: (rawCursor as { epoch: string }).epoch,
|
||||
seq: (rawCursor as { seq: number }).seq,
|
||||
}
|
||||
: null;
|
||||
const request = {
|
||||
direction: typeof sessionMessage.direction === "string" ? sessionMessage.direction : null,
|
||||
cursor,
|
||||
};
|
||||
seen.push(request);
|
||||
waiters.shift()?.(request);
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId
|
||||
) {
|
||||
responseSeen = true;
|
||||
resolveResponse?.();
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
nextRequest() {
|
||||
const request = seen[0];
|
||||
if (request) return Promise.resolve(request);
|
||||
return new Promise((resolve) => waiters.push(resolve));
|
||||
},
|
||||
requests: () => [...seen],
|
||||
waitForResponse: () => (responseSeen ? Promise.resolve() : response),
|
||||
};
|
||||
}
|
||||
|
||||
export async function trackPromptJumpRequests(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
|
||||
@@ -26,7 +26,7 @@ function buildSeededStoragePayload() {
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(getServerId()),
|
||||
preferences: buildCreateAgentPreferences(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import type {
|
||||
FormPreferences,
|
||||
ProviderPreferences,
|
||||
} from "../../../src/create-agent-preferences/preferences";
|
||||
|
||||
export const TEST_HOST_LABEL = "localhost";
|
||||
|
||||
export const TEST_PROVIDER_PREFERENCES = {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
} as const;
|
||||
codex: { model: "gpt-5.4-mini", thinkingByModel: { "gpt-5.4-mini": "low" } },
|
||||
} satisfies Record<string, ProviderPreferences>;
|
||||
|
||||
export function buildDirectTcpConnection(endpoint: string) {
|
||||
export function buildDirectTcpConnection(endpoint: string): {
|
||||
id: string;
|
||||
type: "directTcp";
|
||||
endpoint: string;
|
||||
} {
|
||||
return {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp" as const,
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
@@ -33,12 +42,11 @@ export function buildSeededHost(input: {
|
||||
export const TEST_MOCK_PROVIDER_PREFERENCES = {
|
||||
...TEST_PROVIDER_PREFERENCES,
|
||||
mock: { model: "ten-second-stream" },
|
||||
} as const;
|
||||
} satisfies Record<string, ProviderPreferences>;
|
||||
|
||||
export function buildCreateAgentPreferences(serverId: string) {
|
||||
export function buildCreateAgentPreferences() {
|
||||
return {
|
||||
serverId,
|
||||
provider: "mock" as const,
|
||||
provider: "mock",
|
||||
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
|
||||
};
|
||||
} satisfies FormPreferences;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
const DIAGRAM_NAME = "Diagram";
|
||||
|
||||
function renderedDiagram(page: Page) {
|
||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
||||
const svg = diagram.locator("iframe").contentFrame().locator("#diagram svg");
|
||||
return { diagram, svg };
|
||||
}
|
||||
|
||||
export async function requestDiagram(agent: MockAgentWorkspace): Promise<void> {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Render the requested Mermaid diagram.");
|
||||
}
|
||||
|
||||
export async function expectDiagramWithLabels(
|
||||
page: Page,
|
||||
labels: readonly string[],
|
||||
): Promise<void> {
|
||||
const { diagram, svg } = renderedDiagram(page);
|
||||
await expect(diagram).toBeVisible({ timeout: 30_000 });
|
||||
await expect(svg).toBeVisible({ timeout: 30_000 });
|
||||
for (const label of labels) {
|
||||
await expect(svg).toContainText(label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise<void> {
|
||||
const { diagram, svg } = renderedDiagram(page);
|
||||
const samples = 80;
|
||||
for (let sample = 0; sample < samples; sample += 1) {
|
||||
await expect(diagram).toBeVisible({ timeout: 100 });
|
||||
await expect(svg).toBeVisible({ timeout: 100 });
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDiagramTurnToComplete(agent: MockAgentWorkspace): Promise<void> {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
|
||||
export async function expectCompletedDiagram(page: Page, labels: readonly string[]): Promise<void> {
|
||||
await expectDiagramWithLabels(page, labels);
|
||||
}
|
||||
|
||||
export async function reloadConversation(page: Page): Promise<void> {
|
||||
await page.reload();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./paseo-home-fork";
|
||||
@@ -139,6 +140,74 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
|
||||
);
|
||||
}
|
||||
|
||||
async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void> {
|
||||
if (process.env.E2E_DESKTOP_RUNTIME !== "1") return;
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
const existing = existsSync(configPath) ? JSON.parse(await readFile(configPath, "utf8")) : {};
|
||||
const stateDirectory = path.join(paseoHome, "fixture-runtime");
|
||||
const materializeRoot = path.join(paseoHome, "fixture-workspaces");
|
||||
const fixtureProviderSource = path.resolve(
|
||||
__dirname,
|
||||
"../../../../../runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs",
|
||||
);
|
||||
await mkdir(stateDirectory, { recursive: true });
|
||||
await writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify({
|
||||
...existing,
|
||||
version: 1,
|
||||
agents: {
|
||||
...existing.agents,
|
||||
providers: {
|
||||
...existing.agents?.providers,
|
||||
claude: { ...existing.agents?.providers?.claude, enabled: false },
|
||||
codex: { ...existing.agents?.providers?.codex, enabled: false },
|
||||
copilot: { ...existing.agents?.providers?.copilot, enabled: false },
|
||||
opencode: { ...existing.agents?.providers?.opencode, enabled: false },
|
||||
pi: { ...existing.agents?.providers?.pi, enabled: false },
|
||||
"fixture-agent": {
|
||||
extends: "acp",
|
||||
label: "Fixture Agent",
|
||||
command: [process.execPath, "./.paseo-fixture-agent.mjs"],
|
||||
models: [{ id: "fixture-model", label: "Fixture Model", isDefault: true }],
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceRuntimes: {
|
||||
...existing.workspaceRuntimes,
|
||||
fixture: {
|
||||
type: "command",
|
||||
label: "Fixture",
|
||||
command: [
|
||||
process.execPath,
|
||||
path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"),
|
||||
],
|
||||
options: {
|
||||
stateDirectory,
|
||||
materializeRoot,
|
||||
fixtureProviderSource,
|
||||
preserveSourceDisplayCwd: true,
|
||||
},
|
||||
},
|
||||
"fixture-failure": {
|
||||
type: "command",
|
||||
label: "Fixture Failure",
|
||||
command: [
|
||||
process.execPath,
|
||||
path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"),
|
||||
],
|
||||
options: {
|
||||
stateDirectory: path.join(paseoHome, "fixture-failure-runtime"),
|
||||
failCreate: true,
|
||||
createError: "Fixture probe creation failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function startE2EWorker(
|
||||
workerIndex: number,
|
||||
options: { forkProviders?: string[] } = {},
|
||||
@@ -154,6 +223,7 @@ export async function startE2EWorker(
|
||||
|
||||
try {
|
||||
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
||||
await configureDesktopFixtureRuntime(paseoHome);
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
paseoHome,
|
||||
preserveHome,
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { access, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { waitForConnectedHost } from "./hosts";
|
||||
import {
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspaceEmpty,
|
||||
} from "./new-workspace";
|
||||
import { connectSeedClient } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { createTempDirectory, createTempGitRepo } from "./workspace";
|
||||
|
||||
export interface SeededRuntimeProject {
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
projectDisplayName: string;
|
||||
sourceDirectory: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
const SETUP_MARKER = "runtime-user-setup-ran.txt";
|
||||
|
||||
export async function seedGitProjectForRuntime(
|
||||
options?: Parameters<typeof createTempGitRepo>[1],
|
||||
): Promise<SeededRuntimeProject> {
|
||||
const repo = await createTempGitRepo("runtime-selector-", {
|
||||
...options,
|
||||
paseoConfig: options?.paseoConfig ?? {
|
||||
worktree: { setup: [`printf setup > ${SETUP_MARKER}`] },
|
||||
},
|
||||
});
|
||||
return seedRuntimeProject(repo);
|
||||
}
|
||||
|
||||
async function readProbeRecords(projectId: string): Promise<Array<{ workspaceId: string }>> {
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
|
||||
const records = JSON.parse(
|
||||
await readFile(path.join(paseoHome, "projects", "provider-probes.json"), "utf8"),
|
||||
) as Array<{ workspaceId: string; projectId: string }>;
|
||||
return records.filter((record) => record.projectId === projectId);
|
||||
}
|
||||
|
||||
async function markerExists(root: string): Promise<boolean> {
|
||||
return access(path.join(root, SETUP_MARKER)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectProbeSkippedProjectSetup(project: SeededRuntimeProject): Promise<void> {
|
||||
expect(await markerExists(project.sourceDirectory)).toBe(false);
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
|
||||
const probeIds = new Set(
|
||||
(await readProbeRecords(project.projectId)).map((record) => record.workspaceId),
|
||||
);
|
||||
const stateDirectory = path.join(paseoHome, "fixture-runtime");
|
||||
const stateFiles = await readdir(stateDirectory);
|
||||
const states = await Promise.all(
|
||||
stateFiles.map(
|
||||
async (file) =>
|
||||
JSON.parse(await readFile(path.join(stateDirectory, file), "utf8")) as {
|
||||
workspaceId: string;
|
||||
root: string;
|
||||
},
|
||||
),
|
||||
);
|
||||
const probe = states.find((state) => probeIds.has(state.workspaceId));
|
||||
expect(probe, "fixture probe runtime state").toBeDefined();
|
||||
expect(await markerExists(probe!.root)).toBe(false);
|
||||
}
|
||||
|
||||
export async function expectUserWorkspaceRanProjectSetup(
|
||||
project: SeededRuntimeProject,
|
||||
): Promise<void> {
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
|
||||
const records = JSON.parse(
|
||||
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
|
||||
) as Array<{ projectId: string; workspaceId: string; runtime?: { runtimeId: string } }>;
|
||||
const workspace = records.find(
|
||||
(record) => record.projectId === project.projectId && record.runtime?.runtimeId === "fixture",
|
||||
);
|
||||
expect(workspace, "fixture user workspace record").toBeDefined();
|
||||
const stateFiles = await readdir(path.join(paseoHome, "fixture-runtime"));
|
||||
const states = await Promise.all(
|
||||
stateFiles.map(
|
||||
async (file) =>
|
||||
JSON.parse(await readFile(path.join(paseoHome, "fixture-runtime", file), "utf8")) as {
|
||||
workspaceId: string;
|
||||
root: string;
|
||||
},
|
||||
),
|
||||
);
|
||||
const state = states.find((candidate) => candidate.workspaceId === workspace!.workspaceId);
|
||||
expect(state, "fixture user runtime state").toBeDefined();
|
||||
await expect.poll(() => markerExists(state!.root), { timeout: 30_000 }).toBe(true);
|
||||
}
|
||||
|
||||
export async function expectProviderAvailable(page: Page, providerLabel: string): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: /Select model/ });
|
||||
await expect(trigger).toBeEnabled({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click();
|
||||
await page.getByText(providerLabel, { exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function selectRuntimeProviderModel(
|
||||
page: Page,
|
||||
input: { provider: string; model: string },
|
||||
): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: /Select model/ });
|
||||
await expect(trigger).toBeEnabled({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
const dialog = page.getByRole("dialog").last();
|
||||
await dialog.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await dialog.getByText(input.provider, { exact: true }).click();
|
||||
const model = dialog.getByText(input.model, { exact: true });
|
||||
await expect(model).toBeVisible({ timeout: 30_000 });
|
||||
await model.click();
|
||||
await expect(trigger).toContainText(input.model);
|
||||
}
|
||||
|
||||
export async function expectRuntimeProviderUnavailable(
|
||||
page: Page,
|
||||
providerLabel: string,
|
||||
): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: /Select model/ });
|
||||
await expect(trigger).toBeEnabled({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
const dialog = page.getByRole("dialog").last();
|
||||
await dialog.getByRole("button", { name: "Back", exact: true }).click();
|
||||
const providerId = providerLabel.toLowerCase().replaceAll(" ", "");
|
||||
const providerRow = dialog.getByTestId(`model-provider-${providerId}`);
|
||||
await expect(providerRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(providerRow).toHaveText(`${providerLabel}Error`);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function expectFixtureProviderUnavailable(page: Page): Promise<void> {
|
||||
const providerLabel = "Fixture Agent";
|
||||
const trigger = page.getByRole("button", { name: /Select model/ });
|
||||
await expect(trigger).toBeEnabled({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click();
|
||||
await page.getByText(providerLabel, { exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible();
|
||||
await expect(page.getByText(/^Fixture Model/u)).not.toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function expectProbeFailureWithRetry(page: Page, message: string): Promise<void> {
|
||||
await expect(page.getByRole("alert")).toContainText(message, { timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Select model/ })).toBeDisabled();
|
||||
await expect(page.getByText("Fixture Agent", { exact: true })).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function retryFailedProbe(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectNoProbeInWorkspaceProjection(
|
||||
page: Page,
|
||||
project: SeededRuntimeProject,
|
||||
): Promise<void> {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
try {
|
||||
const probeIds = new Set(
|
||||
(await readProbeRecords(project.projectId)).map((record) => record.workspaceId),
|
||||
);
|
||||
const workspaces = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(workspaces.entries.some((workspace) => probeIds.has(workspace.id))).toBe(false);
|
||||
for (const probeId of probeIds) {
|
||||
await expect(page.getByTestId(`sidebar-workspace-${probeId}`)).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedNonGitProjectForRuntime(): Promise<SeededRuntimeProject> {
|
||||
const directory = await createTempDirectory("runtime-selector-non-git-");
|
||||
return seedRuntimeProject(directory);
|
||||
}
|
||||
|
||||
async function seedRuntimeProject(resource: {
|
||||
path: string;
|
||||
cleanup(): Promise<void>;
|
||||
}): Promise<SeededRuntimeProject> {
|
||||
const client = await connectSeedClient();
|
||||
const added = await client.addProject(resource.path);
|
||||
if (added.error || !added.project) {
|
||||
await client.close();
|
||||
await resource.cleanup();
|
||||
throw new Error(added.error ?? "Runtime project was not added");
|
||||
}
|
||||
const listed = await client.listProjects();
|
||||
const project = listed.projects.find(
|
||||
(candidate) => candidate.projectId === added.project?.projectId,
|
||||
);
|
||||
if (!project?.projectKey) {
|
||||
await client.close();
|
||||
await resource.cleanup();
|
||||
throw new Error("Runtime project has no project key");
|
||||
}
|
||||
return {
|
||||
projectId: added.project.projectId,
|
||||
projectKey: project.projectKey,
|
||||
projectDisplayName: added.project.projectDisplayName,
|
||||
sourceDirectory: resource.path,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(added.project!.projectId);
|
||||
await client.close();
|
||||
await resource.cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function gotoNewWorkspaceForRuntime(
|
||||
page: Page,
|
||||
project: SeededRuntimeProject,
|
||||
): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: getServerId(),
|
||||
endpoint: `localhost:${getE2EDaemonPort()}`,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, project);
|
||||
await expect(page.getByRole("button", { name: "Runtime", exact: true })).toContainText("Local");
|
||||
}
|
||||
|
||||
export async function expectRuntimeChoices(page: Page, labels: readonly string[]): Promise<void> {
|
||||
await page.getByRole("button", { name: "Runtime", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog").last();
|
||||
for (const label of labels) {
|
||||
await expect(dialog.getByRole("button", { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(dialog.getByRole("button")).toHaveCount(labels.length);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function expectRuntimeSelected(page: Page, label: string): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Runtime", exact: true })).toContainText(label);
|
||||
}
|
||||
|
||||
export async function selectRuntime(page: Page, label: string): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
|
||||
await trigger.click();
|
||||
await page.getByRole("dialog").last().getByRole("button", { name: label, exact: true }).click();
|
||||
await expect(trigger).toContainText(label);
|
||||
}
|
||||
|
||||
export async function createWorkspaceInSelectedRuntime(page: Page): Promise<void> {
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceOpenInRuntime(
|
||||
page: Page,
|
||||
project: SeededRuntimeProject,
|
||||
runtimeId: string,
|
||||
): Promise<string> {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
try {
|
||||
const workspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: "",
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
|
||||
const records = JSON.parse(
|
||||
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
|
||||
) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>;
|
||||
const record = records.find((candidate) => candidate.workspaceId === workspace.workspaceId);
|
||||
expect(record?.runtime).toEqual({ runtimeId });
|
||||
return workspace.workspaceId;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectSelectedHostRuntimePlacement(
|
||||
project: SeededRuntimeProject,
|
||||
runtimeId: "local" | "worktree",
|
||||
setupRan: boolean,
|
||||
): Promise<void> {
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
|
||||
const records = JSON.parse(
|
||||
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
|
||||
) as Array<{
|
||||
projectId: string;
|
||||
cwd: string;
|
||||
hostVisiblePath?: string | null;
|
||||
runtime?: { runtimeId: string };
|
||||
}>;
|
||||
const workspace = records.find(
|
||||
(record) => record.projectId === project.projectId && record.runtime?.runtimeId === runtimeId,
|
||||
);
|
||||
expect(workspace, `${runtimeId} selected workspace record`).toBeDefined();
|
||||
expect(workspace?.hostVisiblePath).toBe(workspace?.cwd);
|
||||
if (setupRan) {
|
||||
await expect.poll(() => markerExists(workspace!.cwd), { timeout: 30_000 }).toBe(true);
|
||||
} else {
|
||||
expect(await markerExists(workspace!.cwd)).toBe(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectHostWorkspaceAffordances(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Open workspace in VS Code" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Choose editor" }).click();
|
||||
await expect(page.getByText("VS Code", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Finder", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
@@ -24,6 +24,8 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "on"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
| "removePlugin"
|
||||
| "reloadPlugin"
|
||||
>;
|
||||
|
||||
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||
@@ -219,7 +221,7 @@ export async function expectNewWorkspaceControlsEnabled(page: Page): Promise<voi
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Control+P");
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+P" : "Control+P");
|
||||
|
||||
const searchInput = page.getByPlaceholder("Search projects");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
@@ -317,35 +319,61 @@ export async function selectNewWorkspaceProject(
|
||||
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
|
||||
}
|
||||
|
||||
// The isolation trigger renders the active isolation's label ("Local" / "New
|
||||
// worktree"), so asserting its text proves what the screen currently remembers.
|
||||
const ISOLATION_TRIGGER_LABEL: Record<"local" | "worktree", string> = {
|
||||
type WorkspaceRuntimeChoice = "local" | "worktree";
|
||||
|
||||
const WORKSPACE_RUNTIME_LABEL: Record<WorkspaceRuntimeChoice, string> = {
|
||||
local: "Local",
|
||||
worktree: "New worktree",
|
||||
worktree: "Worktree",
|
||||
};
|
||||
|
||||
export async function expectWorkspaceIsolationSelected(
|
||||
export async function expectWorkspaceRuntimeSelected(
|
||||
page: Page,
|
||||
isolation: "local" | "worktree",
|
||||
runtime: WorkspaceRuntimeChoice,
|
||||
): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Workspace isolation" });
|
||||
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(trigger).toContainText(ISOLATION_TRIGGER_LABEL[isolation]);
|
||||
await expect(trigger.getByText(WORKSPACE_RUNTIME_LABEL[runtime], { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function selectWorkspaceIsolation(
|
||||
export async function selectWorkspaceRuntime(
|
||||
page: Page,
|
||||
isolation: "local" | "worktree",
|
||||
runtime: WorkspaceRuntimeChoice,
|
||||
): Promise<void> {
|
||||
const trigger = page.getByTestId("workspace-create-isolation-trigger");
|
||||
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
|
||||
// Isolation options are derived from project capability. Wait for the option
|
||||
// so this helper also covers route-to-project reconciliation.
|
||||
const option = page.getByTestId(`workspace-create-isolation-${isolation}`);
|
||||
const runtimePicker = page.getByRole("dialog").last();
|
||||
await expect(runtimePicker).toBeVisible({ timeout: 30_000 });
|
||||
const option = runtimePicker.getByRole("button", {
|
||||
name: WORKSPACE_RUNTIME_LABEL[runtime],
|
||||
exact: true,
|
||||
});
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
await option.click();
|
||||
await expectWorkspaceRuntimeSelected(page, runtime);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceRuntimeChoices(
|
||||
page: Page,
|
||||
runtimes: readonly WorkspaceRuntimeChoice[],
|
||||
): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
|
||||
const runtimePicker = page.getByRole("dialog").last();
|
||||
await expect(runtimePicker).toBeVisible({ timeout: 30_000 });
|
||||
for (const runtime of runtimes) {
|
||||
await expect(
|
||||
runtimePicker.getByRole("button", {
|
||||
name: WORKSPACE_RUNTIME_LABEL[runtime],
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(runtimePicker.getByRole("button")).toHaveCount(runtimes.length);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function submitNewWorkspaceEmpty(page: Page): Promise<void> {
|
||||
@@ -462,7 +490,7 @@ export async function pasteGithubPrUrl(
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.evaluate((value) => navigator.clipboard.writeText(value), url);
|
||||
await composer.focus();
|
||||
await page.keyboard.press("Control+V");
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
||||
}
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
@@ -474,6 +502,7 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
projectDisplayName: string;
|
||||
assertSidebarRow?: boolean;
|
||||
assertHeader?: boolean;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
|
||||
// URL is the source of truth so concurrent sidebar rows cannot satisfy this.
|
||||
@@ -483,7 +512,7 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
{ timeout: input.timeoutMs ?? 60_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
@@ -177,6 +178,19 @@ export async function expectSaveButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("save-button")).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function expectUncommittedSetupWarning(page: Page): Promise<void> {
|
||||
const warning = page.getByRole("alert").filter({ hasText: "Commit paseo.json changes" });
|
||||
await expect(warning).toContainText("Commit paseo.json changes");
|
||||
await expect(warning).toContainText(
|
||||
"New worktrees use the setup script from the base branch you select.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectNoUncommittedSetupWarning(page: Page): Promise<void> {
|
||||
const warning = page.getByRole("alert").filter({ hasText: "Commit paseo.json changes" });
|
||||
await expect(warning).toHaveCount(0);
|
||||
}
|
||||
|
||||
// --- Form-state assertions ---
|
||||
|
||||
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
|
||||
@@ -249,6 +263,11 @@ export async function restorePaseoConfig(
|
||||
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
export function commitPaseoConfig(repoPath: string): void {
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoPath });
|
||||
execFileSync("git", ["commit", "-m", "Update project config"], { cwd: repoPath });
|
||||
}
|
||||
|
||||
// The daemon writes atomically via a temp file + rename, so blocking writes requires
|
||||
// removing write permission from the *directory*, not just the file.
|
||||
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function connectDaemonWebAppOnlyThroughRelay(
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ storedHost: host, preferences: buildCreateAgentPreferences(offer.serverId) },
|
||||
{ storedHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
|
||||
const relaySocketOpened = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
observeReplicaCacheStorageWrites,
|
||||
readReplicaCacheStorageWriteObserver,
|
||||
resetReplicaCacheStorageWriteObserver,
|
||||
} from "./replica-cache-storage";
|
||||
|
||||
export interface ReplicaCachePerfReport {
|
||||
elapsedMs: number;
|
||||
redundantWrites: number;
|
||||
serializedChars: number;
|
||||
storageDurationMs: number;
|
||||
writes: number;
|
||||
}
|
||||
|
||||
interface ReplicaCachePerfState {
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__replicaCachePerf?: ReplicaCachePerfState;
|
||||
}
|
||||
}
|
||||
|
||||
export async function observeReplicaCacheWrites(page: Page): Promise<void> {
|
||||
await observeReplicaCacheStorageWrites(page);
|
||||
await page.addInitScript(() => {
|
||||
window.__replicaCachePerf = { resetAt: performance.now() };
|
||||
});
|
||||
}
|
||||
|
||||
export async function beginReplicaCacheMeasurement(page: Page): Promise<void> {
|
||||
await resetReplicaCacheStorageWriteObserver(page);
|
||||
await page.evaluate(() => {
|
||||
window.__replicaCachePerf = { resetAt: performance.now() };
|
||||
});
|
||||
}
|
||||
|
||||
export async function readReplicaCacheMeasurement(page: Page): Promise<ReplicaCachePerfReport> {
|
||||
const storage = await readReplicaCacheStorageWriteObserver(page);
|
||||
const elapsedMs = await page.evaluate(() => {
|
||||
const state = window.__replicaCachePerf;
|
||||
if (!state) throw new Error("Replica cache performance observer is not installed");
|
||||
return performance.now() - state.resetAt;
|
||||
});
|
||||
return { elapsedMs, ...storage };
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const DATABASE_NAME = "paseo-replica-cache";
|
||||
const STORE_NAME = "key-value";
|
||||
const STORAGE_KEY = "@paseo:replica-cache";
|
||||
|
||||
export interface ReplicaCacheRecord {
|
||||
version?: number;
|
||||
hosts?: Array<{
|
||||
timeline?: {
|
||||
agentId?: string;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
range?: {
|
||||
endSeq?: number;
|
||||
epoch?: string;
|
||||
startSeq?: number;
|
||||
} | null;
|
||||
} | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ReplicaCacheWriteObserverState {
|
||||
lastValue: string | null;
|
||||
redundantWrites: number;
|
||||
serializedChars: number;
|
||||
storageDurationMs: number;
|
||||
writes: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__replicaCacheWriteObserver?: ReplicaCacheWriteObserverState;
|
||||
}
|
||||
}
|
||||
|
||||
async function readStoredValue(input: {
|
||||
databaseName: string;
|
||||
storeName: string;
|
||||
storageKey: string;
|
||||
}): Promise<string | null> {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(input.databaseName, 1);
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = database
|
||||
.transaction(input.storeName, "readonly")
|
||||
.objectStore(input.storeName)
|
||||
.get(input.storageKey);
|
||||
request.addEventListener("success", () =>
|
||||
resolve(typeof request.result === "string" ? request.result : null),
|
||||
);
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
}
|
||||
|
||||
export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord | null> {
|
||||
const raw = await page.evaluate(readStoredValue, {
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
});
|
||||
return raw ? (JSON.parse(raw) as ReplicaCacheRecord) : null;
|
||||
}
|
||||
|
||||
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
|
||||
await page.evaluate(
|
||||
async ({ databaseName, storeName, storageKey, raw }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, 1);
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, "readwrite");
|
||||
transaction.objectStore(storeName).put(raw, storageKey);
|
||||
transaction.addEventListener("complete", () => resolve());
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
transaction.addEventListener("abort", () => reject(transaction.error));
|
||||
});
|
||||
},
|
||||
{
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
raw: JSON.stringify(value),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function observeReplicaCacheStorageWrites(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ databaseName, storeName, storageKey }) => {
|
||||
window.__replicaCacheWriteObserver = {
|
||||
lastValue: null,
|
||||
redundantWrites: 0,
|
||||
serializedChars: 0,
|
||||
storageDurationMs: 0,
|
||||
writes: 0,
|
||||
};
|
||||
const originalPut = IDBObjectStore.prototype.put;
|
||||
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
|
||||
const request =
|
||||
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
|
||||
if (
|
||||
this.transaction.db.name !== databaseName ||
|
||||
this.name !== storeName ||
|
||||
key !== storageKey ||
|
||||
typeof value !== "string"
|
||||
) {
|
||||
return request;
|
||||
}
|
||||
const startedAt = performance.now();
|
||||
request.addEventListener("success", () => {
|
||||
const state = window.__replicaCacheWriteObserver;
|
||||
if (!state) return;
|
||||
if (state.lastValue === value) state.redundantWrites += 1;
|
||||
state.lastValue = value;
|
||||
state.writes += 1;
|
||||
state.serializedChars += value.length;
|
||||
state.storageDurationMs += performance.now() - startedAt;
|
||||
});
|
||||
return request;
|
||||
};
|
||||
},
|
||||
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, storageKey: STORAGE_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise<void> {
|
||||
const lastValue = await page.evaluate(readStoredValue, {
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
});
|
||||
await page.evaluate((value) => {
|
||||
window.__replicaCacheWriteObserver = {
|
||||
lastValue: value,
|
||||
redundantWrites: 0,
|
||||
serializedChars: 0,
|
||||
storageDurationMs: 0,
|
||||
writes: 0,
|
||||
};
|
||||
}, lastValue);
|
||||
}
|
||||
|
||||
export async function readReplicaCacheStorageWriteObserver(
|
||||
page: Page,
|
||||
): Promise<ReplicaCacheWriteObserverState> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__replicaCacheWriteObserver;
|
||||
if (!state) throw new Error("Replica cache write observer is not installed");
|
||||
return state;
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export interface SeedDaemonClient {
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
||||
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
|
||||
createWorkspace(input: {
|
||||
source:
|
||||
@@ -142,6 +143,7 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
cancelAgent(agentId: string): Promise<void>;
|
||||
refreshAgent(agentId: string): Promise<unknown>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
@@ -172,6 +174,10 @@ export interface SeedDaemonClient {
|
||||
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||
): () => void;
|
||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||
listWorkspaceScripts(workspaceId: string): Promise<{
|
||||
scripts: Array<{ lifecycle: "running" | "stopped"; scriptName: string }>;
|
||||
}>;
|
||||
stopWorkspaceScript(workspaceId: string, scriptName: string): Promise<{ error?: string | null }>;
|
||||
}
|
||||
|
||||
export async function connectSeedClient(options?: {
|
||||
|
||||
@@ -36,9 +36,12 @@ type HostSection =
|
||||
| "connections"
|
||||
| "pair-device"
|
||||
| "agents"
|
||||
| "metadata"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
| "usage"
|
||||
| "terminals"
|
||||
| "plugins"
|
||||
| "host";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
@@ -130,7 +133,7 @@ export async function seedSavedSettingsHosts(
|
||||
if (!firstHost) {
|
||||
throw new Error("Expected at least one settings host fixture.");
|
||||
}
|
||||
const preferences = buildCreateAgentPreferences(firstHost.serverId);
|
||||
const preferences = buildCreateAgentPreferences();
|
||||
|
||||
await page.evaluate(
|
||||
({ keys, storedRegistry, storedPreferences }) => {
|
||||
|
||||
@@ -106,7 +106,7 @@ class StartupScenario {
|
||||
if (!firstHost) {
|
||||
throw new Error("Expected at least one startup test host.");
|
||||
}
|
||||
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
|
||||
const createAgentPreferences = buildStoredCreateAgentPreferences();
|
||||
|
||||
await this.page.evaluate(
|
||||
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
|
||||
@@ -245,6 +245,6 @@ function buildStoredHost(input: {
|
||||
return buildSeededHost(input);
|
||||
}
|
||||
|
||||
function buildStoredCreateAgentPreferences(serverId: string) {
|
||||
return buildCreateAgentPreferences(serverId);
|
||||
function buildStoredCreateAgentPreferences() {
|
||||
return buildCreateAgentPreferences();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
export interface ManagedSubagentArchiveGate {
|
||||
release(): void;
|
||||
waitForRequest(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ManagedSubagentArchiveRejection {
|
||||
waitForRejection(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SeededSubagentPair {
|
||||
parent: {
|
||||
id: string;
|
||||
@@ -132,6 +144,147 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectManagedSubagentUnarchived(
|
||||
workspace: Pick<SeededWorkspace, "client">,
|
||||
childId: string,
|
||||
): Promise<void> {
|
||||
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
|
||||
agent: { id: childId, archivedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectManagedSubagentArchived(
|
||||
workspace: Pick<SeededWorkspace, "client">,
|
||||
childId: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() => workspace.client.fetchAgent({ agentId: childId }), { timeout: 30_000 })
|
||||
.toMatchObject({ agent: { id: childId, archivedAt: expect.any(String) } });
|
||||
}
|
||||
|
||||
export async function archiveFinishedSubagents(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Archive finished subagents" }).click();
|
||||
}
|
||||
|
||||
export async function expectArchiveFinishedInProgress(
|
||||
page: Page,
|
||||
completed: number,
|
||||
total: number,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
|
||||
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectArchiveFinishedRetry(
|
||||
page: Page,
|
||||
failed: number,
|
||||
total: number,
|
||||
): Promise<void> {
|
||||
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function holdManagedSubagentArchiveRequest(
|
||||
page: Page,
|
||||
subagentId: string,
|
||||
): Promise<ManagedSubagentArchiveGate> {
|
||||
let released = false;
|
||||
let resolveRequest: (() => void) | undefined;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
const request = new Promise<void>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
if (isArchiveRequestForSubagent(message, subagentId) && !released) {
|
||||
delayedForwards.push(() => server.send(message));
|
||||
resolveRequest?.();
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
released = true;
|
||||
for (const forward of delayedForwards.splice(0)) forward();
|
||||
},
|
||||
waitForRequest: () => request,
|
||||
};
|
||||
}
|
||||
|
||||
export async function rejectNextManagedSubagentArchiveRequest(
|
||||
page: Page,
|
||||
subagentId: string,
|
||||
): Promise<ManagedSubagentArchiveRejection> {
|
||||
let resolveRejection: (() => void) | undefined;
|
||||
const rejection = new Promise<void>((resolve) => {
|
||||
resolveRejection = resolve;
|
||||
});
|
||||
let rejected = false;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const requestId = archiveRequestId(message, subagentId);
|
||||
if (requestId && !rejected) {
|
||||
rejected = true;
|
||||
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
|
||||
// correlated error travels back through the real browser client and mutation rollback.
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "archive_agent_request",
|
||||
error: "Archive rejected for test",
|
||||
code: "archive_rejected",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
resolveRejection?.();
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
|
||||
return { waitForRejection: () => rejection };
|
||||
}
|
||||
|
||||
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
|
||||
return archiveRequestId(message, subagentId) !== null;
|
||||
}
|
||||
|
||||
function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(rawMessage) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "archive_agent_request" &&
|
||||
envelope.message.agentId === subagentId &&
|
||||
typeof envelope.message.requestId === "string"
|
||||
) {
|
||||
return envelope.message.requestId;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
|
||||
const row = page.getByTestId(`subagents-track-row-${childId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function openCompletedTaskList(page: Page, count: number): Promise<void> {
|
||||
const taskButton = page.getByRole("button", { name: `${count}/${count} tasks` });
|
||||
await expect(taskButton).toBeVisible({ timeout: 60_000 });
|
||||
await taskButton.click();
|
||||
}
|
||||
|
||||
export async function expectTaskListEntries(page: Page, entries: readonly string[]): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
await expect(page.getByLabel(entry, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectCompletedTaskActivity(
|
||||
page: Page,
|
||||
entries: readonly string[],
|
||||
): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Completed ${entry}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { readReplicaCache } from "./replica-cache-storage";
|
||||
import {
|
||||
delayAgentBootstrapTailResponse,
|
||||
delayAgentOlderTimelineResponse,
|
||||
@@ -39,6 +40,12 @@ interface TimelineViewportSnapshot {
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
interface PersistedCanonicalTimelineRange {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export interface TimelinePresentationSnapshot {
|
||||
marker: string;
|
||||
position: TimelinePromptPositionSnapshot;
|
||||
@@ -284,28 +291,45 @@ export async function reloadAgentTimelineFromPersistedReplica(
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((agentId) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{
|
||||
timeline?: {
|
||||
agentId?: string;
|
||||
items?: unknown[];
|
||||
} | null;
|
||||
}>;
|
||||
};
|
||||
const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline;
|
||||
return timeline?.items?.length === 50;
|
||||
}, agent.agentId),
|
||||
)
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
const timeline = cache?.hosts?.find(
|
||||
(host) => host.timeline?.agentId === agent.agentId,
|
||||
)?.timeline;
|
||||
return timeline?.items?.length === 50;
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
}
|
||||
|
||||
export async function waitForPersistedCanonicalTimelineRange(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<PersistedCanonicalTimelineRange> {
|
||||
const readRange = async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (cache?.version !== 6) return null;
|
||||
const range = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline?.range;
|
||||
if (
|
||||
typeof range?.epoch !== "string" ||
|
||||
typeof range.startSeq !== "number" ||
|
||||
typeof range.endSeq !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq };
|
||||
};
|
||||
|
||||
await expect.poll(readRange).not.toBeNull();
|
||||
const range = await readRange();
|
||||
if (!range) {
|
||||
throw new Error(`Persisted canonical timeline range is missing for ${agentId}`);
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
export async function holdNextOlderTimelinePage(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
|
||||
@@ -40,12 +40,20 @@ export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): Timeli
|
||||
};
|
||||
}
|
||||
|
||||
export function expectOneTailCheckWithoutCatchUpPages(
|
||||
export function expectOneResumeCheckWithoutTail(
|
||||
gate: DaemonWebSocketGate,
|
||||
before: TimelineRequestCounts,
|
||||
): void {
|
||||
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
|
||||
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0);
|
||||
}
|
||||
|
||||
export function expectResumeOverflowFallsBackToOneTail(
|
||||
gate: DaemonWebSocketGate,
|
||||
before: TimelineRequestCounts,
|
||||
): void {
|
||||
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
|
||||
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1);
|
||||
expect(gate.getTimelineRequestCount("after") - before.after).toBe(0);
|
||||
}
|
||||
|
||||
export async function disconnectViewedTimeline(
|
||||
@@ -56,7 +64,7 @@ export async function disconnectViewedTimeline(
|
||||
await expectReconnectingToastVisible(page);
|
||||
}
|
||||
|
||||
export async function restoreViewedTimelineWithHeldTail(
|
||||
export async function restoreViewedTimelineWithHeldResponse(
|
||||
page: Page,
|
||||
gate: DaemonWebSocketGate,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -29,7 +29,8 @@
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
@@ -47,6 +48,8 @@
|
||||
"@getpaseo/protocol": "*",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@mattermost/react-native-paste-input": "2.0.1",
|
||||
"@paseo/plugin": "*",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
@@ -92,6 +95,7 @@
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
@@ -117,6 +121,7 @@
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"use-sync-external-store": "^1.6.0",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
@@ -134,6 +139,7 @@
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"dotenv": "^17.2.3",
|
||||
"eas-cli": "^16.24.1",
|
||||
"esbuild": "0.28.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const { withGradleProperties } = require("expo/config-plugins");
|
||||
|
||||
const ASYNC_STORAGE_SIZE_PROPERTY = "AsyncStorage_db_size_in_MB";
|
||||
|
||||
function withAndroidAsyncStorageSize(config, sizeInMegabytes) {
|
||||
return withGradleProperties(config, (modConfig) => {
|
||||
const existingProperty = modConfig.modResults.find(
|
||||
(property) => property.type === "property" && property.key === ASYNC_STORAGE_SIZE_PROPERTY,
|
||||
);
|
||||
if (existingProperty?.type === "property") {
|
||||
existingProperty.value = String(sizeInMegabytes);
|
||||
return modConfig;
|
||||
}
|
||||
|
||||
modConfig.modResults.push({
|
||||
type: "property",
|
||||
key: ASYNC_STORAGE_SIZE_PROPERTY,
|
||||
value: String(sizeInMegabytes),
|
||||
});
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withAndroidAsyncStorageSize;
|
||||
@@ -0,0 +1,82 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
IOSConfig,
|
||||
withAppDelegate,
|
||||
withDangerousMod,
|
||||
withPodfile,
|
||||
} = require("expo/config-plugins");
|
||||
|
||||
const MODULE_SETUP = " PasteInputModule.setup(factory.rootViewFactory)";
|
||||
const REACT_NATIVE_START_END = " launchOptions: launchOptions)\n#endif";
|
||||
const BRIDGING_HEADER_IMPORT = "#import <react-native-paste-input/PasteInputModule.h>";
|
||||
const PODFILE_NEW_ARCH_DISABLED =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
|
||||
const PODFILE_NEW_ARCH_ENABLED =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'";
|
||||
|
||||
function configurePasteInputAppDelegate(contents) {
|
||||
const configured = contents.replace(`\n${MODULE_SETUP}`, "");
|
||||
if (!configured.includes(REACT_NATIVE_START_END)) {
|
||||
throw new Error("Could not register paste input after React Native starts");
|
||||
}
|
||||
return configured.replace(
|
||||
REACT_NATIVE_START_END,
|
||||
` launchOptions: launchOptions)\n${MODULE_SETUP}\n#endif`,
|
||||
);
|
||||
}
|
||||
|
||||
function configurePasteInputBridgingHeader(contents) {
|
||||
if (contents.includes(BRIDGING_HEADER_IMPORT)) {
|
||||
return contents;
|
||||
}
|
||||
return `${contents.trimEnd()}\n${BRIDGING_HEADER_IMPORT}\n`;
|
||||
}
|
||||
|
||||
function configurePasteInputPodfile(contents) {
|
||||
if (contents.includes(PODFILE_NEW_ARCH_ENABLED)) {
|
||||
return contents;
|
||||
}
|
||||
if (!contents.includes(PODFILE_NEW_ARCH_DISABLED)) {
|
||||
throw new Error("Could not configure the new architecture for paste input");
|
||||
}
|
||||
return contents.replace(
|
||||
PODFILE_NEW_ARCH_DISABLED,
|
||||
`${PODFILE_NEW_ARCH_DISABLED}\n${PODFILE_NEW_ARCH_ENABLED}`,
|
||||
);
|
||||
}
|
||||
|
||||
function withPasteInput(config) {
|
||||
const withDelegate = withAppDelegate(config, (modConfig) => {
|
||||
if (modConfig.modResults.language !== "swift") {
|
||||
throw new Error("Paste input setup requires a Swift AppDelegate");
|
||||
}
|
||||
modConfig.modResults.contents = configurePasteInputAppDelegate(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
const withPods = withPodfile(withDelegate, (modConfig) => {
|
||||
modConfig.modResults.contents = configurePasteInputPodfile(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
return withPasteInputBridgingHeader(withPods);
|
||||
}
|
||||
|
||||
function withPasteInputBridgingHeader(config) {
|
||||
return withDangerousMod(config, [
|
||||
"ios",
|
||||
async (modConfig) => {
|
||||
const projectRoot = modConfig.modRequest.projectRoot;
|
||||
const sourceRoot = IOSConfig.Paths.getSourceRoot(projectRoot);
|
||||
const projectName = IOSConfig.XcodeUtils.getProjectName(projectRoot);
|
||||
const headerPath = path.join(sourceRoot, `${projectName}-Bridging-Header.h`);
|
||||
const contents = fs.readFileSync(headerPath, "utf8");
|
||||
fs.writeFileSync(headerPath, configurePasteInputBridgingHeader(contents));
|
||||
return modConfig;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
module.exports = withPasteInput;
|
||||
module.exports.configurePasteInputAppDelegate = configurePasteInputAppDelegate;
|
||||
module.exports.configurePasteInputBridgingHeader = configurePasteInputBridgingHeader;
|
||||
module.exports.configurePasteInputPodfile = configurePasteInputPodfile;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
configurePasteInputAppDelegate,
|
||||
configurePasteInputBridgingHeader,
|
||||
configurePasteInputPodfile,
|
||||
} = require("./with-paste-input");
|
||||
|
||||
describe("withPasteInput", () => {
|
||||
it("registers the module after React Native starts", () => {
|
||||
const source = [
|
||||
"import ReactAppDependencyProvider",
|
||||
"",
|
||||
" bindReactNativeFactory(factory)",
|
||||
" factory.startReactNative(",
|
||||
' withModuleName: "main",',
|
||||
" in: window,",
|
||||
" launchOptions: launchOptions)",
|
||||
"#endif",
|
||||
].join("\n");
|
||||
const configured = configurePasteInputAppDelegate(source);
|
||||
|
||||
expect(configured).toContain(
|
||||
" launchOptions: launchOptions)\n PasteInputModule.setup(factory.rootViewFactory)\n#endif",
|
||||
);
|
||||
expect(configurePasteInputAppDelegate(configured)).toBe(configured);
|
||||
});
|
||||
|
||||
it("exposes the Objective-C module to Swift through the bridging header", () => {
|
||||
const source = "// Swift bridging header\n";
|
||||
const configured = configurePasteInputBridgingHeader(source);
|
||||
|
||||
expect(configured).toContain("#import <react-native-paste-input/PasteInputModule.h>");
|
||||
expect(configurePasteInputBridgingHeader(configured)).toBe(configured);
|
||||
});
|
||||
|
||||
it("sets the new architecture environment before CocoaPods evaluates dependencies", () => {
|
||||
const source =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
|
||||
const configured = configurePasteInputPodfile(source);
|
||||
|
||||
expect(configured).toContain(
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'",
|
||||
);
|
||||
expect(configurePasteInputPodfile(configured)).toBe(configured);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Agent profiles: named bundles of composer settings (provider, model, mode,
|
||||
* thinking option, feature values, notes) stored host-wide in daemon config.
|
||||
*
|
||||
* Four capabilities leave this module — managing the list in settings, reading
|
||||
* it, pinning it to the model picker, and drawing a profile's glyph. Everything
|
||||
* else (the form model, the catalog and feature probes, the materialization
|
||||
* rules, the icon registry, the row and modal chrome) is internal; import from
|
||||
* `@/agent-profiles`, never a path inside it.
|
||||
*
|
||||
* `useAgentProfilePicker` deliberately hands the picker a flat row view model
|
||||
* and one `applyProfile(id)` callback rather than the profiles themselves: what
|
||||
* a profile contains, and how it reaches a live agent versus a draft, stays in
|
||||
* here.
|
||||
*/
|
||||
export type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
export type { MaterializedAgentProfile } from "./internal/materialize-profile";
|
||||
export { useAgentProfiles } from "./internal/use-agent-profiles";
|
||||
export {
|
||||
useAgentProfilePicker,
|
||||
type AgentProfileApplyTarget,
|
||||
type AgentProfilePicker,
|
||||
type AgentProfilePickerRow,
|
||||
type DraftAgentProfileControls,
|
||||
} from "./internal/use-agent-profile-picker";
|
||||
export { AgentProfileGlyph } from "./internal/agent-profile-glyph";
|
||||
export { AgentProfilesSection } from "./settings/agent-profiles-section";
|
||||
@@ -0,0 +1,128 @@
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Boxes,
|
||||
Brain,
|
||||
Bug,
|
||||
Cloud,
|
||||
Code,
|
||||
Compass,
|
||||
Cpu,
|
||||
Database,
|
||||
Eye,
|
||||
Feather,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Hammer,
|
||||
Layers,
|
||||
Microscope,
|
||||
Package,
|
||||
Palette,
|
||||
Pencil,
|
||||
Rocket,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
TestTube,
|
||||
Wrench,
|
||||
} from "lucide-react-native";
|
||||
import { identityForeground } from "@/styles/identity-colors";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
AGENT_PROFILE_COLORS,
|
||||
resolveAgentProfileColor,
|
||||
resolveAgentProfileIconKey,
|
||||
type AgentProfileColor,
|
||||
type AgentProfileIconKey,
|
||||
} from "./profile-appearance";
|
||||
|
||||
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
||||
const ThemedDefaultIcon = withUnistyles(Bot);
|
||||
|
||||
/**
|
||||
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
||||
* stores the themed component rather than the raw lucide one.
|
||||
*/
|
||||
const THEMED_ICONS: Record<AgentProfileIconKey, typeof ThemedDefaultIcon> = {
|
||||
code: withUnistyles(Code),
|
||||
terminal: withUnistyles(Terminal),
|
||||
bug: withUnistyles(Bug),
|
||||
wrench: withUnistyles(Wrench),
|
||||
hammer: withUnistyles(Hammer),
|
||||
|
||||
flask: withUnistyles(FlaskConical),
|
||||
testTube: withUnistyles(TestTube),
|
||||
microscope: withUnistyles(Microscope),
|
||||
search: withUnistyles(Search),
|
||||
eye: withUnistyles(Eye),
|
||||
|
||||
palette: withUnistyles(Palette),
|
||||
feather: withUnistyles(Feather),
|
||||
pencil: withUnistyles(Pencil),
|
||||
fileText: withUnistyles(FileText),
|
||||
book: withUnistyles(BookOpen),
|
||||
|
||||
rocket: withUnistyles(Rocket),
|
||||
package: withUnistyles(Package),
|
||||
boxes: withUnistyles(Boxes),
|
||||
server: withUnistyles(Server),
|
||||
database: withUnistyles(Database),
|
||||
|
||||
cpu: withUnistyles(Cpu),
|
||||
cloud: withUnistyles(Cloud),
|
||||
globe: withUnistyles(Globe),
|
||||
gitBranch: withUnistyles(GitBranch),
|
||||
layers: withUnistyles(Layers),
|
||||
|
||||
compass: withUnistyles(Compass),
|
||||
brain: withUnistyles(Brain),
|
||||
sparkles: withUnistyles(Sparkles),
|
||||
shield: withUnistyles(Shield),
|
||||
};
|
||||
|
||||
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/**
|
||||
* One `uniProps` mapping per colour, built once. Building these inline would
|
||||
* hand `withUnistyles` a new function every render and defeat its tracking.
|
||||
*/
|
||||
const COLOR_MAPPINGS: Record<AgentProfileColor, (theme: Theme) => { color: string }> = (() => {
|
||||
const byColor = {} as Record<AgentProfileColor, (theme: Theme) => { color: string }>;
|
||||
for (const color of AGENT_PROFILE_COLORS) {
|
||||
byColor[color] =
|
||||
color === "none"
|
||||
? mutedMapping
|
||||
: (theme: Theme) => ({ color: identityForeground(color, theme.colorScheme) });
|
||||
}
|
||||
return byColor;
|
||||
})();
|
||||
|
||||
export function agentProfileColorMapping(
|
||||
color: AgentProfileColor,
|
||||
): (theme: Theme) => { color: string } {
|
||||
return COLOR_MAPPINGS[color];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a profile is drawn everywhere it appears — settings list, model picker,
|
||||
* and the picker grid's own cells. One component so the three can't drift.
|
||||
*/
|
||||
export function AgentProfileGlyph({
|
||||
icon,
|
||||
color,
|
||||
size = ICON_SIZE.md,
|
||||
}: {
|
||||
icon?: string | undefined;
|
||||
color?: string | undefined;
|
||||
size?: number;
|
||||
}) {
|
||||
const iconKey = resolveAgentProfileIconKey(icon);
|
||||
const mapping = COLOR_MAPPINGS[resolveAgentProfileColor(color)];
|
||||
const Icon = iconKey ? THEMED_ICONS[iconKey] : ThemedDefaultIcon;
|
||||
return <Icon size={size} uniProps={mapping} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user