mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(sessions): drafts UX — create-as-draft, promote, admin ghost treatment (#113127)
* feat(protocol): support draft session creation * feat(ui): add draft session workflows * docs: explain multi-user drafts * test(ui): tighten draft ownership fixture * test(ui): satisfy draft E2E lint * test(ui): keep draft fixtures strictly typed * docs: add drafts implementation report * docs: clarify implementation LOC summary * test: strengthen draft compatibility coverage * docs: correct drafts cleanup report * fix: harden draft availability policy * docs: update draft policy review notes * test(ui): type draft policy mock control * fix: preserve keyed draft creation retries * fix: keep disabled draft retries idempotent * docs: finalize drafts cleanup report * fix(ui): keep draft ownership helper internal * docs: refresh drafts docs map
This commit is contained in:
committed by
GitHub
parent
95b86a3b2b
commit
cbf94c0d8b
@@ -1,357 +1,188 @@
|
|||||||
# W5 archive attribution report
|
# W4 Drafts UX implementation report
|
||||||
|
|
||||||
Implementation commit: `083482ea654 feat(sessions): attribute archive transitions`
|
## Outcome
|
||||||
|
|
||||||
Alias-rollback follow-up: `14230f2e84a fix(sessions): restore alias layout after archive audit failure`
|
Implemented the complete drafts UX on top of the landed ownership, visibility, membership, and person-filter machinery.
|
||||||
|
|
||||||
Final rollback redesign: `b5cc8061a9b fix(sessions): isolate archive audit rollback`
|
- New-session creation can start atomically as `visibility: "draft"`; the initial durable session entry carries draft visibility before the create response or any session-change broadcast.
|
||||||
|
- The create affordance is exposed only when the hello policy allows drafts and the Gateway reports at least two canonical, non-merged sharing identities. Older gateways and solo mode fail closed by hiding the control.
|
||||||
Final best-effort policy: `320237964e0 fix(sessions): make archive audit note best-effort`
|
- Own drafts keep normal row emphasis with a subtle ghost marker. Admin-visible drafts owned by someone else use the same draft class family with a faded, light/dark-safe treatment.
|
||||||
|
- A dedicated **Publish draft** header menu item calls the existing visibility callback with `"shared"`, which continues through the landed `session.visibility.set` RPC, audit-line, rollback, and live-event path.
|
||||||
|
- Multi-user and sharing configuration docs now explain create-as-draft behavior, admin visibility, and the non-security-boundary constraint.
|
||||||
|
- No suggestion queue, invite link, config key, SQLite schema/table/version, membership enforcement, new Gateway event, or changelog change was added.
|
||||||
|
|
||||||
## Files changed
|
## Files changed
|
||||||
|
|
||||||
Session state and mutation:
|
### Protocol and Gateway
|
||||||
|
|
||||||
- `src/config/sessions/session-entry-provenance.ts`
|
- `packages/gateway-protocol/src/schema/sessions-create.ts`
|
||||||
- `src/config/sessions/types.ts`
|
- `packages/gateway-protocol/src/schema/sessions-create.test.ts`
|
||||||
- `src/gateway/sessions-patch.ts`
|
- `packages/gateway-protocol/src/schema/frames.ts`
|
||||||
- `src/gateway/server-methods/sessions-mutations.ts`
|
- `src/gateway/session-create-service.ts`
|
||||||
- `src/gateway/server-methods/session-audit.ts`
|
- `src/gateway/server-methods/sessions-create.ts`
|
||||||
- `src/gateway/server-methods/sessions-sharing.ts`
|
- `src/gateway/server/ws-connection/connect-hello.ts`
|
||||||
- `src/plugins/session-entry-slot-keys.ts`
|
- `src/gateway/server.sessions.create.test.ts`
|
||||||
|
- `src/gateway/server.auth.default-token.suite.ts`
|
||||||
Gateway projection and event contract:
|
|
||||||
|
|
||||||
- `packages/gateway-protocol/src/schema/sessions-row.ts`
|
|
||||||
- `src/gateway/session-utils.ts`
|
|
||||||
- `src/gateway/session-utils.types.ts`
|
|
||||||
- `src/gateway/session-event-payload.ts`
|
|
||||||
- `apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift`
|
- `apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift`
|
||||||
|
|
||||||
Control UI:
|
The Kotlin generators ran successfully; their tracked outputs were byte-identical. `dist/protocol.schema.json` is generated locally by protocol checks but remains ignored and untracked, matching `origin/main`.
|
||||||
|
|
||||||
- `ui/src/api/types.ts`
|
### Control UI
|
||||||
|
|
||||||
|
- `ui/src/pages/new-session/new-session-page.ts`
|
||||||
|
- `ui/src/pages/new-session/create-params.ts`
|
||||||
|
- `ui/src/pages/new-session/create-params.test.ts`
|
||||||
|
- `ui/src/pages/new-session/catalog-target.ts`
|
||||||
|
- `ui/src/pages/new-session/target-controls.ts`
|
||||||
|
- `ui/src/pages/new-session/target-controls.test.ts`
|
||||||
|
- `ui/src/pages/chat/components/chat-session-sharing.ts`
|
||||||
|
- `ui/src/pages/chat/components/chat-session-sharing.test.ts`
|
||||||
- `ui/src/components/app-sidebar-session-navigation-logic.ts`
|
- `ui/src/components/app-sidebar-session-navigation-logic.ts`
|
||||||
|
- `ui/src/components/app-sidebar-session-navigation-logic.test.ts`
|
||||||
- `ui/src/components/app-sidebar-session-row-render.ts`
|
- `ui/src/components/app-sidebar-session-row-render.ts`
|
||||||
- `ui/src/components/app-sidebar-session-types.ts`
|
- `ui/src/components/app-sidebar-session-types.ts`
|
||||||
- `ui/src/components/session-owner-chip.ts`
|
- `ui/src/styles/components.css`
|
||||||
|
- `ui/src/styles/new-session.css`
|
||||||
- `ui/src/i18n/locales/en.ts`
|
- `ui/src/i18n/locales/en.ts`
|
||||||
- `ui/src/lib/sessions/reconcile.ts`
|
- `ui/src/e2e/session-ownership.e2e.test.ts`
|
||||||
|
- `ui/src/test-helpers/control-ui-e2e.ts`
|
||||||
|
|
||||||
Behavior tests:
|
### Docs
|
||||||
|
|
||||||
- `src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts`
|
- `docs/concepts/multi-user.md`
|
||||||
- `src/gateway/sessions-patch.test.ts`
|
- `docs/gateway/config-agents.md`
|
||||||
- `src/gateway/session-utils-creators.test.ts`
|
|
||||||
- `src/gateway/session-event-payload.test.ts`
|
|
||||||
- `packages/gateway-protocol/src/schema/sessions-row.test.ts`
|
|
||||||
- `ui/src/lib/sessions/reconcile.test.ts`
|
|
||||||
- `ui/src/test-helpers/app-sidebar-cases/session-ownership.ts`
|
|
||||||
|
|
||||||
Task handoff:
|
### Local proof artifacts, not committed
|
||||||
|
|
||||||
- `REPORT.md`
|
- `.artifacts/control-ui-e2e/drafts-ux/behavior-contract.md`
|
||||||
|
- `.artifacts/control-ui-e2e/drafts-ux/01-sidebar-draft-treatment.png`
|
||||||
|
- `.artifacts/control-ui-e2e/drafts-ux/01-sidebar-draft-treatment-dark.png`
|
||||||
|
- `.artifacts/control-ui-e2e/drafts-ux/02-create-draft-available.png`
|
||||||
|
- `.artifacts/control-ui-e2e/drafts-ux/03-create-draft-selected.png`
|
||||||
|
- `.artifacts/control-ui-e2e/drafts-ux/04-publish-draft-action.png`
|
||||||
|
- `.artifacts/control-ui-e2e/drafts-ux/*.webm`
|
||||||
|
|
||||||
## Key decisions and resolved ambiguities
|
## Key decisions and ambiguity resolution
|
||||||
|
|
||||||
- `archivedBy` uses the canonical `SessionCreatedActor` shape and is written only on a real unarchived-to-archived transition. Repeating `archived: true` preserves the original archiver. Unarchive clears it.
|
1. `visibility` is optional and additive in `SessionsCreateParamsSchema`. Omission preserves the prior storage shape and projects as `shared`. New rows reject a disallowed visibility with `SESSION_VISIBILITY_DISABLED`; keyed adoption is allowed only when the requested visibility exactly matches the existing effective visibility, including after policy changes. Mismatches and in-place resets remain rejected.
|
||||||
- The acting identity comes only from `gatewayClientSessionCreator(client)`. Unidentified token/password clients get no fabricated identity and no actor-stamped audit line.
|
2. The hello `policy` object now exposes optional `allowedSessionVisibilities` and `hasMultipleSessionSharingIdentities`. Current Gateways always populate both. Optional schema fields preserve compatibility with older Gateway/client pairs.
|
||||||
- Archive and unarchive audit notes use the same shared `SessionManager.appendMessage` helper as visibility/membership audit notes. Archive notes are deliberately best-effort: if append fails, the durable archive remains committed, the RPC succeeds, and `sessionLog.warn` records the lost note. Visibility audit remains rollback-on-failure because it protects an access boundary.
|
3. The multi-user boolean comes from canonical non-merged user profiles, not loaded session creators. It reveals only whether the draft UI's two-identity threshold is met, not the exact profile count.
|
||||||
- The existing `applySessionPatchProjection` candidate resolver already supplies the freshest authoritative alias entry. Archive transition comparison uses that `existingEntry`, so alias migration does not invent or suppress a transition.
|
4. The new-session UI rechecks the hello policy at submit time and clears a checked draft selection as soon as policy/identity availability disappears. A hidden prior choice cannot silently reactivate.
|
||||||
- The session catalog is stored as canonical `session_entries.entry_json`; `archivedBy` is an additive JSON field. No SQL DDL, schema-version bump, backfill, dual path, or migration is needed.
|
5. Admin ownership styling compares the session creator id with the current authenticated user id because `sharingRole: "admin"` intentionally wins over `"owner"` for administrators.
|
||||||
- `sessions.changed` remains the existing event and is still emitted with its concrete session-key scope, so draft/visibility filtering remains in force. No new event or `EVENT_SCOPE_GUARDS` entry was needed.
|
6. Promotion remains one existing `session.visibility.set` call with `visibility: "shared"`; no parallel publish method, event, audit path, or invite workflow was introduced.
|
||||||
- In the exact View-archived filter, the existing owner chip renders `archivedBy` with an `Archived by {name}` accessible label. Other session lists retain creator attribution. The existing `sessionOwnershipVisible` gate suppresses this chrome with fewer than two creator identities.
|
7. The sidebar's pending unsent-composer `showDraft`/`renderDraftSessionRow` machinery was not touched.
|
||||||
- All protocol generators ran. Swift changed. Kotlin did not change because `scripts/protocol-gen-kotlin.ts` does not include `SessionRow` in its emitted schema whitelist. `dist/protocol.schema.json` was generated and checked, but current main intentionally ignores and does not track `dist/`; force-adding the 1.9 MB ignored artifact would undo repository policy.
|
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Dependency install:
|
All final code commands below ran against implementation HEAD `0c755a401b47a9c12f61d8a01b49017f3f938e2d`, rebased onto `977db1c83261b13e92e7ad74c5f544e61acf1b90`. `origin/main` advanced by six commits during the final proof; no further moving-base rebase was attempted after the successful gate.
|
||||||
|
|
||||||
```text
|
### Install and formatting
|
||||||
pnpm install
|
|
||||||
Done in 19.9s using pnpm v11.2.2 (exit 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
Focused behavior tests:
|
- `pnpm install`
|
||||||
|
- Initial fresh install: exit 0, 1,272 packages, pnpm 11.2.2.
|
||||||
|
- Final post-rebase refresh: exit 0 using pnpm 11.15.1; lockfile supply-chain policies passed and 285 packages were refreshed.
|
||||||
|
- `./node_modules/.bin/oxfmt --write --threads=1 <changed files>`
|
||||||
|
- Exit 0. The final changed gate reported `All matched files use the correct format.`
|
||||||
|
- `git diff --check origin/main...HEAD`
|
||||||
|
- Exit 0, no output.
|
||||||
|
|
||||||
```text
|
### Focused Vitest and browser behavior
|
||||||
node scripts/run-vitest.mjs src/gateway/sessions-patch.test.ts src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts src/gateway/session-utils-creators.test.ts src/gateway/session-event-payload.test.ts packages/gateway-protocol/src/schema/sessions-row.test.ts ui/src/components/app-sidebar.test.ts ui/src/lib/sessions/reconcile.test.ts
|
|
||||||
Test Files 4 passed, Tests 90 passed (gateway-core)
|
|
||||||
Test Files 1 passed, Tests 1 passed (gateway-client)
|
|
||||||
Test Files 2 passed, Tests 175 passed (UI)
|
|
||||||
[test] passed 3 Vitest shards in 32.67s (exit 0)
|
|
||||||
|
|
||||||
node scripts/run-vitest.mjs src/gateway/server-methods/sessions-sharing.test.ts
|
- `node scripts/run-vitest.mjs packages/gateway-protocol/src/schema/sessions-create.test.ts ui/src/pages/new-session/create-params.test.ts ui/src/pages/new-session/target-controls.test.ts ui/src/pages/chat/components/chat-session-sharing.test.ts ui/src/components/app-sidebar-session-navigation-logic.test.ts`
|
||||||
Test Files 1 passed, Tests 9 passed
|
- Exit 0.
|
||||||
[test] passed 1 Vitest shard in 17.36s (exit 0)
|
- Gateway-client shard: 1 file, 2 tests passed.
|
||||||
|
- UI shard: 4 files, 21 tests passed.
|
||||||
|
- Final wrapper: `passed 2 Vitest shards in 7.32s`.
|
||||||
|
- `node scripts/run-vitest.mjs src/gateway/server.sessions.create.test.ts`
|
||||||
|
- Exit 0: 1 file, 67 tests passed; final wrapper `passed 1 Vitest shard in 22.51s`.
|
||||||
|
- Covers omitted visibility -> shared projection, draft visibility on the first list, disabled new drafts, matching keyed adoption, mismatched adoption rejection, and exact retry after policy disable.
|
||||||
|
- `node scripts/run-vitest.mjs src/gateway/server.auth.default-token.test.ts`
|
||||||
|
- Exit 0: 1 file, 23 tests passed; final wrapper `passed 1 Vitest shard in 6.49s`.
|
||||||
|
- `node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner ui/src/e2e/session-ownership.e2e.test.ts`
|
||||||
|
- Exit 0: 1 file, 7 tests passed in 5.75s.
|
||||||
|
- Proves multi-person availability, solo dormancy, atomic `sessions.create` traffic, own/foreign sidebar treatment, light/dark rendering, one-call publish, and clearing a selected draft mode after policy becomes unavailable.
|
||||||
|
|
||||||
node scripts/run-vitest.mjs src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts
|
### Required typechecks
|
||||||
Test Files 1 passed, Tests 3 passed (exit 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
The archive-attribution handler tests cover actor stamping, idempotent re-archive, unarchive clearing, exact audit text, unidentified-client behavior, alias archiving, and successful best-effort audit failure with no reversal writes. The UI suite includes the required collaborative and solo-dormancy archive-row cases.
|
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.core.json`
|
||||||
|
- Exit 0, no diagnostics.
|
||||||
|
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.ui.json`
|
||||||
|
- Exit 0, no diagnostics.
|
||||||
|
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.extensions.json`
|
||||||
|
- Exit 0, no diagnostics.
|
||||||
|
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -b tsconfig.projects.json`
|
||||||
|
- Exit 0, no diagnostics.
|
||||||
|
|
||||||
Protocol and i18n generation/checks:
|
### Protocol and localization
|
||||||
|
|
||||||
```text
|
- `pnpm protocol:gen`
|
||||||
pnpm protocol:gen && pnpm protocol:gen:swift && pnpm protocol:gen:kotlin
|
- Exit 0; wrote the local ignored `dist/protocol.schema.json` without adding it to Git.
|
||||||
wrote dist/protocol.schema.json
|
- `pnpm protocol:gen:swift`
|
||||||
wrote apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
|
- Exit 0; wrote `GatewayModels.swift`.
|
||||||
wrote apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt
|
- `pnpm protocol:gen:kotlin`
|
||||||
wrote apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt
|
- Exit 0; wrote both Kotlin generator targets, with no tracked diff.
|
||||||
exit 0
|
- `pnpm protocol:check`
|
||||||
|
- Exit 0; regenerated JSON/Swift/Kotlin and printed `protocol since guard passed: 0 new core methods use train 2026.7`.
|
||||||
|
- `node --import tsx scripts/control-ui-i18n-verify.ts baseline`
|
||||||
|
- Exit 0: `raw-copy: baseline entries=105`, `source: keys=3968`.
|
||||||
|
- `node --import tsx scripts/native-app-i18n.ts baseline --write`
|
||||||
|
- Exit 0: `entries=5232 changed=true`; the diff contained only unrelated current-main iOS source-line shifts and was discarded rather than added to W4.
|
||||||
|
|
||||||
pnpm protocol:check
|
### Docs and changed gate
|
||||||
protocol since guard passed: 0 new core methods use train 2026.7
|
|
||||||
exit 0
|
|
||||||
|
|
||||||
node --import tsx scripts/native-app-i18n.ts baseline --write
|
- `pnpm docs:check-mdx`
|
||||||
native-app-i18n: entries=5232 changed=false
|
- Exit 0: `Docs MDX check passed (751 files, 3308ms).`
|
||||||
|
- `pnpm check:changed`
|
||||||
|
- Delegated to Testbox `tbx_01ky81kpwnmn9y6xdzg7jmf99k`, Actions run `30031143438`.
|
||||||
|
- Final wrapper: `exitCode: 0`, `runStatus: succeeded`, command 25m24.264s, total 25m26.417s; the one-shot Testbox stopped successfully.
|
||||||
|
- Ran the actual 30-file W4 lanes: ratchets, formatting, API/plugin boundaries, UI i18n, core/core-test/UI typechecks, all core/UI/packages lint shards, macOS CI test shards, native state schema guard, database-first guards, and import cycles.
|
||||||
|
- Final import-cycle result: `0 runtime value cycle(s)`.
|
||||||
|
|
||||||
node --import tsx scripts/control-ui-i18n-verify.ts baseline
|
### Autoreview
|
||||||
control-ui-i18n: raw-copy: baseline entries=105
|
|
||||||
control-ui-i18n: source: keys=3960
|
|
||||||
exit 0
|
|
||||||
```
|
|
||||||
|
|
||||||
Requested type gates:
|
- Branch command: `.agents/skills/autoreview/scripts/autoreview --mode branch --base 977db1c83261b13e92e7ad74c5f544e61acf1b90 --stream-engine-output`.
|
||||||
|
- Accepted and fixed:
|
||||||
```text
|
- replaced the exact global profile count with privacy-preserving `hasMultipleSessionSharingIdentities`;
|
||||||
node scripts/run-tsgo.mjs -p tsconfig.core.json
|
- cleared stale checked draft state when policy/identity availability disappears;
|
||||||
exit 0 (no diagnostics)
|
- preserved matching keyed-create adoption retries;
|
||||||
|
- preserved those exact retries after the drafts policy is disabled while still rejecting every genuinely new disabled draft.
|
||||||
node scripts/run-tsgo.mjs -p tsconfig.ui.json
|
- Rejected after direct verification:
|
||||||
exit 0 (no diagnostics)
|
- native binding drift: `pnpm protocol:check` regenerated all outputs cleanly; `HelloOk.policy` is intentionally an untyped map in Swift/Kotlin, so policy keys do not produce native field diffs;
|
||||||
|
- unkeyed policy bypass: `createSessionEntryWithTranscript` owns generated and explicit keys, and the 67-test suite proves unkeyed disabled drafts remain rejected.
|
||||||
node scripts/run-tsgo.mjs -p tsconfig.extensions.json
|
- Final branch result: `autoreview clean: no accepted/actionable findings reported`, overall confidence 0.94.
|
||||||
exit 0 (no diagnostics)
|
|
||||||
|
|
||||||
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json
|
|
||||||
exit 0 (no diagnostics)
|
|
||||||
```
|
|
||||||
|
|
||||||
Changed gate:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm check:changed --staged
|
|
||||||
Blacksmith Testbox tbx_01ky7mr2xxsf79mxq8er4r3g7y
|
|
||||||
command=22m27.882s total=22m29.893s exit=0 runStatus=succeeded
|
|
||||||
```
|
|
||||||
|
|
||||||
After autoreview fixes, the final Blacksmith rerun remained capacity-queued for eight minutes and was stopped cleanly. The trusted-source fallback ran the identical staged path set locally without the slow staged-per-file `git show` mode:
|
|
||||||
|
|
||||||
```text
|
|
||||||
changed_paths=("${(@f)$(git diff --cached --name-only)}")
|
|
||||||
OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree CI=1 PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false pnpm check:changed --base HEAD --head HEAD -- "${changed_paths[@]}"
|
|
||||||
lanes=core, coreTests, ui, apps
|
|
||||||
OPENCLAW_* count 529/529
|
|
||||||
max-lines ratchet OK: 1133 grandfathered suppressions
|
|
||||||
Control UI i18n verified: baseline entries=105, source keys=3960
|
|
||||||
Typecheck core, core tests, and UI: passed
|
|
||||||
Core, UI, packages, and Swift lint: 0 errors/violations
|
|
||||||
Mac app CI tests: 232 passed across 5 Vitest shards
|
|
||||||
Native state schema guard: v5 passed
|
|
||||||
Database-first legacy-store guard: passed
|
|
||||||
Import cycle check: 0 runtime value cycles
|
|
||||||
exit 0
|
|
||||||
```
|
|
||||||
|
|
||||||
Review and final sanity:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.agents/skills/autoreview/scripts/autoreview --mode uncommitted
|
|
||||||
trufflehog: clean
|
|
||||||
autoreview clean: no accepted/actionable findings reported
|
|
||||||
overall: patch is correct (0.94)
|
|
||||||
|
|
||||||
git diff --cached --check
|
|
||||||
exit 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## LOC summary
|
## LOC summary
|
||||||
|
|
||||||
From `git show --numstat 083482ea654`, classifying `*.test.*` and `ui/src/test-helpers/**` as tests:
|
Implementation diff before adding this report, grouped by role and excluding generated/docs from production:
|
||||||
|
|
||||||
```text
|
| Group | Added | Deleted |
|
||||||
prod 138 added, 37 deleted, net +101
|
| ---------------------- | ----: | ------: |
|
||||||
test 322 added, 11 deleted, net +311
|
| Production | 192 | 18 |
|
||||||
total 460 added, 48 deleted, net +412
|
| Tests and test support | 562 | 1 |
|
||||||
```
|
| Generated Swift | 4 | 0 |
|
||||||
|
| Docs | 5 | 1 |
|
||||||
|
|
||||||
The shared audit extraction is nearly LOC-neutral by moving the former sharing-only implementation into one reusable helper. The main production growth is the actor field plumbing, event tombstones, protocol/UI types, and accessible archive-chip state.
|
The generated total contains only the tracked Swift protocol model. The JSON schema is deliberately excluded because it is ignored and untracked on `origin/main`.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- `ce0ac10f6af feat(protocol): support draft session creation`
|
||||||
|
- `c73b1076dae feat(ui): add draft session workflows`
|
||||||
|
- `4b3215e3818 docs: explain multi-user drafts`
|
||||||
|
- `afcb60ba214 test(ui): tighten draft ownership fixture`
|
||||||
|
- `d415bd43305 test(ui): satisfy draft E2E lint`
|
||||||
|
- `9403a9fa1f9 test(ui): keep draft fixtures strictly typed`
|
||||||
|
- `7eb3abb54da test: strengthen draft compatibility coverage`
|
||||||
|
- `d91a15034c2 fix: harden draft availability policy`
|
||||||
|
- `4705acfd2e3 test(ui): type draft policy mock control`
|
||||||
|
- `0e0e96f339d fix: preserve keyed draft creation retries`
|
||||||
|
- `0c755a401b4 fix: keep disabled draft retries idempotent`
|
||||||
|
|
||||||
## Skipped or deferred
|
## Skipped or deferred
|
||||||
|
|
||||||
- No live browser/source-blind validation was run. This task had no independent live Gateway/browser fixture, and the same agent was already source-aware. The Control UI DOM behavior suite provides the requested behavior-level proof, including solo dormancy.
|
- No push, pull request, `scripts/pr`, release, publish, `CHANGELOG.md`, or GitHub mutation was performed.
|
||||||
- No SQLite schema version or migration was added because the canonical session entry is stored in `entry_json`; adding one would violate the spec and repository storage rules.
|
- No SQLite change was needed; therefore there was no schema-version bump or lazy table ensure.
|
||||||
- No Kotlin generated diff was committed because the Kotlin generator does not emit `SessionRow` and produced no change.
|
- No new Gateway event was added; existing `sessions.changed` and `session.sharing` paths retain their current scope guards and draft filtering.
|
||||||
- No ignored `dist/protocol.schema.json` was force-added; it was generated and checked, while current repository policy leaves `dist/` untracked.
|
- Foreign-language UI bundles were not edited; the requested `en.ts` plus baseline workflow was used.
|
||||||
- No push, PR, changelog edit, `scripts/pr`, or live Gateway mutation was performed.
|
- `dist/protocol.schema.json` is absent from branch history and `git ls-files`, while final status reports it only as `!! dist/protocol.schema.json` after generation. Swift and both Kotlin targets were verified with `git cat-file -e origin/main:<path>`; no state-generated files changed.
|
||||||
- `origin/main` advanced during the long validation window. The branch is intentionally left for the reviewer to rebase as requested.
|
- Final branch status after proof: feature work is committed and the tracked worktree is clean. `origin/main` advanced by six commits after the successful gate; no push or further moving-base chase was performed.
|
||||||
|
|
||||||
## Superseded raw-layout rollback follow-up
|
|
||||||
|
|
||||||
This section records the intermediate `14230f2e84a` approach for review history. It was superseded and removed by `b5cc8061a9b` because restoring snapshotted `session_members` could revive a membership concurrently revoked outside the archive lifecycle lock.
|
|
||||||
|
|
||||||
The first rollback restored only the canonical primary entry after an audit append failure. That was logically complete for the archive fields but not layout-complete: `applySessionPatchProjection` removes every non-primary alias candidate while canonicalizing the freshest row.
|
|
||||||
|
|
||||||
The follow-up captures the exact raw `session_entries` rows for every candidate key, plus `session_members` rows that SQLite cascades when an alias entry is removed. On audit failure it deletes the projected candidate set and reinserts the snapshot in one SQLite write transaction, preserving raw `entry_json`, `session_id`, `updated_at`, `status`, alias keys, and memberships. It then emits the inverse session identity diff, matching the forward projection notification path. `sessions.changed` remains post-audit and therefore is never emitted for the failed archive.
|
|
||||||
|
|
||||||
The visibility rollback was checked separately. It is alias-safe for its narrower mutation because it patches `current.storeKey` in place and never runs candidate canonicalization; copying that path alone would not have repaired archive rollback.
|
|
||||||
|
|
||||||
Follow-up files:
|
|
||||||
|
|
||||||
- `src/config/sessions/session-accessor.types.ts`
|
|
||||||
- `src/config/sessions/session-accessor.lifecycle.ts`
|
|
||||||
- `src/config/sessions/session-accessor.sqlite-projection.ts`
|
|
||||||
- `src/config/sessions/session-accessor.sqlite.ts`
|
|
||||||
- `src/config/sessions/session-accessor.ts`
|
|
||||||
- `src/gateway/server-methods/sessions-mutations.ts`
|
|
||||||
- `src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts`
|
|
||||||
|
|
||||||
Follow-up proof:
|
|
||||||
|
|
||||||
```text
|
|
||||||
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-vitest.mjs src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts -- --reporter=verbose
|
|
||||||
Test Files 1 passed
|
|
||||||
Tests 4 passed
|
|
||||||
The alias case passed in 2834ms and asserted exact raw entry/member row equality.
|
|
||||||
[test] passed 1 Vitest shard in 52.88s (exit 0)
|
|
||||||
|
|
||||||
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-vitest.mjs src/gateway/sessions-patch.test.ts -- -t 'attributes the archive transition|does not fabricate archive attribution' --reporter=verbose
|
|
||||||
Test Files 1 passed
|
|
||||||
Tests 2 passed, 84 skipped
|
|
||||||
[test] passed 1 Vitest shard in 33.06s (exit 0)
|
|
||||||
|
|
||||||
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-vitest.mjs src/gateway/sessions-patch.test.ts -- -t 'atomically snapshots prior selection for agent model patches' --reporter=verbose
|
|
||||||
Test Files 1 passed
|
|
||||||
Tests 1 passed, 85 skipped
|
|
||||||
[test] passed 1 Vitest shard in 38.39s (exit 0)
|
|
||||||
|
|
||||||
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.core.json
|
|
||||||
exit 0 (no diagnostics)
|
|
||||||
|
|
||||||
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json
|
|
||||||
exit 0 (no diagnostics)
|
|
||||||
|
|
||||||
OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree CI=1 PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false pnpm check:changed --base HEAD --head HEAD -- src/config/sessions/session-accessor.lifecycle.ts src/config/sessions/session-accessor.sqlite-projection.ts src/config/sessions/session-accessor.sqlite.ts src/config/sessions/session-accessor.ts src/config/sessions/session-accessor.types.ts src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts src/gateway/server-methods/sessions-mutations.ts
|
|
||||||
lanes=core, coreTests
|
|
||||||
format, core/core-test typechecks, changed-file lint, database-first guard, runtime sidecar loader guard, import-cycle guard, webhook and pairing guards passed
|
|
||||||
exit 0
|
|
||||||
|
|
||||||
.agents/skills/autoreview/scripts/autoreview --mode uncommitted --engine codex
|
|
||||||
trufflehog: clean
|
|
||||||
autoreview clean: no accepted/actionable findings reported
|
|
||||||
overall: patch is correct (0.97)
|
|
||||||
```
|
|
||||||
|
|
||||||
The first full local `sessions-patch.test.ts` attempt passed 85/86 tests; the unrelated agent-model snapshot test timed out under severe host contention after 120 seconds. Its isolated retry passed in 27.8 seconds, and the two archive projection cases passed independently. The first delegated `check:changed --staged` attempt also stopped before feature lanes because the current trusted-workflow checkout used env budget 525 while this reviewer-owned branch uses 529. The branch-relative local trusted-source fallback above passed; the follow-up adds no environment variables.
|
|
||||||
|
|
||||||
Follow-up LOC from `git show --numstat 14230f2e84a`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
prod 154 added, 15 deleted, net +139
|
|
||||||
test 96 added, 1 deleted, net +95
|
|
||||||
total 250 added, 16 deleted, net +234
|
|
||||||
```
|
|
||||||
|
|
||||||
No push, PR, changelog edit, protocol change, schema-version bump, or `CLAUDE.md` edit was performed.
|
|
||||||
|
|
||||||
## Superseded archive-only rollback redesign
|
|
||||||
|
|
||||||
This section records the intermediate `b5cc8061a9b` approach. It was superseded by `320237964e0`; archive audit notes no longer trigger any rollback.
|
|
||||||
|
|
||||||
The final design treats candidate-key canonicalization as the normal benign behavior of `sessions.patch`; alias layout is not rolled back. If archive auditing fails, the handler re-reads the current candidate state and applies the inverse archived boolean through a second `applySessionPatchProjection` plus `projectSessionsPatchEntry` pass. It then restores only the prior `archivedAt` and `archivedBy` values. Other valid patch effects remain, and the rollback never reads or writes `session_members`.
|
|
||||||
|
|
||||||
The intermediate raw `session_entries`/`session_members` snapshot API and all related accessor plumbing were deleted. This avoids reviving a member removed concurrently between archive projection and audit failure.
|
|
||||||
|
|
||||||
Final regression behavior:
|
|
||||||
|
|
||||||
- archive is requested through the alias `alias-archive`;
|
|
||||||
- a canonical session member exists before the request;
|
|
||||||
- the forced audit failure removes that member before throwing;
|
|
||||||
- rollback leaves the canonical session unarchived with no `archivedBy`;
|
|
||||||
- the removed member remains absent;
|
|
||||||
- alias canonicalization may remain under the primary key by design.
|
|
||||||
|
|
||||||
Final proof:
|
|
||||||
|
|
||||||
```text
|
|
||||||
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-vitest.mjs src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts -- --reporter=verbose
|
|
||||||
Test Files 1 passed
|
|
||||||
Tests 4 passed
|
|
||||||
[test] passed 1 Vitest shard in 13.59s (exit 0)
|
|
||||||
|
|
||||||
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.core.json
|
|
||||||
exit 0 (no diagnostics)
|
|
||||||
|
|
||||||
OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree CI=1 PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false pnpm check:changed --base HEAD --head HEAD -- src/config/sessions/session-accessor.lifecycle.ts src/config/sessions/session-accessor.sqlite-projection.ts src/config/sessions/session-accessor.sqlite.ts src/config/sessions/session-accessor.ts src/config/sessions/session-accessor.types.ts src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts src/gateway/server-methods/sessions-mutations.ts
|
|
||||||
lanes=core, coreTests
|
|
||||||
format, core/core-test typechecks, changed-file lint, database-first guard, runtime sidecar loader guard, import-cycle guard, webhook and pairing guards passed
|
|
||||||
exit 0
|
|
||||||
|
|
||||||
.agents/skills/autoreview/scripts/autoreview --mode uncommitted --engine codex
|
|
||||||
trufflehog: clean
|
|
||||||
autoreview clean: no accepted/actionable findings reported
|
|
||||||
overall: patch is correct (0.98)
|
|
||||||
```
|
|
||||||
|
|
||||||
Final redesign LOC from `git show --numstat b5cc8061a9b`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
prod 45 added, 166 deleted, net -121
|
|
||||||
test 24 added, 62 deleted, net -38
|
|
||||||
total 69 added, 228 deleted, net -159
|
|
||||||
```
|
|
||||||
|
|
||||||
No push was performed. `SPEC.md` remains the only untracked file.
|
|
||||||
|
|
||||||
## Final best-effort archive audit policy
|
|
||||||
|
|
||||||
The final policy deletes the archive rollback path entirely. `archivedAt` and `archivedBy` are the durable result. If the actor-stamped transcript note cannot be appended, `sessions.patch` logs `sessionLog.warn`, returns success, and keeps the archive. The catch performs no entry, alias, pin, or membership writes. This is a deliberate tradeoff for a non-security lifecycle note; visibility audit remains rollback-on-failure because visibility is an access boundary.
|
|
||||||
|
|
||||||
Final focused behavior proof:
|
|
||||||
|
|
||||||
```text
|
|
||||||
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-vitest.mjs src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts -- --reporter=verbose
|
|
||||||
Test Files 1 passed
|
|
||||||
Tests 4 passed
|
|
||||||
[test] passed 1 Vitest shard in 8.85s (exit 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
The forced-failure case archives through an alias and verifies:
|
|
||||||
|
|
||||||
- the RPC succeeds;
|
|
||||||
- the derived row has `archived: true`, `archivedAt`, and `archivedBy`;
|
|
||||||
- the expected warning includes the audit failure and “archive kept” decision;
|
|
||||||
- candidate entry state and `session_members` match their state at the failure point;
|
|
||||||
- SQLite `total_changes()` does not advance after the audit throw, proving there are no reversal writes.
|
|
||||||
|
|
||||||
Final gates:
|
|
||||||
|
|
||||||
```text
|
|
||||||
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.core.json
|
|
||||||
exit 0 (no diagnostics)
|
|
||||||
|
|
||||||
OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree CI=1 PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false pnpm check:changed --base HEAD --head HEAD -- src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts src/gateway/server-methods/sessions-mutations.ts
|
|
||||||
lanes=core, coreTests
|
|
||||||
format, core/core-test typechecks, changed-file lint, database-first guard, runtime sidecar loader guard, import-cycle guard, webhook and pairing guards passed
|
|
||||||
exit 0
|
|
||||||
|
|
||||||
.agents/skills/autoreview/scripts/autoreview --mode uncommitted --engine codex
|
|
||||||
trufflehog: clean
|
|
||||||
autoreview clean: no accepted/actionable findings reported
|
|
||||||
overall: patch is correct (0.98)
|
|
||||||
```
|
|
||||||
|
|
||||||
The full branch review uses base `3023d69fd8858882470da009c8874541c2ddd9e9`. Its only initial finding was this report's stale rollback wording; the implementation was accepted as consistent with the deliberate best-effort policy.
|
|
||||||
|
|
||||||
Best-effort cleanup LOC from `git show --numstat 320237964e0`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
prod 14 added, 40 deleted, net -26
|
|
||||||
test 61 added, 38 deleted, net +23
|
|
||||||
total 75 added, 78 deleted, net -3
|
|
||||||
```
|
|
||||||
|
|
||||||
No push was performed. `SPEC.md` remains the only untracked file.
|
|
||||||
|
|||||||
@@ -6895,6 +6895,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
|||||||
public let model: String?
|
public let model: String?
|
||||||
public let thinkinglevel: String?
|
public let thinkinglevel: String?
|
||||||
public let incognito: Bool?
|
public let incognito: Bool?
|
||||||
|
public let visibility: SessionVisibility?
|
||||||
public let catalogid: String?
|
public let catalogid: String?
|
||||||
public let parentsessionkey: String?
|
public let parentsessionkey: String?
|
||||||
public let spawndepth: Int?
|
public let spawndepth: Int?
|
||||||
@@ -6917,6 +6918,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
|||||||
model: String? = nil,
|
model: String? = nil,
|
||||||
thinkinglevel: String? = nil,
|
thinkinglevel: String? = nil,
|
||||||
incognito: Bool? = nil,
|
incognito: Bool? = nil,
|
||||||
|
visibility: SessionVisibility? = nil,
|
||||||
catalogid: String? = nil,
|
catalogid: String? = nil,
|
||||||
parentsessionkey: String? = nil,
|
parentsessionkey: String? = nil,
|
||||||
spawndepth: Int? = nil,
|
spawndepth: Int? = nil,
|
||||||
@@ -6938,6 +6940,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
|||||||
self.model = model
|
self.model = model
|
||||||
self.thinkinglevel = thinkinglevel
|
self.thinkinglevel = thinkinglevel
|
||||||
self.incognito = incognito
|
self.incognito = incognito
|
||||||
|
self.visibility = visibility
|
||||||
self.catalogid = catalogid
|
self.catalogid = catalogid
|
||||||
self.parentsessionkey = parentsessionkey
|
self.parentsessionkey = parentsessionkey
|
||||||
self.spawndepth = spawndepth
|
self.spawndepth = spawndepth
|
||||||
@@ -6961,6 +6964,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
|||||||
case model
|
case model
|
||||||
case thinkinglevel = "thinkingLevel"
|
case thinkinglevel = "thinkingLevel"
|
||||||
case incognito
|
case incognito
|
||||||
|
case visibility
|
||||||
case catalogid = "catalogId"
|
case catalogid = "catalogId"
|
||||||
case parentsessionkey = "parentSessionKey"
|
case parentsessionkey = "parentSessionKey"
|
||||||
case spawndepth = "spawnDepth"
|
case spawndepth = "spawnDepth"
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ The web app keeps ownership and presence visually distinct:
|
|||||||
|
|
||||||
When fewer than two distinct creators appear in the loaded session list, OpenClaw hides all ownership and person-filter chrome. A single-user gateway therefore looks unchanged.
|
When fewer than two distinct creators appear in the loaded session list, OpenClaw hides all ownership and person-filter chrome. A single-user gateway therefore looks unchanged.
|
||||||
|
|
||||||
|
## Drafts
|
||||||
|
|
||||||
|
Start a session as a draft to keep work in progress out of teammates' sidebars until you publish it. Drafts are never hidden from admins, who see other people's drafts with a faded ghost marker. This is a coordination feature, not a security boundary.
|
||||||
|
|
||||||
## Turn attribution
|
## Turn attribution
|
||||||
|
|
||||||
Turn sender attribution is best-effort. Steering can merge input into an active turn, so the transcript cannot always represent each person's contribution as a separate turn.
|
Turn sender attribution is best-effort. Steering can merge input into an active turn, so the transcript cannot always represent each person's contribution as a separate turn.
|
||||||
|
|||||||
@@ -2786,6 +2786,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
|||||||
- Headings:
|
- Headings:
|
||||||
- H2: Trust boundary
|
- H2: Trust boundary
|
||||||
- H2: Ownership and presence
|
- H2: Ownership and presence
|
||||||
|
- H2: Drafts
|
||||||
- H2: Turn attribution
|
- H2: Turn attribution
|
||||||
- H2: Related
|
- H2: Related
|
||||||
|
|
||||||
|
|||||||
@@ -1268,7 +1268,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
|
|||||||
- `maxAgeHours`: default hard max age in hours (`0` disables; providers can override)
|
- `maxAgeHours`: default hard max age in hours (`0` disables; providers can override)
|
||||||
- `spawnSessions`: default gate for creating thread-bound work sessions from `sessions_spawn` and ACP thread spawns. Defaults to `true` when thread bindings are enabled; providers/accounts can override.
|
- `spawnSessions`: default gate for creating thread-bound work sessions from `sessions_spawn` and ACP thread spawns. Defaults to `true` when thread bindings are enabled; providers/accounts can override.
|
||||||
- `defaultSpawnContext`: default native subagent context for thread-bound spawns (`"fork"` or `"isolated"`). Defaults to `"fork"`.
|
- `defaultSpawnContext`: default native subagent context for thread-bound spawns (`"fork"` or `"isolated"`). Defaults to `"fork"`.
|
||||||
- **`sharing`**: controls which per-session collaboration modes owners and `operator.admin` connections may select. Every flag defaults to `true`; setting one to `false` removes that choice from the Control UI and makes `session.visibility.set` reject it. New sessions always start `shared`.
|
- **`sharing`**: controls which per-session collaboration modes owners and `operator.admin` connections may select. Every flag defaults to `true`; setting one to `false` removes that choice from the Control UI and makes create-time visibility or `session.visibility.set` reject it. New sessions start `shared` unless the Control UI starts one as a draft.
|
||||||
- `readOnly`: allow `read-only`, where non-members can watch but cannot send, steer, abort, approve, or mutate session state.
|
- `readOnly`: allow `read-only`, where non-members can watch but cannot send, steer, abort, approve, or mutate session state.
|
||||||
- `suggest`: allow `suggest`. In this phase it enforces the same admission behavior as `read-only`; the suggestion queue is a later feature.
|
- `suggest`: allow `suggest`. In this phase it enforces the same admission behavior as `read-only`; the suggestion queue is a later feature.
|
||||||
- `drafts`: allow `draft`, which hides the session from non-admin, non-owner session lists and event broadcasts.
|
- `drafts`: allow `draft`, which hides the session from non-admin, non-owner session lists and event broadcasts.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Static } from "typebox";
|
|||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
import { closedObject } from "./closed-object.js";
|
import { closedObject } from "./closed-object.js";
|
||||||
import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js";
|
import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js";
|
||||||
|
import { SessionVisibilitySchema } from "./sessions-sharing-values.js";
|
||||||
import { SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
import { SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||||
|
|
||||||
export const GATEWAY_SERVER_CAPS = {
|
export const GATEWAY_SERVER_CAPS = {
|
||||||
@@ -137,6 +138,8 @@ export const HelloOkSchema = closedObject({
|
|||||||
maxPayload: Type.Integer({ minimum: 1 }),
|
maxPayload: Type.Integer({ minimum: 1 }),
|
||||||
maxBufferedBytes: Type.Integer({ minimum: 1 }),
|
maxBufferedBytes: Type.Integer({ minimum: 1 }),
|
||||||
tickIntervalMs: Type.Integer({ minimum: 1 }),
|
tickIntervalMs: Type.Integer({ minimum: 1 }),
|
||||||
|
allowedSessionVisibilities: Type.Optional(Type.Array(SessionVisibilitySchema)),
|
||||||
|
hasMultipleSessionSharingIdentities: Type.Optional(Type.Boolean()),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateSessionsCreateParams } from "../index.js";
|
||||||
|
|
||||||
|
describe("sessions.create schema", () => {
|
||||||
|
it("accepts additive create-time visibility values", () => {
|
||||||
|
for (const visibility of ["shared", "read-only", "suggest", "draft"]) {
|
||||||
|
expect(validateSessionsCreateParams({ agentId: "main", visibility })).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown visibility values", () => {
|
||||||
|
expect(validateSessionsCreateParams({ agentId: "main", visibility: "private" })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { Type } from "typebox";
|
|||||||
import { closedObject } from "./closed-object.js";
|
import { closedObject } from "./closed-object.js";
|
||||||
import { ChatAttachmentsSchema } from "./logs-chat.js";
|
import { ChatAttachmentsSchema } from "./logs-chat.js";
|
||||||
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
||||||
|
import { SessionVisibilitySchema } from "./sessions-sharing-values.js";
|
||||||
|
|
||||||
/** Creates or adopts a session with optional model, thinking, label, and parent linkage. */
|
/** Creates or adopts a session with optional model, thinking, label, and parent linkage. */
|
||||||
export const SessionsCreateParamsSchema = closedObject({
|
export const SessionsCreateParamsSchema = closedObject({
|
||||||
@@ -11,6 +12,7 @@ export const SessionsCreateParamsSchema = closedObject({
|
|||||||
model: Type.Optional(NonEmptyString),
|
model: Type.Optional(NonEmptyString),
|
||||||
thinkingLevel: Type.Optional(NonEmptyString),
|
thinkingLevel: Type.Optional(NonEmptyString),
|
||||||
incognito: Type.Optional(Type.Boolean()),
|
incognito: Type.Optional(Type.Boolean()),
|
||||||
|
visibility: Type.Optional(SessionVisibilitySchema),
|
||||||
catalogId: Type.Optional(NonEmptyString),
|
catalogId: Type.Optional(NonEmptyString),
|
||||||
parentSessionKey: Type.Optional(NonEmptyString),
|
parentSessionKey: Type.Optional(NonEmptyString),
|
||||||
spawnDepth: Type.Optional(
|
spawnDepth: Type.Optional(
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
|||||||
thinkingLevel: p.thinkingLevel,
|
thinkingLevel: p.thinkingLevel,
|
||||||
incognito: p.incognito,
|
incognito: p.incognito,
|
||||||
...(client?.connect ? { requestingOperatorScopes: clientScopes } : {}),
|
...(client?.connect ? { requestingOperatorScopes: clientScopes } : {}),
|
||||||
|
visibility: p.visibility,
|
||||||
allowExistingModelSelection,
|
allowExistingModelSelection,
|
||||||
parentSessionKey: p.parentSessionKey,
|
parentSessionKey: p.parentSessionKey,
|
||||||
spawnDepth: p.spawnDepth,
|
spawnDepth: p.spawnDepth,
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ export function registerDefaultAuthTokenSuite(): void {
|
|||||||
type?: unknown;
|
type?: unknown;
|
||||||
features?: { capabilities?: unknown };
|
features?: { capabilities?: unknown };
|
||||||
snapshot?: { configPath?: string; stateDir?: string };
|
snapshot?: { configPath?: string; stateDir?: string };
|
||||||
|
policy?: {
|
||||||
|
allowedSessionVisibilities?: unknown;
|
||||||
|
hasMultipleSessionSharingIdentities?: unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
expect(payload?.type).toBe("hello-ok");
|
expect(payload?.type).toBe("hello-ok");
|
||||||
@@ -175,10 +179,37 @@ export function registerDefaultAuthTokenSuite(): void {
|
|||||||
);
|
);
|
||||||
expect(payload?.snapshot?.configPath).toBe(createConfigIO().configPath);
|
expect(payload?.snapshot?.configPath).toBe(createConfigIO().configPath);
|
||||||
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR);
|
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR);
|
||||||
|
expect(payload?.policy?.allowedSessionVisibilities).toEqual([
|
||||||
|
"shared",
|
||||||
|
"read-only",
|
||||||
|
"suggest",
|
||||||
|
"draft",
|
||||||
|
]);
|
||||||
|
expect(payload?.policy?.hasMultipleSessionSharingIdentities).toBe(false);
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("hello policy counts canonical session-sharing identities", async () => {
|
||||||
|
const { ensureProfileForEmail, linkEmail } = await import("../state/user-profiles.js");
|
||||||
|
const suffix = `${process.pid}-${Date.now()}`;
|
||||||
|
ensureProfileForEmail(`hello-a-${suffix}@example.invalid`);
|
||||||
|
const target = ensureProfileForEmail(`hello-b-${suffix}@example.invalid`);
|
||||||
|
ensureProfileForEmail(`hello-merged-${suffix}@example.invalid`);
|
||||||
|
linkEmail(`hello-merged-${suffix}@example.invalid`, target.id);
|
||||||
|
|
||||||
|
const ws = await openWs(port);
|
||||||
|
try {
|
||||||
|
const res = await connectReq(ws);
|
||||||
|
const payload = res.payload as
|
||||||
|
| { policy?: { hasMultipleSessionSharingIdentities?: unknown } }
|
||||||
|
| undefined;
|
||||||
|
expect(payload?.policy?.hasMultipleSessionSharingIdentities).toBe(true);
|
||||||
|
} finally {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("connect (req) handshake resolves server version from runtime precedence", async () => {
|
test("connect (req) handshake resolves server version from runtime precedence", async () => {
|
||||||
const { VERSION } = await import("../version.js");
|
const { VERSION } = await import("../version.js");
|
||||||
for (const testCase of [
|
for (const testCase of [
|
||||||
|
|||||||
@@ -555,6 +555,109 @@ function requireNonEmptyString(value: string | undefined, label: string): string
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("sessions.create persists draft visibility in the initial session entry", async () => {
|
||||||
|
const { storePath } = await createSessionStoreDir();
|
||||||
|
const created = await directSessionReq<{
|
||||||
|
key: string;
|
||||||
|
entry: { visibility?: string };
|
||||||
|
}>("sessions.create", { agentId: "main", visibility: "draft" });
|
||||||
|
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
expect(created.payload?.entry.visibility).toBe("draft");
|
||||||
|
const key = requireNonEmptyString(created.payload?.key, "created session key");
|
||||||
|
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })?.visibility).toBe(
|
||||||
|
"draft",
|
||||||
|
);
|
||||||
|
const listed = await directSessionReq<{
|
||||||
|
sessions?: Array<{ key: string; visibility?: string }>;
|
||||||
|
}>("sessions.list", {});
|
||||||
|
expect(listed.payload?.sessions?.find((row) => row.key === key)?.visibility).toBe("draft");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sessions.create keeps omitted visibility on the prior shared default", async () => {
|
||||||
|
const { storePath } = await createSessionStoreDir();
|
||||||
|
const created = await directSessionReq<{
|
||||||
|
key: string;
|
||||||
|
entry: { visibility?: string };
|
||||||
|
}>("sessions.create", { agentId: "main" });
|
||||||
|
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
expect(created.payload?.entry.visibility).toBeUndefined();
|
||||||
|
const key = requireNonEmptyString(created.payload?.key, "created session key");
|
||||||
|
expect(
|
||||||
|
loadSessionEntry({ agentId: "main", sessionKey: key, storePath })?.visibility,
|
||||||
|
).toBeUndefined();
|
||||||
|
const listed = await directSessionReq<{
|
||||||
|
sessions?: Array<{ key: string; visibility?: string }>;
|
||||||
|
}>("sessions.list", {});
|
||||||
|
expect(listed.payload?.sessions?.find((row) => row.key === key)?.visibility).toBe("shared");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sessions.create preserves keyed draft adoption idempotency", async () => {
|
||||||
|
await createSessionStoreDir();
|
||||||
|
const key = "agent:main:dashboard:idempotent-draft";
|
||||||
|
const first = await directSessionReq<{
|
||||||
|
sessionId: string;
|
||||||
|
entry: { visibility?: string };
|
||||||
|
}>("sessions.create", { agentId: "main", key, visibility: "draft" });
|
||||||
|
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
const retried = await directSessionReq<{
|
||||||
|
sessionId: string;
|
||||||
|
entry: { visibility?: string };
|
||||||
|
}>("sessions.create", { agentId: "main", key, visibility: "draft" });
|
||||||
|
expect(retried).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
sessionId: first.payload?.sessionId,
|
||||||
|
entry: { visibility: "draft" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testState.sessionConfig = { sharing: { drafts: false } };
|
||||||
|
const retriedAfterPolicyChange = await directSessionReq<{
|
||||||
|
sessionId: string;
|
||||||
|
entry: { visibility?: string };
|
||||||
|
}>("sessions.create", { agentId: "main", key, visibility: "draft" });
|
||||||
|
expect(retriedAfterPolicyChange).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
sessionId: first.payload?.sessionId,
|
||||||
|
entry: { visibility: "draft" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mismatch = await directSessionReq("sessions.create", {
|
||||||
|
agentId: "main",
|
||||||
|
key,
|
||||||
|
visibility: "shared",
|
||||||
|
});
|
||||||
|
expect(mismatch).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_REQUEST",
|
||||||
|
message: "sessions.create visibility requires a new session",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sessions.create rejects draft visibility when policy disables drafts", async () => {
|
||||||
|
testState.sessionConfig = { sharing: { drafts: false } };
|
||||||
|
const created = await directSessionReq("sessions.create", {
|
||||||
|
agentId: "main",
|
||||||
|
visibility: "draft",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_REQUEST",
|
||||||
|
message: "session visibility is disabled: draft",
|
||||||
|
details: { code: "SESSION_VISIBILITY_DISABLED", visibility: "draft" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("sessions.create provisions and reuses a session worktree for later runs", async () => {
|
test("sessions.create provisions and reuses a session worktree for later runs", async () => {
|
||||||
const root = await fs.mkdtemp(
|
const root = await fs.mkdtemp(
|
||||||
path.join(await fs.realpath(os.tmpdir()), "openclaw-session-worktree-"),
|
path.join(await fs.realpath(os.tmpdir()), "openclaw-session-worktree-"),
|
||||||
@@ -816,6 +919,27 @@ test("sessions.create reset-in-place clears a prior node binding for Gateway exe
|
|||||||
expect(gatewaySession.payload?.entry.execCwd).toBeUndefined();
|
expect(gatewaySession.payload?.entry.execCwd).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sessions.create does not apply create-time visibility to an in-place reset", async () => {
|
||||||
|
testState.sessionConfig = { dmScope: "main" };
|
||||||
|
await createSessionStoreDir();
|
||||||
|
await writeSessionStore({ entries: { main: sessionStoreEntry("sess-existing-main") } });
|
||||||
|
|
||||||
|
const reset = await directSessionReq("sessions.create", {
|
||||||
|
agentId: "main",
|
||||||
|
parentSessionKey: "main",
|
||||||
|
emitCommandHooks: true,
|
||||||
|
visibility: "draft",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reset).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_REQUEST",
|
||||||
|
message: "sessions.create visibility requires a new session",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("sessions.create rejects a Gateway worktree targeting a node", async () => {
|
test("sessions.create rejects a Gateway worktree targeting a node", async () => {
|
||||||
await createSessionStoreDir();
|
await createSessionStoreDir();
|
||||||
const created = await directSessionReq(
|
const created = await directSessionReq(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
finalizeNodePairingCleanupClaim,
|
finalizeNodePairingCleanupClaim,
|
||||||
recordPairedNodeConnection,
|
recordPairedNodeConnection,
|
||||||
} from "../../../infra/node-pairing.js";
|
} from "../../../infra/node-pairing.js";
|
||||||
|
import { listProfiles } from "../../../state/user-profiles.js";
|
||||||
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
||||||
import {
|
import {
|
||||||
listControlUiPluginTabs,
|
listControlUiPluginTabs,
|
||||||
@@ -21,6 +22,7 @@ import { ADMIN_SCOPE } from "../../method-scopes.js";
|
|||||||
import { scheduleNodeConnectionNotification } from "../../node-connection-notifications.js";
|
import { scheduleNodeConnectionNotification } from "../../node-connection-notifications.js";
|
||||||
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
|
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
|
||||||
import { formatError } from "../../server-utils.js";
|
import { formatError } from "../../server-utils.js";
|
||||||
|
import { allowedSessionVisibilities } from "../../session-sharing.js";
|
||||||
import { formatForLog, logWs } from "../../ws-log.js";
|
import { formatForLog, logWs } from "../../ws-log.js";
|
||||||
import { buildGatewaySnapshot, getHealthCache, getHealthVersion } from "../health-state.js";
|
import { buildGatewaySnapshot, getHealthCache, getHealthVersion } from "../health-state.js";
|
||||||
import { emitGatewayAuthSecurityEvent } from "./connect-auth-security.js";
|
import { emitGatewayAuthSecurityEvent } from "./connect-auth-security.js";
|
||||||
@@ -122,6 +124,9 @@ export async function sendGatewayHello(
|
|||||||
maxPayload: MAX_PAYLOAD_BYTES,
|
maxPayload: MAX_PAYLOAD_BYTES,
|
||||||
maxBufferedBytes: MAX_BUFFERED_BYTES,
|
maxBufferedBytes: MAX_BUFFERED_BYTES,
|
||||||
tickIntervalMs: TICK_INTERVAL_MS,
|
tickIntervalMs: TICK_INTERVAL_MS,
|
||||||
|
allowedSessionVisibilities: allowedSessionVisibilities(context.configSnapshot),
|
||||||
|
hasMultipleSessionSharingIdentities:
|
||||||
|
listProfiles().filter((profile) => !profile.mergedInto).length >= 2,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
advanceHandshakePhase("hello_payload_prepared");
|
advanceHandshakePhase("hello_payload_prepared");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
type ErrorShape,
|
type ErrorShape,
|
||||||
|
type SessionVisibility,
|
||||||
errorShape,
|
errorShape,
|
||||||
missingScopeErrorShape,
|
missingScopeErrorShape,
|
||||||
} from "../../packages/gateway-protocol/src/index.js";
|
} from "../../packages/gateway-protocol/src/index.js";
|
||||||
@@ -70,6 +71,7 @@ import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
|||||||
import { ADMIN_SCOPE } from "./operator-scopes.js";
|
import { ADMIN_SCOPE } from "./operator-scopes.js";
|
||||||
import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js";
|
import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js";
|
||||||
import { shouldPreserveSessionAuthProfileOverride } from "./session-model-patch-origin.js";
|
import { shouldPreserveSessionAuthProfileOverride } from "./session-model-patch-origin.js";
|
||||||
|
import { isSessionVisibilityAllowed, resolveSessionVisibility } from "./session-sharing.js";
|
||||||
import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js";
|
import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js";
|
||||||
import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "./session-utils.js";
|
import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "./session-utils.js";
|
||||||
import { applySessionsPatchToStore, resolveSessionPatchModelSelection } from "./sessions-patch.js";
|
import { applySessionsPatchToStore, resolveSessionPatchModelSelection } from "./sessions-patch.js";
|
||||||
@@ -272,6 +274,7 @@ export async function createGatewaySession(params: {
|
|||||||
model?: string;
|
model?: string;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
incognito?: boolean;
|
incognito?: boolean;
|
||||||
|
visibility?: SessionVisibility;
|
||||||
/** Trusted catalog-owned model/runtime pair, persisted and locked together. */
|
/** Trusted catalog-owned model/runtime pair, persisted and locked together. */
|
||||||
catalogTarget?: TrustedCatalogSessionTarget;
|
catalogTarget?: TrustedCatalogSessionTarget;
|
||||||
parentSessionKey?: string;
|
parentSessionKey?: string;
|
||||||
@@ -615,6 +618,15 @@ export async function createGatewaySession(params: {
|
|||||||
);
|
);
|
||||||
const parentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: parentAgentId });
|
const parentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: parentAgentId });
|
||||||
if (canonicalParentSessionKey === parentMainKey) {
|
if (canonicalParentSessionKey === parentMainKey) {
|
||||||
|
if (params.visibility) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
"sessions.create visibility requires a new session",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
const { performGatewaySessionReset } = await loadSessionLifecycleRuntime();
|
const { performGatewaySessionReset } = await loadSessionLifecycleRuntime();
|
||||||
const spawnedCwd = normalizeOptionalString(params.spawnedCwd);
|
const spawnedCwd = normalizeOptionalString(params.spawnedCwd);
|
||||||
const execCwd = normalizeOptionalString(params.execCwd);
|
const execCwd = normalizeOptionalString(params.execCwd);
|
||||||
@@ -742,6 +754,8 @@ export async function createGatewaySession(params: {
|
|||||||
storePath: target.storePath,
|
storePath: target.storePath,
|
||||||
},
|
},
|
||||||
async ({ existingEntry, sessionEntries }) => {
|
async ({ existingEntry, sessionEntries }) => {
|
||||||
|
// This callback owns generated and explicit keys alike; no existing row
|
||||||
|
// is the canonical signal that this request will actually create one.
|
||||||
if (
|
if (
|
||||||
isAgentHarnessSessionKey(target.canonicalKey) &&
|
isAgentHarnessSessionKey(target.canonicalKey) &&
|
||||||
!authorizedHarnessCreation &&
|
!authorizedHarnessCreation &&
|
||||||
@@ -782,6 +796,33 @@ export async function createGatewaySession(params: {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
params.visibility &&
|
||||||
|
existingEntry === undefined &&
|
||||||
|
!isSessionVisibilityAllowed(params.cfg, params.visibility)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`session visibility is disabled: ${params.visibility}`,
|
||||||
|
{ details: { code: "SESSION_VISIBILITY_DISABLED", visibility: params.visibility } },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
params.visibility &&
|
||||||
|
existingEntry !== undefined &&
|
||||||
|
resolveSessionVisibility(existingEntry) !== params.visibility
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
"sessions.create visibility requires a new session",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
// Adoption of an existing key must not stamp provenance or emit a
|
// Adoption of an existing key must not stamp provenance or emit a
|
||||||
// `created` event; only a genuinely new row is a node creation.
|
// `created` event; only a genuinely new row is a node creation.
|
||||||
createdNewEntry = existingEntry === undefined;
|
createdNewEntry = existingEntry === undefined;
|
||||||
@@ -873,6 +914,7 @@ export async function createGatewaySession(params: {
|
|||||||
// must not restamp write-once node facts (this direct store write bypasses
|
// must not restamp write-once node facts (this direct store write bypasses
|
||||||
// the merge-level write-once guard), and legacy rows stay "unknown".
|
// the merge-level write-once guard), and legacy rows stay "unknown".
|
||||||
...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}),
|
...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}),
|
||||||
|
...(params.visibility && createdNewEntry ? { visibility: params.visibility } : {}),
|
||||||
...(catalogResolvedModel && catalogAgentRuntime
|
...(catalogResolvedModel && catalogAgentRuntime
|
||||||
? {
|
? {
|
||||||
providerOverride: catalogResolvedModel.provider,
|
providerOverride: catalogResolvedModel.provider,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { GatewaySessionRow } from "../api/types.ts";
|
||||||
|
import { buildSidebarSessionNavigationState } from "./app-sidebar-session-navigation-logic.ts";
|
||||||
|
|
||||||
|
function projectDraftOwnership(
|
||||||
|
row: Pick<GatewaySessionRow, "createdActor" | "sharingRole" | "visibility">,
|
||||||
|
selfUserId?: string,
|
||||||
|
): boolean | undefined {
|
||||||
|
const context = {
|
||||||
|
basePath: "",
|
||||||
|
agentSelection: { state: { selectedId: "main" } },
|
||||||
|
gateway: {
|
||||||
|
snapshot: {
|
||||||
|
assistantAgentId: "main",
|
||||||
|
hello: null,
|
||||||
|
selfUser: selfUserId ? { id: selfUserId } : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessions: { pullRequestSummary: () => undefined },
|
||||||
|
} as unknown as Parameters<typeof buildSidebarSessionNavigationState>[0]["context"];
|
||||||
|
const navigation = buildSidebarSessionNavigationState({
|
||||||
|
context,
|
||||||
|
routeSessionKey: "agent:main:main",
|
||||||
|
sessionsResult: null,
|
||||||
|
sessionsAgentId: null,
|
||||||
|
showCron: false,
|
||||||
|
statusFilter: "active",
|
||||||
|
compareSessions: () => 0,
|
||||||
|
highlightCurrentSession: false,
|
||||||
|
runtimeSampledAtByRow: new WeakMap(),
|
||||||
|
loadingChildSessionKeys: new Set(),
|
||||||
|
outboxCountForSessionKey: () => 0,
|
||||||
|
resolveAttention: () => ({ kind: "none" }),
|
||||||
|
resolveAgentStatusNote: () => undefined,
|
||||||
|
});
|
||||||
|
return navigation.toSidebarSession({
|
||||||
|
key: "agent:main:draft",
|
||||||
|
kind: "direct",
|
||||||
|
updatedAt: 1,
|
||||||
|
...row,
|
||||||
|
}).draftOwnedBySelf;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sidebar draft ownership presentation", () => {
|
||||||
|
it("keeps owner drafts at normal emphasis", () => {
|
||||||
|
expect(
|
||||||
|
projectDraftOwnership({
|
||||||
|
visibility: "draft",
|
||||||
|
sharingRole: "owner",
|
||||||
|
createdActor: undefined,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes an admin's own draft from another person's draft", () => {
|
||||||
|
const ownDraft = {
|
||||||
|
visibility: "draft" as const,
|
||||||
|
sharingRole: "admin" as const,
|
||||||
|
createdActor: { type: "human" as const, id: "admin" },
|
||||||
|
};
|
||||||
|
expect(projectDraftOwnership(ownDraft, "admin")).toBe(true);
|
||||||
|
expect(projectDraftOwnership(ownDraft, "teammate")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never marks a shared session as an owned draft", () => {
|
||||||
|
expect(
|
||||||
|
projectDraftOwnership(
|
||||||
|
{
|
||||||
|
visibility: "shared",
|
||||||
|
sharingRole: "owner",
|
||||||
|
createdActor: { type: "human", id: "owner" },
|
||||||
|
},
|
||||||
|
"owner",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,6 +49,16 @@ import { isStoppableCloudWorkerPlacement } from "./session-row-badges.ts";
|
|||||||
|
|
||||||
type SessionRow = SessionsListResult["sessions"][number];
|
type SessionRow = SessionsListResult["sessions"][number];
|
||||||
|
|
||||||
|
function isSidebarDraftOwnedBySelf(
|
||||||
|
row: Pick<SessionRow, "createdActor" | "sharingRole" | "visibility">,
|
||||||
|
selfUserId: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
row.visibility === "draft" &&
|
||||||
|
(row.sharingRole === "owner" || Boolean(selfUserId && row.createdActor?.id === selfUserId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type SidebarSessionNavigationState = {
|
export type SidebarSessionNavigationState = {
|
||||||
routeSessionKey: string;
|
routeSessionKey: string;
|
||||||
selectedAgentId: string;
|
selectedAgentId: string;
|
||||||
@@ -113,6 +123,7 @@ export function buildSidebarSessionNavigationState(input: {
|
|||||||
pinned: row.pinned === true,
|
pinned: row.pinned === true,
|
||||||
archived: row.archived === true,
|
archived: row.archived === true,
|
||||||
visibility: row.visibility,
|
visibility: row.visibility,
|
||||||
|
draftOwnedBySelf: isSidebarDraftOwnedBySelf(row, context?.gateway.snapshot.selfUser?.id),
|
||||||
icon: row.icon,
|
icon: row.icon,
|
||||||
category: normalizeOptionalString(row.category),
|
category: normalizeOptionalString(row.category),
|
||||||
channel: channelInfo.channel,
|
channel: channelInfo.channel,
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ export function renderRecentSession(params: {
|
|||||||
session.pinned ? "session-row-host--pinned" : "",
|
session.pinned ? "session-row-host--pinned" : "",
|
||||||
running ? "session-row-host--running" : "",
|
running ? "session-row-host--running" : "",
|
||||||
session.visibility === "draft" ? "session-row-host--draft" : "",
|
session.visibility === "draft" ? "session-row-host--draft" : "",
|
||||||
|
session.visibility === "draft"
|
||||||
|
? session.draftOwnedBySelf
|
||||||
|
? "session-row-host--draft-owner"
|
||||||
|
: "session-row-host--draft-other"
|
||||||
|
: "",
|
||||||
session.attention.kind === "error"
|
session.attention.kind === "error"
|
||||||
? "sidebar-recent-session--attention-danger"
|
? "sidebar-recent-session--attention-danger"
|
||||||
: session.attention.kind !== "none"
|
: session.attention.kind !== "none"
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export type SidebarRecentSession = {
|
|||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
visibility?: SessionVisibility;
|
visibility?: SessionVisibility;
|
||||||
|
draftOwnedBySelf?: boolean;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// Control UI E2E tests cover session ownership dormancy and creator filtering.
|
// Control UI E2E tests cover session ownership dormancy and creator filtering.
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import { chromium, type Browser, type Page } from "playwright";
|
import { chromium, type Browser, type Page } from "playwright";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +15,8 @@ const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.
|
|||||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||||
|
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||||
|
const uiProofArtifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "drafts-ux");
|
||||||
|
|
||||||
let browser: Browser;
|
let browser: Browser;
|
||||||
let page: Page | undefined;
|
let page: Page | undefined;
|
||||||
@@ -54,6 +58,38 @@ function sessionsList(creators: [string, string]) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function draftSessionsList() {
|
||||||
|
const result = sessionsList(["profile-ada", "profile-bob"]);
|
||||||
|
for (const session of result.sessions) {
|
||||||
|
Object.assign(session, { visibility: "draft", sharingRole: "admin" });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureUiProof(targetPage: Page, fileName: string) {
|
||||||
|
if (!captureUiProofEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||||
|
await targetPage.screenshot({
|
||||||
|
animations: "disabled",
|
||||||
|
fullPage: true,
|
||||||
|
path: path.join(uiProofArtifactDir, fileName),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceGatewayClient(targetPage: Page) {
|
||||||
|
await targetPage.evaluate(() => {
|
||||||
|
const app = document.querySelector("openclaw-app") as HTMLElement & {
|
||||||
|
runtime?: { context: { gateway: { connect: () => void } } };
|
||||||
|
};
|
||||||
|
if (!app.runtime) {
|
||||||
|
throw new Error("OpenClaw application runtime is unavailable");
|
||||||
|
}
|
||||||
|
app.runtime.context.gateway.connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describeControlUiE2e("Control UI session ownership", () => {
|
describeControlUiE2e("Control UI session ownership", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||||
@@ -132,4 +168,178 @@ describeControlUiE2e("Control UI session ownership", () => {
|
|||||||
expect(await currentPage.getByLabel("Filter by creator").count()).toBe(0);
|
expect(await currentPage.getByLabel("Filter by creator").count()).toBe(0);
|
||||||
expect(await currentPage.locator("openclaw-session-owner-chip").count()).toBe(0);
|
expect(await currentPage.locator("openclaw-session-owner-chip").count()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps own drafts subtle and fades admin-visible drafts from other people", async () => {
|
||||||
|
if (captureUiProofEnabled) {
|
||||||
|
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { height: 800, width: 1200 },
|
||||||
|
...(captureUiProofEnabled
|
||||||
|
? { recordVideo: { dir: uiProofArtifactDir, size: { height: 800, width: 1200 } } }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
const currentPage = await context.newPage();
|
||||||
|
page = currentPage;
|
||||||
|
await installMockGateway(currentPage, {
|
||||||
|
sessionKey: "agent:main:ada",
|
||||||
|
presenceUsers: [{ self: true, id: "profile-ada", name: "Ada" }],
|
||||||
|
methodResponses: { "sessions.list": draftSessionsList() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
|
||||||
|
const ownDraft = currentPage.locator('[data-session-key="agent:main:ada"]');
|
||||||
|
const otherDraft = currentPage.locator('[data-session-key="agent:main:bob"]');
|
||||||
|
await ownDraft.waitFor();
|
||||||
|
await otherDraft.waitFor();
|
||||||
|
await expect
|
||||||
|
.poll(() => ownDraft.getAttribute("class"))
|
||||||
|
.toContain("session-row-host--draft-owner");
|
||||||
|
await expect
|
||||||
|
.poll(() => otherDraft.getAttribute("class"))
|
||||||
|
.toContain("session-row-host--draft-other");
|
||||||
|
expect(await currentPage.locator(".session-row-draft-indicator").count()).toBe(2);
|
||||||
|
await captureUiProof(currentPage, "01-sidebar-draft-treatment.png");
|
||||||
|
await currentPage.evaluate(() =>
|
||||||
|
document.documentElement.setAttribute("data-theme-mode", "dark"),
|
||||||
|
);
|
||||||
|
await captureUiProof(currentPage, "01-sidebar-draft-treatment-dark.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a draft atomically from the multi-person new-session flow", async () => {
|
||||||
|
if (captureUiProofEnabled) {
|
||||||
|
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { height: 800, width: 1200 },
|
||||||
|
...(captureUiProofEnabled
|
||||||
|
? { recordVideo: { dir: uiProofArtifactDir, size: { height: 800, width: 1200 } } }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
const currentPage = await context.newPage();
|
||||||
|
page = currentPage;
|
||||||
|
const gateway = await installMockGateway(currentPage, {
|
||||||
|
allowedSessionVisibilities: ["shared", "draft"],
|
||||||
|
hasMultipleSessionSharingIdentities: true,
|
||||||
|
methodResponses: {
|
||||||
|
"sessions.list": sessionsList(["profile-ada", "profile-bob"]),
|
||||||
|
"sessions.create": { key: "agent:main:new-draft", runStarted: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await currentPage.goto(`${server?.baseUrl ?? ""}new`);
|
||||||
|
const draftToggle = currentPage.getByLabel("Start as draft");
|
||||||
|
await draftToggle.waitFor();
|
||||||
|
await captureUiProof(currentPage, "02-create-draft-available.png");
|
||||||
|
await draftToggle.check();
|
||||||
|
await currentPage.locator(".new-session-page__message").fill("work privately first");
|
||||||
|
await captureUiProof(currentPage, "03-create-draft-selected.png");
|
||||||
|
await currentPage.getByRole("button", { name: "Start thread" }).click();
|
||||||
|
|
||||||
|
const create = await gateway.waitForRequest("sessions.create");
|
||||||
|
expect(create.params).toMatchObject({
|
||||||
|
agentId: "main",
|
||||||
|
message: "work privately first",
|
||||||
|
visibility: "draft",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes a draft through the header sharing menu", async () => {
|
||||||
|
if (captureUiProofEnabled) {
|
||||||
|
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { height: 800, width: 1200 },
|
||||||
|
...(captureUiProofEnabled
|
||||||
|
? { recordVideo: { dir: uiProofArtifactDir, size: { height: 800, width: 1200 } } }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
const currentPage = await context.newPage();
|
||||||
|
page = currentPage;
|
||||||
|
const sessions = draftSessionsList();
|
||||||
|
const ownerSession = sessions.sessions[0];
|
||||||
|
if (!ownerSession) {
|
||||||
|
throw new Error("expected owner draft fixture");
|
||||||
|
}
|
||||||
|
Object.assign(ownerSession, { sharingRole: "owner" });
|
||||||
|
const gateway = await installMockGateway(currentPage, {
|
||||||
|
sessionKey: "agent:main:ada",
|
||||||
|
featureMethods: ["chat.metadata", "chat.startup", "session.visibility.set"],
|
||||||
|
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
|
||||||
|
methodResponses: {
|
||||||
|
"sessions.list": sessions,
|
||||||
|
"session.members.list": {
|
||||||
|
sessionKey: "agent:main:ada",
|
||||||
|
members: [],
|
||||||
|
identities: [],
|
||||||
|
role: "owner",
|
||||||
|
allowedVisibilities: ["shared", "draft"],
|
||||||
|
},
|
||||||
|
"session.visibility.set": {
|
||||||
|
ok: true,
|
||||||
|
sessionKey: "agent:main:ada",
|
||||||
|
visibility: "shared",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
|
||||||
|
await currentPage.getByText("Ready.", { exact: true }).waitFor();
|
||||||
|
await currentPage.getByLabel("Thread sharing").click();
|
||||||
|
const publish = currentPage.getByText("Publish draft", { exact: true });
|
||||||
|
await publish.waitFor();
|
||||||
|
await captureUiProof(currentPage, "04-publish-draft-action.png");
|
||||||
|
await publish.click();
|
||||||
|
|
||||||
|
const request = await gateway.waitForRequest("session.visibility.set");
|
||||||
|
expect(request.params).toMatchObject({
|
||||||
|
sessionKey: "agent:main:ada",
|
||||||
|
visibility: "shared",
|
||||||
|
});
|
||||||
|
expect(await gateway.getRequests("session.visibility.set")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a selected draft mode when sharing policy becomes unavailable", async () => {
|
||||||
|
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
|
||||||
|
const currentPage = await context.newPage();
|
||||||
|
page = currentPage;
|
||||||
|
const gateway = await installMockGateway(currentPage, {
|
||||||
|
allowedSessionVisibilities: ["shared", "draft"],
|
||||||
|
hasMultipleSessionSharingIdentities: true,
|
||||||
|
methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-bob"]) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await currentPage.goto(`${server?.baseUrl ?? ""}new`);
|
||||||
|
const draftToggle = currentPage.getByLabel("Start as draft");
|
||||||
|
await draftToggle.check();
|
||||||
|
await gateway.setSessionSharingPolicy({
|
||||||
|
allowedSessionVisibilities: ["shared"],
|
||||||
|
hasMultipleSessionSharingIdentities: false,
|
||||||
|
});
|
||||||
|
await replaceGatewayClient(currentPage);
|
||||||
|
await expect.poll(() => draftToggle.count()).toBe(0);
|
||||||
|
|
||||||
|
await gateway.setSessionSharingPolicy({
|
||||||
|
allowedSessionVisibilities: ["shared", "draft"],
|
||||||
|
hasMultipleSessionSharingIdentities: true,
|
||||||
|
});
|
||||||
|
await replaceGatewayClient(currentPage);
|
||||||
|
await draftToggle.waitFor();
|
||||||
|
expect(await draftToggle.isChecked()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps create-as-draft dormant for one creator", async () => {
|
||||||
|
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
|
||||||
|
const currentPage = await context.newPage();
|
||||||
|
page = currentPage;
|
||||||
|
await installMockGateway(currentPage, {
|
||||||
|
allowedSessionVisibilities: ["shared", "draft"],
|
||||||
|
hasMultipleSessionSharingIdentities: false,
|
||||||
|
methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-ada"]) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await currentPage.goto(`${server?.baseUrl ?? ""}new`);
|
||||||
|
await currentPage.locator(".new-session-page__message").waitFor();
|
||||||
|
expect(await currentPage.getByLabel("Start as draft").count()).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -568,6 +568,7 @@ export const en: TranslationMap = {
|
|||||||
worktreeNameInvalid: "Worktree names use lowercase letters, digits, and dashes.",
|
worktreeNameInvalid: "Worktree names use lowercase letters, digits, and dashes.",
|
||||||
incognito: "Incognito",
|
incognito: "Incognito",
|
||||||
incognitoDescription: "Keep this thread only until the Gateway restarts",
|
incognitoDescription: "Keep this thread only until the Gateway restarts",
|
||||||
|
startAsDraft: "Start as draft",
|
||||||
messagePlaceholder: "What should this thread work on?",
|
messagePlaceholder: "What should this thread work on?",
|
||||||
readingAttachment: "Reading attachment",
|
readingAttachment: "Reading attachment",
|
||||||
start: "Start thread",
|
start: "Start thread",
|
||||||
@@ -3774,6 +3775,7 @@ export const en: TranslationMap = {
|
|||||||
readOnly: "Read-only",
|
readOnly: "Read-only",
|
||||||
suggest: "Suggest",
|
suggest: "Suggest",
|
||||||
draft: "Draft",
|
draft: "Draft",
|
||||||
|
publishDraft: "Publish draft",
|
||||||
members: "Members",
|
members: "Members",
|
||||||
selected: "Member",
|
selected: "Member",
|
||||||
noPeople: "No paired people found.",
|
noPeople: "No paired people found.",
|
||||||
|
|||||||
@@ -89,4 +89,42 @@ describe("chat session sharing menu", () => {
|
|||||||
expect(root.querySelector("wa-dropdown")).toBeNull();
|
expect(root.querySelector("wa-dropdown")).toBeNull();
|
||||||
expect(root.querySelector(".chat-pane__draft-indicator")?.textContent).toContain("👻");
|
expect(root.querySelector(".chat-pane__draft-indicator")?.textContent).toContain("👻");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("publishes a manageable draft through the shared visibility callback", () => {
|
||||||
|
const onVisibilityChange = vi.fn();
|
||||||
|
const root = mount(
|
||||||
|
renderChatSessionSharing({
|
||||||
|
session: {
|
||||||
|
key: "agent:main:draft",
|
||||||
|
kind: "direct",
|
||||||
|
updatedAt: 1,
|
||||||
|
visibility: "draft",
|
||||||
|
sharingRole: "owner",
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
loading: false,
|
||||||
|
result: {
|
||||||
|
sessionKey: "agent:main:draft",
|
||||||
|
members: [],
|
||||||
|
identities: [],
|
||||||
|
role: "owner",
|
||||||
|
allowedVisibilities: ["shared", "draft"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onOpen: vi.fn(),
|
||||||
|
onVisibilityChange,
|
||||||
|
onMemberChange: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const publish = root.querySelector<HTMLElement>(".chat-pane__publish-draft");
|
||||||
|
expect(publish?.textContent).toContain("Publish draft");
|
||||||
|
root.querySelector("wa-dropdown")?.dispatchEvent(
|
||||||
|
new CustomEvent("wa-select", {
|
||||||
|
detail: { item: { value: publish?.getAttribute("value") } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onVisibilityChange).toHaveBeenCalledWith("shared");
|
||||||
|
expect(root.querySelectorAll('wa-dropdown-item[value="visibility:shared"]')).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) {
|
|||||||
const identities =
|
const identities =
|
||||||
result?.identities.filter((identity) => identity.id !== result.owner?.id) ?? [];
|
result?.identities.filter((identity) => identity.id !== result.owner?.id) ?? [];
|
||||||
const allowed = result?.allowedVisibilities ?? [visibility];
|
const allowed = result?.allowedVisibilities ?? [visibility];
|
||||||
|
const canPublish = visibility === "draft" && allowed.includes("shared");
|
||||||
return html`
|
return html`
|
||||||
<wa-dropdown
|
<wa-dropdown
|
||||||
class="chat-pane__sharing-menu"
|
class="chat-pane__sharing-menu"
|
||||||
@@ -84,17 +85,26 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) {
|
|||||||
>
|
>
|
||||||
${sharingIcon(visibility)}
|
${sharingIcon(visibility)}
|
||||||
</button>
|
</button>
|
||||||
|
${canPublish
|
||||||
|
? html`<wa-dropdown-item value="visibility:shared" class="chat-pane__publish-draft">
|
||||||
|
<span>${t("chat.sessionSharing.publishDraft")}</span>
|
||||||
|
<span slot="details" aria-hidden="true">${icons.users}</span>
|
||||||
|
</wa-dropdown-item>
|
||||||
|
<div class="session-menu__separator" role="separator"></div>`
|
||||||
|
: nothing}
|
||||||
<div class="chat-pane__sharing-title">${t("chat.sessionSharing.visibility")}</div>
|
<div class="chat-pane__sharing-title">${t("chat.sessionSharing.visibility")}</div>
|
||||||
${allowed.map(
|
${allowed
|
||||||
(option) => html`
|
.filter((option) => !canPublish || option !== "shared")
|
||||||
<wa-dropdown-item value=${`visibility:${option}`} ?disabled=${option === visibility}>
|
.map(
|
||||||
<span>${t(VISIBILITY_LABEL_KEYS[option])}</span>
|
(option) => html`
|
||||||
${option === visibility
|
<wa-dropdown-item value=${`visibility:${option}`} ?disabled=${option === visibility}>
|
||||||
? html`<span slot="details" aria-hidden="true">${icons.check}</span>`
|
<span>${t(VISIBILITY_LABEL_KEYS[option])}</span>
|
||||||
: nothing}
|
${option === visibility
|
||||||
</wa-dropdown-item>
|
? html`<span slot="details" aria-hidden="true">${icons.check}</span>`
|
||||||
`,
|
: nothing}
|
||||||
)}
|
</wa-dropdown-item>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
<div class="session-menu__separator" role="separator"></div>
|
<div class="session-menu__separator" role="separator"></div>
|
||||||
<div class="chat-pane__sharing-title">${t("chat.sessionSharing.members")}</div>
|
<div class="chat-pane__sharing-title">${t("chat.sessionSharing.members")}</div>
|
||||||
${props.state?.loading
|
${props.state?.loading
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function renderBar(params: {
|
|||||||
data?: NewSessionRouteData;
|
data?: NewSessionRouteData;
|
||||||
agentSelect: unknown;
|
agentSelect: unknown;
|
||||||
placeSelect: unknown;
|
placeSelect: unknown;
|
||||||
|
draftVisibilityControl: unknown;
|
||||||
retrying: boolean;
|
retrying: boolean;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -85,7 +86,7 @@ export function renderBar(params: {
|
|||||||
return html`
|
return html`
|
||||||
<div class="new-session-page__triggers">
|
<div class="new-session-page__triggers">
|
||||||
${renderTarget(params.data)} ${isTarget(params.data) ? nothing : params.agentSelect}
|
${renderTarget(params.data)} ${isTarget(params.data) ? nothing : params.agentSelect}
|
||||||
${params.placeSelect}
|
${params.placeSelect} ${params.draftVisibilityControl}
|
||||||
${pending
|
${pending
|
||||||
? html`<span class="new-session-page__catalog-unavailable">
|
? html`<span class="new-session-page__catalog-unavailable">
|
||||||
${t("newSession.catalogUnavailable")}
|
${t("newSession.catalogUnavailable")}
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildDraftSessionCreateParams } from "./create-params.ts";
|
import { buildDraftSessionCreateParams, canStartSessionAsDraft } from "./create-params.ts";
|
||||||
|
|
||||||
|
describe("create-as-draft availability", () => {
|
||||||
|
it("requires both draft policy and multiple creator identities", () => {
|
||||||
|
expect(
|
||||||
|
canStartSessionAsDraft({
|
||||||
|
allowedVisibilities: ["shared", "draft"],
|
||||||
|
hasMultipleIdentities: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
canStartSessionAsDraft({
|
||||||
|
allowedVisibilities: ["shared"],
|
||||||
|
hasMultipleIdentities: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(canStartSessionAsDraft({ hasMultipleIdentities: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays dormant when the gateway has fewer than two identities", () => {
|
||||||
|
for (const hasMultipleIdentities of [undefined, false]) {
|
||||||
|
expect(
|
||||||
|
canStartSessionAsDraft({
|
||||||
|
allowedVisibilities: ["shared", "draft"],
|
||||||
|
hasMultipleIdentities,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildDraftSessionCreateParams", () => {
|
describe("buildDraftSessionCreateParams", () => {
|
||||||
it("keeps plain chats minimal", () => {
|
it("keeps plain chats minimal", () => {
|
||||||
@@ -28,6 +57,21 @@ describe("buildDraftSessionCreateParams", () => {
|
|||||||
).toEqual({ agentId: "main", message: "private task", incognito: true });
|
).toEqual({ agentId: "main", message: "private task", incognito: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds draft visibility only when selected", () => {
|
||||||
|
expect(
|
||||||
|
buildDraftSessionCreateParams({
|
||||||
|
agentId: "main",
|
||||||
|
message: "private work in progress",
|
||||||
|
worktree: false,
|
||||||
|
startAsDraft: true,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
agentId: "main",
|
||||||
|
message: "private work in progress",
|
||||||
|
visibility: "draft",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("includes initial-message attachments", () => {
|
it("includes initial-message attachments", () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{ type: "image", mimeType: "image/png", fileName: "pixel.png", content: "aGVsbG8=" },
|
{ type: "image", mimeType: "image/png", fileName: "pixel.png", content: "aGVsbG8=" },
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import { normalizeOptionalString } from "../../lib/string-coerce.ts";
|
|||||||
|
|
||||||
const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
|
|
||||||
|
export function canStartSessionAsDraft(params: {
|
||||||
|
allowedVisibilities?: readonly string[];
|
||||||
|
hasMultipleIdentities?: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
params.allowedVisibilities?.includes("draft") === true && params.hasMultipleIdentities === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isWorktreeNameValid(value: string): boolean {
|
export function isWorktreeNameValid(value: string): boolean {
|
||||||
const name = value.trim();
|
const name = value.trim();
|
||||||
return !name || WORKTREE_NAME_PATTERN.test(name);
|
return !name || WORKTREE_NAME_PATTERN.test(name);
|
||||||
@@ -24,6 +33,7 @@ export function buildDraftSessionCreateParams(draft: {
|
|||||||
workspace?: string;
|
workspace?: string;
|
||||||
execNode?: string;
|
execNode?: string;
|
||||||
catalogId?: string;
|
catalogId?: string;
|
||||||
|
startAsDraft?: boolean;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const cwd = normalizeOptionalString(draft.cwd);
|
const cwd = normalizeOptionalString(draft.cwd);
|
||||||
const workspace = normalizeOptionalString(draft.workspace);
|
const workspace = normalizeOptionalString(draft.workspace);
|
||||||
@@ -37,6 +47,7 @@ export function buildDraftSessionCreateParams(draft: {
|
|||||||
agentId: normalizeAgentId(draft.agentId),
|
agentId: normalizeAgentId(draft.agentId),
|
||||||
message: draft.message,
|
message: draft.message,
|
||||||
...(draft.incognito ? { incognito: true } : {}),
|
...(draft.incognito ? { incognito: true } : {}),
|
||||||
|
...(draft.startAsDraft ? { visibility: "draft" } : {}),
|
||||||
...(draft.attachments?.length ? { attachments: draft.attachments } : {}),
|
...(draft.attachments?.length ? { attachments: draft.attachments } : {}),
|
||||||
...(catalogId ? { catalogId } : {}),
|
...(catalogId ? { catalogId } : {}),
|
||||||
...(!catalogId && model ? { model } : {}),
|
...(!catalogId && model ? { model } : {}),
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ import { CloudProfileDiscovery, selectProfiles } from "./cloud-profile-discovery
|
|||||||
import { PendingCloudRecoveryState, resolveScope } from "./cloud-recovery-state.ts";
|
import { PendingCloudRecoveryState, resolveScope } from "./cloud-recovery-state.ts";
|
||||||
import { advanceCloudDraftSession } from "./cloud-submit.ts";
|
import { advanceCloudDraftSession } from "./cloud-submit.ts";
|
||||||
import { renderDraftError, renderNewSessionDraftComposer } from "./composer.ts";
|
import { renderDraftError, renderNewSessionDraftComposer } from "./composer.ts";
|
||||||
import { buildDraftSessionCreateParams, isWorktreeNameValid } from "./create-params.ts";
|
import {
|
||||||
|
buildDraftSessionCreateParams,
|
||||||
|
canStartSessionAsDraft,
|
||||||
|
isWorktreeNameValid,
|
||||||
|
} from "./create-params.ts";
|
||||||
import {
|
import {
|
||||||
type BrowserTarget,
|
type BrowserTarget,
|
||||||
type DraftCloudProfile,
|
type DraftCloudProfile,
|
||||||
@@ -43,7 +47,7 @@ import { NewSessionModelControl } from "./model-control.ts";
|
|||||||
import { isAbsolutePath } from "./path.ts";
|
import { isAbsolutePath } from "./path.ts";
|
||||||
import { renderPlaceSelect } from "./place-picker.ts";
|
import { renderPlaceSelect } from "./place-picker.ts";
|
||||||
import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts";
|
import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts";
|
||||||
import { renderAgentSelect } from "./target-controls.ts";
|
import { renderAgentSelect, renderStartAsDraftToggle } from "./target-controls.ts";
|
||||||
|
|
||||||
const CATALOG_RETRY_DELAYS_MS = [0, 1_000, 3_000] as const;
|
const CATALOG_RETRY_DELAYS_MS = [0, 1_000, 3_000] as const;
|
||||||
|
|
||||||
@@ -58,6 +62,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
@state() private worktree = false;
|
@state() private worktree = false;
|
||||||
@state() private incognito = false;
|
@state() private incognito = false;
|
||||||
@state() private worktreeName = "";
|
@state() private worktreeName = "";
|
||||||
|
@state() private startAsDraft = false;
|
||||||
@state() private baseRef = "";
|
@state() private baseRef = "";
|
||||||
@state() private repository: DraftRepositoryState = { kind: "idle" };
|
@state() private repository: DraftRepositoryState = { kind: "idle" };
|
||||||
@state() private nodes: DraftNode[] = [];
|
@state() private nodes: DraftNode[] = [];
|
||||||
@@ -173,6 +178,9 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
this.gatewayRecoveryScope = recoveryScope.next;
|
this.gatewayRecoveryScope = recoveryScope.next;
|
||||||
this.gatewayRecoveryScopeReady = snapshot.client?.recoveryScopeReady === true;
|
this.gatewayRecoveryScopeReady = snapshot.client?.recoveryScopeReady === true;
|
||||||
this.gatewayConnected = connected;
|
this.gatewayConnected = connected;
|
||||||
|
if (this.startAsDraft && !this.canStartAsDraft()) {
|
||||||
|
this.startAsDraft = false;
|
||||||
|
}
|
||||||
if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) {
|
if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) {
|
||||||
this.invalidateGatewayDiscovery(gatewayUrlChanged || recoveryScope.changed);
|
this.invalidateGatewayDiscovery(gatewayUrlChanged || recoveryScope.changed);
|
||||||
}
|
}
|
||||||
@@ -235,6 +243,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
this.worktree = false;
|
this.worktree = false;
|
||||||
this.incognito = false;
|
this.incognito = false;
|
||||||
this.worktreeName = "";
|
this.worktreeName = "";
|
||||||
|
this.startAsDraft = false;
|
||||||
this.baseRefEditGeneration += 1;
|
this.baseRefEditGeneration += 1;
|
||||||
this.nodes = [];
|
this.nodes = [];
|
||||||
this.execNode = "";
|
this.execNode = "";
|
||||||
@@ -410,6 +419,14 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
return hasOperatorAdminAccess(this.context?.gateway.snapshot.hello?.auth ?? null);
|
return hasOperatorAdminAccess(this.context?.gateway.snapshot.hello?.auth ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private canStartAsDraft(): boolean {
|
||||||
|
return canStartSessionAsDraft({
|
||||||
|
allowedVisibilities: this.context?.gateway.snapshot.hello?.policy?.allowedSessionVisibilities,
|
||||||
|
hasMultipleIdentities:
|
||||||
|
this.context?.gateway.snapshot.hello?.policy?.hasMultipleSessionSharingIdentities,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private workspacePath(): string {
|
private workspacePath(): string {
|
||||||
return normalizeOptionalString(this.selectedAgent()?.workspace) ?? "";
|
return normalizeOptionalString(this.selectedAgent()?.workspace) ?? "";
|
||||||
}
|
}
|
||||||
@@ -455,6 +472,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
this.worktree = false;
|
this.worktree = false;
|
||||||
this.incognito = false;
|
this.incognito = false;
|
||||||
this.worktreeName = "";
|
this.worktreeName = "";
|
||||||
|
this.startAsDraft = false;
|
||||||
this.baseRef = "";
|
this.baseRef = "";
|
||||||
this.repository = { kind: "idle" };
|
this.repository = { kind: "idle" };
|
||||||
this.execNode = "";
|
this.execNode = "";
|
||||||
@@ -797,6 +815,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
workspace: this.workspacePath(),
|
workspace: this.workspacePath(),
|
||||||
execNode: this.execNode,
|
execNode: this.execNode,
|
||||||
catalogId: this.data?.catalogId,
|
catalogId: this.data?.catalogId,
|
||||||
|
startAsDraft: this.startAsDraft && this.canStartAsDraft(),
|
||||||
});
|
});
|
||||||
const cloudCreateParams = cloudProfileId
|
const cloudCreateParams = cloudProfileId
|
||||||
? pendingCloud
|
? pendingCloud
|
||||||
@@ -1349,6 +1368,17 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
data: this.data,
|
data: this.data,
|
||||||
agentSelect: agents.length > 1 ? this.renderAgentSelect(agents) : nothing,
|
agentSelect: agents.length > 1 ? this.renderAgentSelect(agents) : nothing,
|
||||||
placeSelect: this.renderPlaceSelect(),
|
placeSelect: this.renderPlaceSelect(),
|
||||||
|
draftVisibilityControl: this.canStartAsDraft()
|
||||||
|
? renderStartAsDraftToggle({
|
||||||
|
checked: this.startAsDraft,
|
||||||
|
disabled: this.submitting || Boolean(this.pendingCloud.sessionKey),
|
||||||
|
onChange: (checked) => {
|
||||||
|
if (!this.submitting && !this.pendingCloud.sessionKey) {
|
||||||
|
this.startAsDraft = checked;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: nothing,
|
||||||
retrying: this.catalogRetrying,
|
retrying: this.catalogRetrying,
|
||||||
onRetry: this.handleCatalogRetry,
|
onRetry: this.handleCatalogRetry,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { render } from "lit";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { renderStartAsDraftToggle } from "./target-controls.ts";
|
||||||
|
|
||||||
|
describe("start-as-draft control", () => {
|
||||||
|
it("renders a labeled checkbox and reports the selected state", () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(renderStartAsDraftToggle({ checked: false, disabled: false, onChange }), container);
|
||||||
|
|
||||||
|
const input = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||||
|
expect(container.textContent).toContain("Start as draft");
|
||||||
|
expect(input?.checked).toBe(false);
|
||||||
|
if (!input) {
|
||||||
|
throw new Error("expected draft checkbox");
|
||||||
|
}
|
||||||
|
input.checked = true;
|
||||||
|
input.dispatchEvent(new Event("change"));
|
||||||
|
expect(onChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,3 +31,21 @@ export function renderAgentSelect(params: {
|
|||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderStartAsDraftToggle(params: {
|
||||||
|
checked: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return html`<label class="new-session-page__trigger new-session-page__draft-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
.checked=${params.checked}
|
||||||
|
?disabled=${params.disabled}
|
||||||
|
@change=${(event: Event) =>
|
||||||
|
params.onChange((event.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<span aria-hidden="true">👻</span>
|
||||||
|
<span>${t("newSession.startAsDraft")}</span>
|
||||||
|
</label>`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5445,17 +5445,34 @@ td.data-table-key-col {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-row-host--draft {
|
.session-row-host--draft {
|
||||||
opacity: 0.68;
|
--draft-row-opacity: 1;
|
||||||
|
opacity: var(--draft-row-opacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-row-host--draft:hover,
|
.session-row-host--draft-owner .session-row-draft-indicator {
|
||||||
.session-row-host--draft:focus-within,
|
opacity: 0.55;
|
||||||
.session-row-host--draft.sidebar-recent-session--selected {
|
}
|
||||||
opacity: 1;
|
|
||||||
|
.session-row-host--draft-other {
|
||||||
|
--draft-row-opacity: 0.58;
|
||||||
|
background: color-mix(in srgb, var(--bg-muted) 44%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row-host--draft-other:hover,
|
||||||
|
.session-row-host--draft-other:focus-within,
|
||||||
|
.session-row-host--draft-other.sidebar-recent-session--selected {
|
||||||
|
--draft-row-opacity: 0.88;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-row-draft-indicator {
|
.session-row-draft-indicator {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: color-mix(in srgb, var(--bg-elevated) 72%, transparent);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,21 @@ openclaw-new-session-page {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.new-session-page__draft-toggle {
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-session-page__draft-toggle input {
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-session-page__draft-toggle:has(input:disabled) {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* Keep the anchor focusable while Web Awesome finishes its hide animation. */
|
/* Keep the anchor focusable while Web Awesome finishes its hide animation. */
|
||||||
.new-session-page__trigger--hiding {
|
.new-session-page__trigger--hiding {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export type ControlUiMockGatewayScenario = {
|
|||||||
label: string;
|
label: string;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
}>;
|
}>;
|
||||||
|
allowedSessionVisibilities?: Array<"shared" | "read-only" | "suggest" | "draft">;
|
||||||
|
hasMultipleSessionSharingIdentities?: boolean;
|
||||||
featureCapabilities?: string[];
|
featureCapabilities?: string[];
|
||||||
defaultAgentId?: string;
|
defaultAgentId?: string;
|
||||||
deferredMethods?: string[];
|
deferredMethods?: string[];
|
||||||
@@ -131,6 +133,10 @@ export type MockGatewayControls = {
|
|||||||
setOnline: (online: boolean) => Promise<void>;
|
setOnline: (online: boolean) => Promise<void>;
|
||||||
setHistoryMessages: (messages: unknown[]) => Promise<void>;
|
setHistoryMessages: (messages: unknown[]) => Promise<void>;
|
||||||
setMethodResponse: (method: string, payload: unknown) => Promise<void>;
|
setMethodResponse: (method: string, payload: unknown) => Promise<void>;
|
||||||
|
setSessionSharingPolicy: (policy: {
|
||||||
|
allowedSessionVisibilities: Array<"shared" | "read-only" | "suggest" | "draft">;
|
||||||
|
hasMultipleSessionSharingIdentities: boolean;
|
||||||
|
}) => Promise<void>;
|
||||||
waitForRequest: (method: string) => Promise<MockGatewayRequest>;
|
waitForRequest: (method: string) => Promise<MockGatewayRequest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -279,6 +285,13 @@ function normalizeScenario(
|
|||||||
basePath,
|
basePath,
|
||||||
controlUiTabs: scenario.controlUiTabs ?? [],
|
controlUiTabs: scenario.controlUiTabs ?? [],
|
||||||
controlUiWidgetKinds: scenario.controlUiWidgetKinds ?? [],
|
controlUiWidgetKinds: scenario.controlUiWidgetKinds ?? [],
|
||||||
|
allowedSessionVisibilities: scenario.allowedSessionVisibilities ?? [
|
||||||
|
"shared",
|
||||||
|
"read-only",
|
||||||
|
"suggest",
|
||||||
|
"draft",
|
||||||
|
],
|
||||||
|
hasMultipleSessionSharingIdentities: scenario.hasMultipleSessionSharingIdentities ?? false,
|
||||||
featureCapabilities: scenario.featureCapabilities ?? [],
|
featureCapabilities: scenario.featureCapabilities ?? [],
|
||||||
defaultAgentId,
|
defaultAgentId,
|
||||||
deferredMethods: scenario.deferredMethods ?? [],
|
deferredMethods: scenario.deferredMethods ?? [],
|
||||||
@@ -371,6 +384,10 @@ function installControlUiMockGateway(input: {
|
|||||||
setOnline: (online: boolean) => void;
|
setOnline: (online: boolean) => void;
|
||||||
setHistoryMessages: (messages: unknown[]) => void;
|
setHistoryMessages: (messages: unknown[]) => void;
|
||||||
setMethodResponse: (method: string, payload: unknown) => void;
|
setMethodResponse: (method: string, payload: unknown) => void;
|
||||||
|
setSessionSharingPolicy: (policy: {
|
||||||
|
allowedSessionVisibilities: Array<"shared" | "read-only" | "suggest" | "draft">;
|
||||||
|
hasMultipleSessionSharingIdentities: boolean;
|
||||||
|
}) => void;
|
||||||
socketCount: () => number;
|
socketCount: () => number;
|
||||||
socketUrls: () => string[];
|
socketUrls: () => string[];
|
||||||
};
|
};
|
||||||
@@ -893,6 +910,13 @@ function installControlUiMockGateway(input: {
|
|||||||
: {}),
|
: {}),
|
||||||
protocol: protocolVersion,
|
protocol: protocolVersion,
|
||||||
server: { connId: "control-ui-e2e", version: "e2e" },
|
server: { connId: "control-ui-e2e", version: "e2e" },
|
||||||
|
policy: {
|
||||||
|
maxPayload: 1_048_576,
|
||||||
|
maxBufferedBytes: 1_048_576,
|
||||||
|
tickIntervalMs: 30_000,
|
||||||
|
allowedSessionVisibilities: scenario.allowedSessionVisibilities,
|
||||||
|
hasMultipleSessionSharingIdentities: scenario.hasMultipleSessionSharingIdentities,
|
||||||
|
},
|
||||||
snapshot: {
|
snapshot: {
|
||||||
...presenceSnapshot(params),
|
...presenceSnapshot(params),
|
||||||
sessionDefaults: {
|
sessionDefaults: {
|
||||||
@@ -1306,6 +1330,10 @@ function installControlUiMockGateway(input: {
|
|||||||
// Current-document responses still work if browser storage is unavailable.
|
// Current-document responses still work if browser storage is unavailable.
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setSessionSharingPolicy(policy) {
|
||||||
|
scenario.allowedSessionVisibilities = policy.allowedSessionVisibilities;
|
||||||
|
scenario.hasMultipleSessionSharingIdentities = policy.hasMultipleSessionSharingIdentities;
|
||||||
|
},
|
||||||
setHistoryMessages(messages) {
|
setHistoryMessages(messages) {
|
||||||
scenario.historyMessages = Array.isArray(messages) ? messages : [];
|
scenario.historyMessages = Array.isArray(messages) ? messages : [];
|
||||||
const configuredHistory = scenario.methodResponses["chat.history"];
|
const configuredHistory = scenario.methodResponses["chat.history"];
|
||||||
@@ -1558,6 +1586,21 @@ function createMockGatewayControls(page: Page, defaultSessionKey: string): MockG
|
|||||||
{ targetMethod: method, responsePayload: payload },
|
{ targetMethod: method, responsePayload: payload },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
async setSessionSharingPolicy(policy) {
|
||||||
|
await page.evaluate((nextPolicy) => {
|
||||||
|
const gateway = (
|
||||||
|
window as Window & {
|
||||||
|
openclawControlUiE2eGateway?: {
|
||||||
|
setSessionSharingPolicy: (policy: typeof nextPolicy) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).openclawControlUiE2eGateway;
|
||||||
|
if (!gateway) {
|
||||||
|
throw new Error("Mock Gateway is not installed");
|
||||||
|
}
|
||||||
|
gateway.setSessionSharingPolicy(nextPolicy);
|
||||||
|
}, policy);
|
||||||
|
},
|
||||||
async waitForRequest(method) {
|
async waitForRequest(method) {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(targetMethod) => {
|
(targetMethod) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user