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`
|
||||
|
||||
Final best-effort policy: `320237964e0 fix(sessions): make archive audit note best-effort`
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
Session state and mutation:
|
||||
### Protocol and Gateway
|
||||
|
||||
- `src/config/sessions/session-entry-provenance.ts`
|
||||
- `src/config/sessions/types.ts`
|
||||
- `src/gateway/sessions-patch.ts`
|
||||
- `src/gateway/server-methods/sessions-mutations.ts`
|
||||
- `src/gateway/server-methods/session-audit.ts`
|
||||
- `src/gateway/server-methods/sessions-sharing.ts`
|
||||
- `src/plugins/session-entry-slot-keys.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`
|
||||
- `packages/gateway-protocol/src/schema/sessions-create.ts`
|
||||
- `packages/gateway-protocol/src/schema/sessions-create.test.ts`
|
||||
- `packages/gateway-protocol/src/schema/frames.ts`
|
||||
- `src/gateway/session-create-service.ts`
|
||||
- `src/gateway/server-methods/sessions-create.ts`
|
||||
- `src/gateway/server/ws-connection/connect-hello.ts`
|
||||
- `src/gateway/server.sessions.create.test.ts`
|
||||
- `src/gateway/server.auth.default-token.suite.ts`
|
||||
- `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.test.ts`
|
||||
- `ui/src/components/app-sidebar-session-row-render.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/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`
|
||||
- `src/gateway/sessions-patch.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/lib/sessions/reconcile.test.ts`
|
||||
- `ui/src/test-helpers/app-sidebar-cases/session-ownership.ts`
|
||||
- `docs/concepts/multi-user.md`
|
||||
- `docs/gateway/config-agents.md`
|
||||
|
||||
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.
|
||||
- The acting identity comes only from `gatewayClientSessionCreator(client)`. Unidentified token/password clients get no fabricated identity and no actor-stamped audit line.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- `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.
|
||||
- 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.
|
||||
- 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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
5. Admin ownership styling compares the session creator id with the current authenticated user id because `sharingRole: "admin"` intentionally wins over `"owner"` for administrators.
|
||||
6. Promotion remains one existing `session.visibility.set` call with `visibility: "shared"`; no parallel publish method, event, audit path, or invite workflow was introduced.
|
||||
7. The sidebar's pending unsent-composer `showDraft`/`renderDraftSessionRow` machinery was not touched.
|
||||
|
||||
## 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
|
||||
pnpm install
|
||||
Done in 19.9s using pnpm v11.2.2 (exit 0)
|
||||
```
|
||||
### Install and formatting
|
||||
|
||||
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
|
||||
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)
|
||||
### Focused Vitest and browser behavior
|
||||
|
||||
node scripts/run-vitest.mjs src/gateway/server-methods/sessions-sharing.test.ts
|
||||
Test Files 1 passed, Tests 9 passed
|
||||
[test] passed 1 Vitest shard in 17.36s (exit 0)
|
||||
- `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`
|
||||
- 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
|
||||
Test Files 1 passed, Tests 3 passed (exit 0)
|
||||
```
|
||||
### Required typechecks
|
||||
|
||||
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:swift && pnpm protocol:gen:kotlin
|
||||
wrote dist/protocol.schema.json
|
||||
wrote apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
|
||||
wrote apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt
|
||||
wrote apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt
|
||||
exit 0
|
||||
- `pnpm protocol:gen`
|
||||
- Exit 0; wrote the local ignored `dist/protocol.schema.json` without adding it to Git.
|
||||
- `pnpm protocol:gen:swift`
|
||||
- Exit 0; wrote `GatewayModels.swift`.
|
||||
- `pnpm protocol:gen:kotlin`
|
||||
- Exit 0; wrote both Kotlin generator targets, with no tracked diff.
|
||||
- `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
|
||||
protocol since guard passed: 0 new core methods use train 2026.7
|
||||
exit 0
|
||||
### Docs and changed gate
|
||||
|
||||
node --import tsx scripts/native-app-i18n.ts baseline --write
|
||||
native-app-i18n: entries=5232 changed=false
|
||||
- `pnpm docs:check-mdx`
|
||||
- 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
|
||||
control-ui-i18n: raw-copy: baseline entries=105
|
||||
control-ui-i18n: source: keys=3960
|
||||
exit 0
|
||||
```
|
||||
### Autoreview
|
||||
|
||||
Requested type gates:
|
||||
|
||||
```text
|
||||
node scripts/run-tsgo.mjs -p tsconfig.core.json
|
||||
exit 0 (no diagnostics)
|
||||
|
||||
node scripts/run-tsgo.mjs -p tsconfig.ui.json
|
||||
exit 0 (no diagnostics)
|
||||
|
||||
node scripts/run-tsgo.mjs -p tsconfig.extensions.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)
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
- Branch command: `.agents/skills/autoreview/scripts/autoreview --mode branch --base 977db1c83261b13e92e7ad74c5f544e61acf1b90 --stream-engine-output`.
|
||||
- Accepted and fixed:
|
||||
- replaced the exact global profile count with privacy-preserving `hasMultipleSessionSharingIdentities`;
|
||||
- cleared stale checked draft state when policy/identity availability disappears;
|
||||
- preserved matching keyed-create adoption retries;
|
||||
- preserved those exact retries after the drafts policy is disabled while still rejecting every genuinely new disabled draft.
|
||||
- Rejected after direct verification:
|
||||
- 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.
|
||||
- Final branch result: `autoreview clean: no accepted/actionable findings reported`, overall confidence 0.94.
|
||||
|
||||
## 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
|
||||
prod 138 added, 37 deleted, net +101
|
||||
test 322 added, 11 deleted, net +311
|
||||
total 460 added, 48 deleted, net +412
|
||||
```
|
||||
| Group | Added | Deleted |
|
||||
| ---------------------- | ----: | ------: |
|
||||
| Production | 192 | 18 |
|
||||
| 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
|
||||
|
||||
- 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 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 Kotlin generated diff was committed because the Kotlin generator does not emit `SessionRow` and produced no change.
|
||||
- No ignored `dist/protocol.schema.json` was force-added; it was generated and checked, while current repository policy leaves `dist/` untracked.
|
||||
- No push, PR, changelog edit, `scripts/pr`, or live Gateway mutation was performed.
|
||||
- `origin/main` advanced during the long validation window. The branch is intentionally left for the reviewer to rebase as requested.
|
||||
|
||||
## 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.
|
||||
- No push, pull request, `scripts/pr`, release, publish, `CHANGELOG.md`, or GitHub mutation was performed.
|
||||
- No SQLite change was needed; therefore there was no schema-version bump or lazy table ensure.
|
||||
- No new Gateway event was added; existing `sessions.changed` and `session.sharing` paths retain their current scope guards and draft filtering.
|
||||
- Foreign-language UI bundles were not edited; the requested `en.ts` plus baseline workflow was used.
|
||||
- `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.
|
||||
- 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.
|
||||
|
||||
@@ -6895,6 +6895,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let model: String?
|
||||
public let thinkinglevel: String?
|
||||
public let incognito: Bool?
|
||||
public let visibility: SessionVisibility?
|
||||
public let catalogid: String?
|
||||
public let parentsessionkey: String?
|
||||
public let spawndepth: Int?
|
||||
@@ -6917,6 +6918,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
model: String? = nil,
|
||||
thinkinglevel: String? = nil,
|
||||
incognito: Bool? = nil,
|
||||
visibility: SessionVisibility? = nil,
|
||||
catalogid: String? = nil,
|
||||
parentsessionkey: String? = nil,
|
||||
spawndepth: Int? = nil,
|
||||
@@ -6938,6 +6940,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
self.model = model
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.incognito = incognito
|
||||
self.visibility = visibility
|
||||
self.catalogid = catalogid
|
||||
self.parentsessionkey = parentsessionkey
|
||||
self.spawndepth = spawndepth
|
||||
@@ -6961,6 +6964,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
case model
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case incognito
|
||||
case visibility
|
||||
case catalogid = "catalogId"
|
||||
case parentsessionkey = "parentSessionKey"
|
||||
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.
|
||||
|
||||
## 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 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:
|
||||
- H2: Trust boundary
|
||||
- H2: Ownership and presence
|
||||
- H2: Drafts
|
||||
- H2: Turn attribution
|
||||
- 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)
|
||||
- `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"`.
|
||||
- **`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.
|
||||
- `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.
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Static } from "typebox";
|
||||
import { Type } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js";
|
||||
import { SessionVisibilitySchema } from "./sessions-sharing-values.js";
|
||||
import { SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||
|
||||
export const GATEWAY_SERVER_CAPS = {
|
||||
@@ -137,6 +138,8 @@ export const HelloOkSchema = closedObject({
|
||||
maxPayload: Type.Integer({ minimum: 1 }),
|
||||
maxBufferedBytes: 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 { ChatAttachmentsSchema } from "./logs-chat.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. */
|
||||
export const SessionsCreateParamsSchema = closedObject({
|
||||
@@ -11,6 +12,7 @@ export const SessionsCreateParamsSchema = closedObject({
|
||||
model: Type.Optional(NonEmptyString),
|
||||
thinkingLevel: Type.Optional(NonEmptyString),
|
||||
incognito: Type.Optional(Type.Boolean()),
|
||||
visibility: Type.Optional(SessionVisibilitySchema),
|
||||
catalogId: Type.Optional(NonEmptyString),
|
||||
parentSessionKey: Type.Optional(NonEmptyString),
|
||||
spawnDepth: Type.Optional(
|
||||
|
||||
@@ -337,6 +337,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
thinkingLevel: p.thinkingLevel,
|
||||
incognito: p.incognito,
|
||||
...(client?.connect ? { requestingOperatorScopes: clientScopes } : {}),
|
||||
visibility: p.visibility,
|
||||
allowExistingModelSelection,
|
||||
parentSessionKey: p.parentSessionKey,
|
||||
spawnDepth: p.spawnDepth,
|
||||
|
||||
@@ -161,6 +161,10 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
type?: unknown;
|
||||
features?: { capabilities?: unknown };
|
||||
snapshot?: { configPath?: string; stateDir?: string };
|
||||
policy?: {
|
||||
allowedSessionVisibilities?: unknown;
|
||||
hasMultipleSessionSharingIdentities?: unknown;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.type).toBe("hello-ok");
|
||||
@@ -175,10 +179,37 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
);
|
||||
expect(payload?.snapshot?.configPath).toBe(createConfigIO().configPath);
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { VERSION } = await import("../version.js");
|
||||
for (const testCase of [
|
||||
|
||||
@@ -555,6 +555,109 @@ function requireNonEmptyString(value: string | undefined, label: string): string
|
||||
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 () => {
|
||||
const root = await fs.mkdtemp(
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await createSessionStoreDir();
|
||||
const created = await directSessionReq(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
finalizeNodePairingCleanupClaim,
|
||||
recordPairedNodeConnection,
|
||||
} from "../../../infra/node-pairing.js";
|
||||
import { listProfiles } from "../../../state/user-profiles.js";
|
||||
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
||||
import {
|
||||
listControlUiPluginTabs,
|
||||
@@ -21,6 +22,7 @@ import { ADMIN_SCOPE } from "../../method-scopes.js";
|
||||
import { scheduleNodeConnectionNotification } from "../../node-connection-notifications.js";
|
||||
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
|
||||
import { formatError } from "../../server-utils.js";
|
||||
import { allowedSessionVisibilities } from "../../session-sharing.js";
|
||||
import { formatForLog, logWs } from "../../ws-log.js";
|
||||
import { buildGatewaySnapshot, getHealthCache, getHealthVersion } from "../health-state.js";
|
||||
import { emitGatewayAuthSecurityEvent } from "./connect-auth-security.js";
|
||||
@@ -122,6 +124,9 @@ export async function sendGatewayHello(
|
||||
maxPayload: MAX_PAYLOAD_BYTES,
|
||||
maxBufferedBytes: MAX_BUFFERED_BYTES,
|
||||
tickIntervalMs: TICK_INTERVAL_MS,
|
||||
allowedSessionVisibilities: allowedSessionVisibilities(context.configSnapshot),
|
||||
hasMultipleSessionSharingIdentities:
|
||||
listProfiles().filter((profile) => !profile.mergedInto).length >= 2,
|
||||
},
|
||||
};
|
||||
advanceHandshakePhase("hello_payload_prepared");
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
type SessionVisibility,
|
||||
errorShape,
|
||||
missingScopeErrorShape,
|
||||
} 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 { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.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 { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "./session-utils.js";
|
||||
import { applySessionsPatchToStore, resolveSessionPatchModelSelection } from "./sessions-patch.js";
|
||||
@@ -272,6 +274,7 @@ export async function createGatewaySession(params: {
|
||||
model?: string;
|
||||
thinkingLevel?: string;
|
||||
incognito?: boolean;
|
||||
visibility?: SessionVisibility;
|
||||
/** Trusted catalog-owned model/runtime pair, persisted and locked together. */
|
||||
catalogTarget?: TrustedCatalogSessionTarget;
|
||||
parentSessionKey?: string;
|
||||
@@ -615,6 +618,15 @@ export async function createGatewaySession(params: {
|
||||
);
|
||||
const parentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: parentAgentId });
|
||||
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 spawnedCwd = normalizeOptionalString(params.spawnedCwd);
|
||||
const execCwd = normalizeOptionalString(params.execCwd);
|
||||
@@ -742,6 +754,8 @@ export async function createGatewaySession(params: {
|
||||
storePath: target.storePath,
|
||||
},
|
||||
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 (
|
||||
isAgentHarnessSessionKey(target.canonicalKey) &&
|
||||
!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
|
||||
// `created` event; only a genuinely new row is a node creation.
|
||||
createdNewEntry = existingEntry === undefined;
|
||||
@@ -873,6 +914,7 @@ export async function createGatewaySession(params: {
|
||||
// must not restamp write-once node facts (this direct store write bypasses
|
||||
// the merge-level write-once guard), and legacy rows stay "unknown".
|
||||
...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}),
|
||||
...(params.visibility && createdNewEntry ? { visibility: params.visibility } : {}),
|
||||
...(catalogResolvedModel && catalogAgentRuntime
|
||||
? {
|
||||
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];
|
||||
|
||||
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 = {
|
||||
routeSessionKey: string;
|
||||
selectedAgentId: string;
|
||||
@@ -113,6 +123,7 @@ export function buildSidebarSessionNavigationState(input: {
|
||||
pinned: row.pinned === true,
|
||||
archived: row.archived === true,
|
||||
visibility: row.visibility,
|
||||
draftOwnedBySelf: isSidebarDraftOwnedBySelf(row, context?.gateway.snapshot.selfUser?.id),
|
||||
icon: row.icon,
|
||||
category: normalizeOptionalString(row.category),
|
||||
channel: channelInfo.channel,
|
||||
|
||||
@@ -169,6 +169,11 @@ export function renderRecentSession(params: {
|
||||
session.pinned ? "session-row-host--pinned" : "",
|
||||
running ? "session-row-host--running" : "",
|
||||
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"
|
||||
? "sidebar-recent-session--attention-danger"
|
||||
: session.attention.kind !== "none"
|
||||
|
||||
@@ -69,6 +69,7 @@ export type SidebarRecentSession = {
|
||||
pinned: boolean;
|
||||
archived?: boolean;
|
||||
visibility?: SessionVisibility;
|
||||
draftOwnedBySelf?: boolean;
|
||||
icon?: string;
|
||||
category?: string;
|
||||
channel?: string;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// 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 { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -13,6 +15,8 @@ const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
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 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", () => {
|
||||
beforeAll(async () => {
|
||||
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.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.",
|
||||
incognito: "Incognito",
|
||||
incognitoDescription: "Keep this thread only until the Gateway restarts",
|
||||
startAsDraft: "Start as draft",
|
||||
messagePlaceholder: "What should this thread work on?",
|
||||
readingAttachment: "Reading attachment",
|
||||
start: "Start thread",
|
||||
@@ -3774,6 +3775,7 @@ export const en: TranslationMap = {
|
||||
readOnly: "Read-only",
|
||||
suggest: "Suggest",
|
||||
draft: "Draft",
|
||||
publishDraft: "Publish draft",
|
||||
members: "Members",
|
||||
selected: "Member",
|
||||
noPeople: "No paired people found.",
|
||||
|
||||
@@ -89,4 +89,42 @@ describe("chat session sharing menu", () => {
|
||||
expect(root.querySelector("wa-dropdown")).toBeNull();
|
||||
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 =
|
||||
result?.identities.filter((identity) => identity.id !== result.owner?.id) ?? [];
|
||||
const allowed = result?.allowedVisibilities ?? [visibility];
|
||||
const canPublish = visibility === "draft" && allowed.includes("shared");
|
||||
return html`
|
||||
<wa-dropdown
|
||||
class="chat-pane__sharing-menu"
|
||||
@@ -84,17 +85,26 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) {
|
||||
>
|
||||
${sharingIcon(visibility)}
|
||||
</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>
|
||||
${allowed.map(
|
||||
(option) => html`
|
||||
<wa-dropdown-item value=${`visibility:${option}`} ?disabled=${option === visibility}>
|
||||
<span>${t(VISIBILITY_LABEL_KEYS[option])}</span>
|
||||
${option === visibility
|
||||
? html`<span slot="details" aria-hidden="true">${icons.check}</span>`
|
||||
: nothing}
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)}
|
||||
${allowed
|
||||
.filter((option) => !canPublish || option !== "shared")
|
||||
.map(
|
||||
(option) => html`
|
||||
<wa-dropdown-item value=${`visibility:${option}`} ?disabled=${option === visibility}>
|
||||
<span>${t(VISIBILITY_LABEL_KEYS[option])}</span>
|
||||
${option === visibility
|
||||
? html`<span slot="details" aria-hidden="true">${icons.check}</span>`
|
||||
: nothing}
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)}
|
||||
<div class="session-menu__separator" role="separator"></div>
|
||||
<div class="chat-pane__sharing-title">${t("chat.sessionSharing.members")}</div>
|
||||
${props.state?.loading
|
||||
|
||||
@@ -78,6 +78,7 @@ export function renderBar(params: {
|
||||
data?: NewSessionRouteData;
|
||||
agentSelect: unknown;
|
||||
placeSelect: unknown;
|
||||
draftVisibilityControl: unknown;
|
||||
retrying: boolean;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
@@ -85,7 +86,7 @@ export function renderBar(params: {
|
||||
return html`
|
||||
<div class="new-session-page__triggers">
|
||||
${renderTarget(params.data)} ${isTarget(params.data) ? nothing : params.agentSelect}
|
||||
${params.placeSelect}
|
||||
${params.placeSelect} ${params.draftVisibilityControl}
|
||||
${pending
|
||||
? html`<span class="new-session-page__catalog-unavailable">
|
||||
${t("newSession.catalogUnavailable")}
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
// @vitest-environment node
|
||||
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", () => {
|
||||
it("keeps plain chats minimal", () => {
|
||||
@@ -28,6 +57,21 @@ describe("buildDraftSessionCreateParams", () => {
|
||||
).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", () => {
|
||||
const attachments = [
|
||||
{ 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}$/;
|
||||
|
||||
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 {
|
||||
const name = value.trim();
|
||||
return !name || WORKTREE_NAME_PATTERN.test(name);
|
||||
@@ -24,6 +33,7 @@ export function buildDraftSessionCreateParams(draft: {
|
||||
workspace?: string;
|
||||
execNode?: string;
|
||||
catalogId?: string;
|
||||
startAsDraft?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const cwd = normalizeOptionalString(draft.cwd);
|
||||
const workspace = normalizeOptionalString(draft.workspace);
|
||||
@@ -37,6 +47,7 @@ export function buildDraftSessionCreateParams(draft: {
|
||||
agentId: normalizeAgentId(draft.agentId),
|
||||
message: draft.message,
|
||||
...(draft.incognito ? { incognito: true } : {}),
|
||||
...(draft.startAsDraft ? { visibility: "draft" } : {}),
|
||||
...(draft.attachments?.length ? { attachments: draft.attachments } : {}),
|
||||
...(catalogId ? { catalogId } : {}),
|
||||
...(!catalogId && model ? { model } : {}),
|
||||
|
||||
@@ -29,7 +29,11 @@ import { CloudProfileDiscovery, selectProfiles } from "./cloud-profile-discovery
|
||||
import { PendingCloudRecoveryState, resolveScope } from "./cloud-recovery-state.ts";
|
||||
import { advanceCloudDraftSession } from "./cloud-submit.ts";
|
||||
import { renderDraftError, renderNewSessionDraftComposer } from "./composer.ts";
|
||||
import { buildDraftSessionCreateParams, isWorktreeNameValid } from "./create-params.ts";
|
||||
import {
|
||||
buildDraftSessionCreateParams,
|
||||
canStartSessionAsDraft,
|
||||
isWorktreeNameValid,
|
||||
} from "./create-params.ts";
|
||||
import {
|
||||
type BrowserTarget,
|
||||
type DraftCloudProfile,
|
||||
@@ -43,7 +47,7 @@ import { NewSessionModelControl } from "./model-control.ts";
|
||||
import { isAbsolutePath } from "./path.ts";
|
||||
import { renderPlaceSelect } from "./place-picker.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;
|
||||
|
||||
@@ -58,6 +62,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
@state() private worktree = false;
|
||||
@state() private incognito = false;
|
||||
@state() private worktreeName = "";
|
||||
@state() private startAsDraft = false;
|
||||
@state() private baseRef = "";
|
||||
@state() private repository: DraftRepositoryState = { kind: "idle" };
|
||||
@state() private nodes: DraftNode[] = [];
|
||||
@@ -173,6 +178,9 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
this.gatewayRecoveryScope = recoveryScope.next;
|
||||
this.gatewayRecoveryScopeReady = snapshot.client?.recoveryScopeReady === true;
|
||||
this.gatewayConnected = connected;
|
||||
if (this.startAsDraft && !this.canStartAsDraft()) {
|
||||
this.startAsDraft = false;
|
||||
}
|
||||
if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) {
|
||||
this.invalidateGatewayDiscovery(gatewayUrlChanged || recoveryScope.changed);
|
||||
}
|
||||
@@ -235,6 +243,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
this.worktree = false;
|
||||
this.incognito = false;
|
||||
this.worktreeName = "";
|
||||
this.startAsDraft = false;
|
||||
this.baseRefEditGeneration += 1;
|
||||
this.nodes = [];
|
||||
this.execNode = "";
|
||||
@@ -410,6 +419,14 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
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 {
|
||||
return normalizeOptionalString(this.selectedAgent()?.workspace) ?? "";
|
||||
}
|
||||
@@ -455,6 +472,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
this.worktree = false;
|
||||
this.incognito = false;
|
||||
this.worktreeName = "";
|
||||
this.startAsDraft = false;
|
||||
this.baseRef = "";
|
||||
this.repository = { kind: "idle" };
|
||||
this.execNode = "";
|
||||
@@ -797,6 +815,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
workspace: this.workspacePath(),
|
||||
execNode: this.execNode,
|
||||
catalogId: this.data?.catalogId,
|
||||
startAsDraft: this.startAsDraft && this.canStartAsDraft(),
|
||||
});
|
||||
const cloudCreateParams = cloudProfileId
|
||||
? pendingCloud
|
||||
@@ -1349,6 +1368,17 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
data: this.data,
|
||||
agentSelect: agents.length > 1 ? this.renderAgentSelect(agents) : nothing,
|
||||
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,
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
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 {
|
||||
opacity: 0.68;
|
||||
--draft-row-opacity: 1;
|
||||
opacity: var(--draft-row-opacity);
|
||||
}
|
||||
|
||||
.session-row-host--draft:hover,
|
||||
.session-row-host--draft:focus-within,
|
||||
.session-row-host--draft.sidebar-recent-session--selected {
|
||||
opacity: 1;
|
||||
.session-row-host--draft-owner .session-row-draft-indicator {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: inline-grid;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,21 @@ openclaw-new-session-page {
|
||||
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. */
|
||||
.new-session-page__trigger--hiding {
|
||||
pointer-events: none;
|
||||
|
||||
@@ -59,6 +59,8 @@ export type ControlUiMockGatewayScenario = {
|
||||
label: string;
|
||||
pluginId: string;
|
||||
}>;
|
||||
allowedSessionVisibilities?: Array<"shared" | "read-only" | "suggest" | "draft">;
|
||||
hasMultipleSessionSharingIdentities?: boolean;
|
||||
featureCapabilities?: string[];
|
||||
defaultAgentId?: string;
|
||||
deferredMethods?: string[];
|
||||
@@ -131,6 +133,10 @@ export type MockGatewayControls = {
|
||||
setOnline: (online: boolean) => Promise<void>;
|
||||
setHistoryMessages: (messages: 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>;
|
||||
};
|
||||
|
||||
@@ -279,6 +285,13 @@ function normalizeScenario(
|
||||
basePath,
|
||||
controlUiTabs: scenario.controlUiTabs ?? [],
|
||||
controlUiWidgetKinds: scenario.controlUiWidgetKinds ?? [],
|
||||
allowedSessionVisibilities: scenario.allowedSessionVisibilities ?? [
|
||||
"shared",
|
||||
"read-only",
|
||||
"suggest",
|
||||
"draft",
|
||||
],
|
||||
hasMultipleSessionSharingIdentities: scenario.hasMultipleSessionSharingIdentities ?? false,
|
||||
featureCapabilities: scenario.featureCapabilities ?? [],
|
||||
defaultAgentId,
|
||||
deferredMethods: scenario.deferredMethods ?? [],
|
||||
@@ -371,6 +384,10 @@ function installControlUiMockGateway(input: {
|
||||
setOnline: (online: boolean) => void;
|
||||
setHistoryMessages: (messages: unknown[]) => void;
|
||||
setMethodResponse: (method: string, payload: unknown) => void;
|
||||
setSessionSharingPolicy: (policy: {
|
||||
allowedSessionVisibilities: Array<"shared" | "read-only" | "suggest" | "draft">;
|
||||
hasMultipleSessionSharingIdentities: boolean;
|
||||
}) => void;
|
||||
socketCount: () => number;
|
||||
socketUrls: () => string[];
|
||||
};
|
||||
@@ -893,6 +910,13 @@ function installControlUiMockGateway(input: {
|
||||
: {}),
|
||||
protocol: protocolVersion,
|
||||
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: {
|
||||
...presenceSnapshot(params),
|
||||
sessionDefaults: {
|
||||
@@ -1306,6 +1330,10 @@ function installControlUiMockGateway(input: {
|
||||
// Current-document responses still work if browser storage is unavailable.
|
||||
}
|
||||
},
|
||||
setSessionSharingPolicy(policy) {
|
||||
scenario.allowedSessionVisibilities = policy.allowedSessionVisibilities;
|
||||
scenario.hasMultipleSessionSharingIdentities = policy.hasMultipleSessionSharingIdentities;
|
||||
},
|
||||
setHistoryMessages(messages) {
|
||||
scenario.historyMessages = Array.isArray(messages) ? messages : [];
|
||||
const configuredHistory = scenario.methodResponses["chat.history"];
|
||||
@@ -1558,6 +1586,21 @@ function createMockGatewayControls(page: Page, defaultSessionKey: string): MockG
|
||||
{ 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) {
|
||||
await page.waitForFunction(
|
||||
(targetMethod) => {
|
||||
|
||||
Reference in New Issue
Block a user