row 272: preserve untested session CLI and Console draft (sage-3145)

This commit is contained in:
jason.woltje committed 2026-10-22 21:39:34 -05:00
1 parent b23783f395
commit 850b226809
20 files changed
+953 -58

No files matched your search

+8
View File
@@ -5380,3 +5380,11 @@ Gander: after Duckworth-0100 design approval with C1-C5, prepared the two host-s
2026-10-23T01:53:25Z | Gyro fenton-0013 N7 mixed-format chapter order | Fenton approved local 0f4fa4f93. D10 now explicitly orders every chapter ID regardless of Markdown/DOCX format, inserts new canon Markdown by row216 or human committed order, and preserves ID/position at import. W7 tests cover mixed traversal/insertion/import stability. Static diff/append-only checks and docs hook pass. Local commit only, narrow re-check requested, HTTPS push still held for Sage gap. No runtime, queue or live change.
2026-10-23T02:21:06Z | Sage, rows 270+271 landing, 571 landing, MVP rows | Before: 270 (#1775) and 271 (#1776) were approved on gizmoduck/row270-271-r4 49cee846e and 2b964a082 after combined gate C2 run 5 GREEN on 53406f3e4 (bus 873, seat 202, cli 905, ten scripts, 38/38 mutants). After: 33 commits cherry-picked onto 767bb62c7 as 7328da8ca, range-diff all "=", 40 paths identical to the candidate; 270 manifest f4174458 matches, 271 manifest 3ab1f5c8 matches on 29 paths, the other 4 moved under 270 and match its manifest. Done revs 3396/3398, #1775 c30962 and #1776 c30964 closed, P19 463 c861. 571 (Gyro, Fenton APPROVE) picked as ee5f7b9ef, 6345aae3d, 767bb62c7 (blobs 9382f85b, 7ec0897b), 1ac76a417 left out; N7 911dad2aa (Fenton-0014) picked after. Rows 293-299 added (574, FED R6, R4, R7, GitHub grant, Q228, 573), queue commit 253346e3c. Correction: the push sync step checked out O..S paths over the canonical worktree without testing for uncommitted edits, so any pending SESSIONS or BUILD-LOG line in the canonical checkout at 02:06Z would have been overwritten; the sync now skips edited paths. Schema 3n is not live (LIVE 0cbe2e9b3 at 3m).
2026-10-22 | Huey | Row 272 / 264e (#1777) preparation on huey/row272, base 2d0d407ba. Before: bus session metadata and launcher isolation exist, CLI/Console expose only the saved thread default. Draft: session CLI, Console chooser with isolated drafts/cursors/receipts, metadata counts and safe-clear operation receipts against Gizmoduck's 0260 contracts; narrow deferred B6 reply fence. Tests authored, not run: Gander row 253 holds Sage's slot; no Node, claim, HTTPS git, push or live changes. This entry records preparation after the first draft, not a green acceptance result.
2026-10-22 | Huey | Row 272 draft, 264d additive contract gizmoduck-0272 (78e14352f). Added confirmed cancellation through the human channel, cancelled receipt retirement distinction, named refusals and routed-message cancellation output. Before: cancelled was not recognized as terminal; after: draft CLI stops polling and Console shows the before/after retirement outcome. Acceptance remains unverified: no Node/tests/claim/commit/push under Sage's gate. No runtime/lifecycle authority expanded.
2026-10-23 | Huey | Row 272 / 264e sage-3141 local rebase. Before: uncommitted draft on 2d0d407ba. After: same draft on new huey/row272-b23783f39 at b23783f39, with 270/271 landed (7328da8ca) and schema 3n. Product changes auto-merged; both append-only logs retain the full new base and original Huey additions. Checked landed session.list and clear/cancel/operation envelopes against the wired draft: no incompatible contract change. Historical unavailable-dependency cases stay as stand-ins; actual base supplies counters and lifecycle state. No tests/Node/claim/commit/push while Gander holds row 574 run 2. Existing-test fixture/assertion delta is named in the packet for Mos's ruling via Sage before execution.
2026-10-23 | Huey | Row 272 sage-3145 preservation correction. refs/stash is shared, so it was unsafe as sole draft storage. Pinned exact stash commit 07dc9d2f7944b4781b5e80dfa18425f4749f7a45 to local huey/row272-stash-keep and left the entry untouched. Sage explicitly directed committing the rebased draft before testing to preserve it: an untested local checkpoint, no acceptance claim. No Node/test/queue/network/live action. Separate claims and test CLEARs remain pending; old-test delta is with Mos via sage-3144.
+160
View File
@@ -0,0 +1,160 @@
# Row 272 / 264e (#1777): session CLI and Console chooser
Owner Huey; reviewer Scrooge per sage-2816 / mos-1310 item 2 (supersedes sage-2796). Current base b23783f39, branch huey/row272-b23783f39 (sage-3141). Original draft base 2d0d407ba on huey/row272.
Brief: docs/plans/2026-10-21_t1-sessions.md (ff0be0c79), section 8; row 264 rulings
at 5f5b51dd6. Dependencies: 264a-d landed; 270/271 landing is 7328da8ca.
Count/clear implementation and contracts are now in the current base. No runtime, launcher, library adapter,
role, schema or queue edits belong to this slice.
## Execution and evidence
1. Read session/channel/HTTP/browser contracts and write isolation/validation tests.
2. Add session CLI through the existing verified human channel, preserving defaults.
3. Add authenticated Console chooser, per-session drafts/history/cursors, count and
clear status. Unavailable dependency verbs refuse explicitly; no simulated success.
4. On Sage's CLEAR: claim, run focused tests and required suites/mutants, repair and
record exact evidence. No Node, tests, HTTPS git, claim or push under Gander's gate.
5. After 253 and 269 land, rebase on a new branch name. Review packet to Scrooge,
Mos, Gizmoduck and Sage; push only when permitted.
2026-10-22 preparation: clean worktree switched locally to the requested base. Read
approved brief and all Q1-Q7 rulings. Della notified: no startBroker/library changes.
Gizmoduck asked to pin count/clear contracts. Tests will be written first; Sage's
explicit gate restriction defers RED/GREEN execution. No test result claimed.
Verification plan: CLI real-broker isolation and channel refusal; HTTP strict keys,
Origin/Fetch Metadata, forged selector/refusal and no retries; fixture-browser two
sessions, isolated draft/cursor/receipt, deep-link/refresh, count unknown/known,
read-only writes, clear confirmation/pending. Add selector omission and cursor/draft
sharing mutants. Broader cli/webui/plugins suites use Sage's gate only.
264c/d contracts received gizmoduck-0260: session.list gains compactions and revision/state;
session.clear and session.operation use the pinned envelopes. CLI polls at most 30 s
then reports pending; Console status checks use GET only. Counts absent on 264b show
unavailable; unknown never becomes zero. Clear absent on 264b is disabled with a reason.
USER_VERBS and tester listener write policy remain unchanged (Q7). Existing collaborators
therefore cannot enter the new session surface until the reviewed RBAC integration.
Consumer audit found the explicitly deferred B6 user-reply scope (DEFERRED.md:455-459,
queue-264/BUILD.md:317-322) bound to 272. Added a narrow broker predicate change from
thread to session and a sibling-session regression fixture, without widening user verbs.
This is the only planned broker change and does not overlap Della's runtime adapter.
2026-10-22 static review: added explicit --session to thread read/open and talk/follow;
omission remains the saved default. CLI clear first reads a recorded operation, so a
repeat after a lifecycle revision change cannot become a conflicting second write.
Console deep links name both thread and session; accepted-send receipts, drafts and
history pagination use that tuple. Clear-status GETs refresh the envelope without
reissuing clear. Read failures discard private metadata; late generations cannot paint
another selection. Scrooge is reviewer per sage-2816 / mos-1310, superseding the first
assignment. CLEAR remains after 269, 275, 203 and 207. No execution evidence yet.
## Acceptance / mutant map (prepared, unexecuted)
| Check | Evidence file / intended kill |
| --- | --- |
| T5 legacy human, forged agent, foreign session, strict HTTP identity/Origin/site | session-cli.test.mjs, chat.test.mjs; unchanged USER_VERBS matrix in user-principal.test.mjs |
| T7 saved default, explicit CLI session selector, metadata-only create | session-cli.test.mjs real broker and thread CLI compatibility tests |
| T7 Console chooser, deep link, draft/cursor/receipt isolation, unknown count, confirmation/pending | chat-browser.test.mjs; existing history/sign-out/late-read tests run whole |
| Count/clear contract absence | CLI unavailable refusal; Console disabled clear reason; exact zero vs unknown lower bound tests |
| B6 assigned follow-up | user-principal.test.mjs sibling-session citation refusal and valid same-session reply |
| M1 omit session in chat.mjs messages forwarding | chat.test.mjs explicit selector deepEqual fails |
| M2 collapse browser state key from thread/session to thread | chooser draft isolation assertion fails |
| M3 treat known:false count:0 as exact zero | CLI label assertion and browser unknown-count assertion fail |
| M4 restore B6 mt.thread/s.thread predicate | user-principal sibling refusal assertion fails |
| M5 remove session from CLI thread.send payload | explicit --session talk assertion fails |
| M6 repeat a clear write in place of session.operation poll | CLI exactly-one-clear assertion fails |
Intended focused commands, only after CLEAR, one process at a time:
- `timeout -k 10 300 node --test packages/cli/tests/session-cli.test.mjs`
- `timeout -k 10 300 node --test packages/cli/tests/thread-cli.test.mjs`
- `timeout -k 10 300 node --test packages/bus/tests/user-principal.test.mjs`
- `timeout -k 10 300 node --test packages/plugins/tests/channel.test.mjs`
- `timeout -k 10 300 node --test packages/webui/tests/chat.test.mjs`
- `timeout -k 10 600 node --test packages/webui/tests/chat-browser.test.mjs`
Whole touched suites on Sage's slot: bus (900 s), cli (1800 s, >=3x recorded 591 s),
webui (600 s), plugins (600 s), plus required integration scripts per final gate brief.
Mutants run in disposable copies of the pinned candidate, never against a live stack;
only failed assertions count as kills, never a timeout or import/setup failure.
No test process, browser, provider, credential read or live service action ran in this
preparation. `git diff --check` is the only executed draft verification so far.
Next: Sage CLEAR for claim/focused runs; repair against actual results, then rebase on
a newly named branch once 253 and 269 land. Generate final manifest only after those
changes. Review handoff is Scrooge and Mos, copying Gizmoduck and Sage; no handoff to
Duckworth under the superseded assignment. No candidate commit or green claim yet.
2026-10-22, gizmoduck-0272 / sage-2828 additive contract at 78e14352f: added
session.cancel to CLI/channel/Console with confirmed human writes and strict keys.
Cancelled envelopes retain `retired:null` (old active) or `true` (startup cancelled,
old retired); cancelled is terminal for polling. CLI reports routed message cancellation
with stored ID and exit 3, never waits for delivery or replays it. HTTP maps
session-name-too-long and session-clear-complete without reflecting transport text.
Added unexecuted before/after cancellation, identity/Origin, confirmation and message
receipt tests. Existing clear/create/read wiring is preserved. Asked Gizmoduck whether
the deterministic successor keeps 264a's UUID shape, as selectors currently require.
No Node, test run, claim, commit, push, provider call or live action. Static diff only.
Gizmoduck-0274 resolves the selector compatibility check: successors are lowercase
RFC 9562 v8 UUIDs (variant 10), while 264a sessions are v4. Existing generic UUID
validators accept both and stay unchanged. CLI explicit-selector and Console chooser
fixtures now include mixed v4/v8 IDs to prevent a later v4-only regression. Unexecuted.
## sage-3141 rebase and landed-contract check (2026-10-23)
Reapplied the uncommitted draft onto b23783f39 in the same isolated worktree, on new
branch huey/row272-b23783f39. No candidate commits existed to replay. Preserved the
original draft in the named local stash `huey row272 draft before sage-3141 rebase`.
Product files merged without conflict. Only append-only BUILD-LOG.md and SESSIONS.md
conflicted: kept every new-base byte and appended the original Huey entries unchanged.
No force push, fetch, Node, test, queue write or live change. No claim or candidate
commit yet. Static `git diff --check` clean after resolution.
Checked the actual landed broker, not just the older contract packet:
- broker.mjs:687-707 clearEnvelope supplies the same op/thread/session/successor/state/
start/refusal/at fields and cancelled-only retired:null|true. The generic UUID
selectors still accept both original v4 and successor v8 IDs.
- broker.mjs:6227-6245 session.list supplies compactions, state, revision and successor;
count reader/unknown behavior is the landed 270 contract. Real-base tests must now
exercise these available fields; stand-ins still exercise an unavailable dependency.
- broker.mjs:6270-6330 clear/cancel/operation inputs and receipts match the wired contract.
clear repeats preserve the op; cancel also refuses a failed clear as
session-clear-complete. Our existing refusal mapping already covers it.
- queue-271/BUILD.md:225-238 clarifies cancellation after retirement cancels this clear's
startup, while later deliberate human open/send can start the fresh successor. It
does not permanently disable that new session. No frontend authority change needed.
- store.mjs:17 is 3n; migration is 3m-3n.sql. This slice adds/pins no schema step,
migration blob or schema test count. No DDL copied from an older branch.
Plan after the Gander 574 run-2 hold: on a claims-only CLEAR, claim only; run no Node
or tests beyond the explicit scope of Sage's grant. On test CLEAR run new CLI tests
first, then new broker/HTTP/browser cases and whole touched files, sequentially under
the recorded timeouts. Place/report a first-run red only in a new test; do not alter
an old test, loosen an assertion or fix a second red without Mos's ruling via Sage.
Whole-suite/mutant execution remains on Sage's slot. Candidate commit and push follow
applicable green evidence and an allowed gap, then Scrooge/Mos handoff with final SHA,
manifest, copying Gizmoduck/Sage.
Existing-test edits in the inherited draft need Mos's ruling through Sage before
execution: channel.test.mjs extends its existing supported-verbs table for five
session verbs; chat-browser.test.mjs extends the existing fetch fixture with session
catalog/operation responses and changes its exact send-payload assertion to include
the selected session. Assertions are not loosened. All other added test cases are
new tests. These changes were prepared before sage-3141 and have never run. Ask Sage
to forward this precise delta rather than treating a later old-test red as permission.
sage-3145 correction: refs/stash is shared across worktrees; using it as sole draft
storage was wrong. Pinned the exact draft commit 07dc9d2f7944b4781b5e80dfa18425f4749f7a45
on local branch huey/row272-stash-keep before any stash cleanup. The entry remains
untouched; the branch retains its tracked and untracked snapshots. Future seat draft
preservation uses owned branches/local commits, not shared stash positions.
Sage explicitly directed a local draft commit before tests to preserve the rebased
work. This overrides the normal green-before-commit gate for this storage checkpoint
only. It is an untested draft, not an approved candidate, test result, push or landing.
Claims await the post-574-run-2 CLEAR; test execution awaits Mos's ruling and Sage's
separate test CLEAR. Old-test deltas were forwarded as sage-3144; no ruling assumed.
@@ -0,0 +1,16 @@
fb518c370aeb531220de123464f0a809156fc546b51d7195f4f9ca660409cf22 packages/bus/src/broker.mjs
35d044b0904ce913b3356b02cdc5bf22babecf738b511dca80c75f704e4ef78e packages/bus/tests/user-principal.test.mjs
e795bc4ee5aa038835edfeabe3498e53fdd700df478198cac6767498e2fac0b7 packages/cli/README.md
96c4f93628964b6ea10fb87d5cf936f9ecf2e7286aa20c0914ae8b0fb32c47e7 packages/cli/src/cli.mjs
0c31ac7260e77556f30f33a9932dc59a30f394982d8556650af7c521d2bcc947 packages/cli/src/session.mjs
58888b7731f8009d4c3f8be2caf4445f13dd8f534e1633f6e2720b259fcc3f92 packages/cli/src/thread.mjs
27fb7dd91007ec5f729fbe854219f423177cf4db2d44d1f8b04c7e8c5e6db90a packages/cli/tests/session-cli.test.mjs
5e7e1ec169392ee3065f8f47d19732ebbe8bdd90f9193581425b956e66da6b73 packages/plugins/channel/cli/index.mjs
aa086c365c4a6b0eaf84359a20ef2b30e723013ada6573fb0133163d5bf98a74 packages/plugins/channel/webui/index.mjs
143bf601badf5cc2b25cb24368ace2ec34840abec858d272af239d6a4ddcdecb packages/plugins/tests/channel.test.mjs
0720f66a57d965c9275f598b7c7ac438f238a0fac52808fc8f10312b85ba2727 packages/webui/src/chat-http.mjs
65fd3fd2337aaa88245a9fb10b55fd872f9150085ff9a53fe522e758a93c64fa packages/webui/src/chat.mjs
88dd353f5df0603ed1fd43dd543ef7b45af93fee72b6bb6a03a26c068202fbd3 packages/webui/src/public/chat.css
074d570aee4e2b6cdcbd6b8be399a2a21e863f2d620f95dad8d93ffc7d9c46da packages/webui/src/public/chat.js
2161c4830a3f73f0343e76521b948b933e401f7b9ba4ef8a5c75bf99c565d6b6 packages/webui/tests/chat-browser.test.mjs
b79a8809951580187b581450cdd4012245f2d88a371bf7d2f123580af8815449 packages/webui/tests/chat.test.mjs
+4
View File
@@ -1565,3 +1565,7 @@ are never rewritten or removed; corrections are new entries.
2026-10-23T01:49:08Z | Gyro (Codex gpt-6.1-sol, fenton-0012/mos-1379) | 571 B1/B2 and N1-N6 docs corrections | Real-helper denial gate and two off-by-default human review settings; static acceptance/brief/immutable-mechanics checks pass. Local candidate for Fenton via shared Git objects; no HTTPS push before Sage gap, no runtime or credential experiment.
2026-10-23T01:53:25Z | Gyro (Codex gpt-6.1-sol, fenton-0013) | 571 N7 chapter-order clarification | D10 and W7 explicitly cover mixed formats and stable chapter identity/position. Docs checks pass; local candidate sent for narrow re-check, push held for Sage gap. No runtime or live change.
2026-10-23T02:21:06Z | Sage (Opus 5.5, 1ef1e4f8) | Gap after 270+271 run 5: landings and MVP rows | 270+271 LANDED 7328da8ca (mos-1351), 571 landed 767bb62c7 plus N7, rows 293-299 added and committed 253346e3c, 270 request c30929 and Duckworth verdict c30930 posted, 219 rebase conflict ruled (rocko/row219 fd17203ed), 574 CLEAR to Gander 02:19Z.
2026-10-22 | Huey (Codex gpt-6.1-sol, cabd45ca) | Row 272 / 264e (#1777), sage-2796, huey/row272 on 2d0d407ba | CLI/Console session work in preparation; test execution/claim/push deferred under Gander's gate, no runtime or live change.
2026-10-22 | Huey (cabd45ca) | Row 272 additive 264d contract gizmoduck-0272 at 78e14352f | Draft cancellation UI/CLI and terminal receipts added; tests authored but not run under sage-2816 gate, no commit/push/live change.
2026-10-23 | Huey (cabd45ca) | Row 272 sage-3141 local rebase | Draft reapplied from 2d0d407ba onto b23783f39 on huey/row272-b23783f39; product merge clean, append-only log conflicts retained both sides. Landed 270/271 contracts checked; no Node/tests/claim/commit/push pending CLEAR.
2026-10-23 | Huey (cabd45ca) | Row 272 sage-3145 draft preservation | Shared stash commit 07dc9d2f7 pinned to huey/row272-stash-keep; rebased draft committed locally by explicit Sage instruction before tests. Untested checkpoint only; claims/test/push CLEARs still pending.
+4 -4
View File
@@ -5504,13 +5504,13 @@ export class Broker {
)
fail('tester-reply-required');
// FED R3 (design 3.6 item 7, B6): a run answers a user only in reply to that user's message
// in its own thread, the thread the user owns. Anything else, another user's message or one
// outside the thread included, refuses.
// in its own session of the thread the user owns (264e, deferred B6). A sibling
// session, another user or another thread cannot supply that citation.
if (to === 'user') {
const m = id(a.in_reply_to) && s.thread
? this.#store.get(
"SELECT m.from_run FROM messages m JOIN message_threads mt ON mt.message=m.id JOIN threads t ON t.id=mt.thread WHERE m.id=? AND m.business=? AND m.from_role='user' AND mt.thread=? AND t.owner=m.from_run",
a.in_reply_to, s.business, s.thread)
"SELECT m.from_run FROM messages m JOIN message_threads mt ON mt.message=m.id JOIN threads t ON t.id=mt.thread WHERE m.id=? AND m.business=? AND m.from_role='user' AND mt.session=? AND t.owner=m.from_run",
a.in_reply_to, s.business, s.session)
: null;
if (!m) fail('user-reply-required');
}
@@ -394,3 +394,20 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
test('R3 r4: a non-function access throws before the store is read', () => {
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
});
test('264e deferred B6: a reply cannot cite the same user in a sibling session', t => {
const { b, store, caps, call, A } = granted(t);
// Session writes remain off USER_VERBS (Q7). Seed the future multi-session case directly,
// without granting a collaborator new authority just to exercise the reply fence.
const sibling = 'f0000000-0000-4000-8000-000000000001';
store.run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,?,instance,owner,'sibling','264e-fixture' FROM threads WHERE id=?", sibling, A);
const original = call('alice', 'thread.send', { thread: A, body: 'original asks' }).id;
const second = call('alice', 'thread.send', { thread: A, session: sibling, body: 'sibling asks' }).id;
caps.worker = b.bindLaunch({ business:'demo', role:'assistant', run:'session-worker', harness:'pi', thread:A, session:sibling });
call('worker', 'role.claim');
const before = store.get('SELECT count(*) n FROM messages').n;
assert.throws(() => call('worker', 'message.send', { to:'user', in_reply_to:original, body:'wrong context' }), { code:'user-reply-required' });
assert.equal(store.get('SELECT count(*) n FROM messages').n, before);
const reply = call('worker', 'message.send', { to:'user', in_reply_to:second, body:'right context' });
assert.equal(store.get('SELECT session FROM message_threads WHERE message=?', reply.id).session, sibling);
});
+42
View File
@@ -1145,3 +1145,45 @@ result. Resume refuses unconfirmed stops and a stale generation. A restarted
`bus start --pm` keeps its control bus up while halted and skips the chief.
The switch does not clear other launch/Discord brakes or retry tasks. Native
diagnostic launch stays available. See packages/processing-control/README.md.
### Independent thread sessions (264e)
`mosaic session list <thread>` lists logical sessions and the saved default. `show`
reads metadata/count; `open <thread> <session>` requests that session's normal guarded
launch. Reads and `create <thread> <name> --op <key>` spend nothing and start no run.
Use `--json` for receipts and `--business <id>` as on the thread commands. A session
reference is its full ID, unambiguous ID prefix (8+ characters), or exact name in that
thread. Creating or opening a session never changes the saved default. Legacy thread
commands continue using that default.
`mosaic session clear <thread> <session> --yes --op <key>` requests safe context
replacement. The confirmation preserves old history and all old workspace files,
including dirty/untracked/unpushed work. The successor gets a fresh checkout at the
approved revision; old work is not silently copied. No forced stop is authorized.
The command waits up to 30 seconds by reading `session.operation`, then prints pending
if stop proof/capacity is outstanding. Keep the operation key: `mosaic session operation
<thread> <key> --json` reads the recorded outcome. A transport failure never triggers an
automatic second clear. Count/clear dependencies that are not installed report
unavailable; unsupported or incomplete count evidence reports unknown, never false zero.
The Console Chat thread pane offers the same chooser, New session and Clear context.
Links preserve the selected session; drafts, paging cursors and receipts belong only
to that session. Clear status can be checked with its operation key without another
write. Read-only listeners disable writes. Until the reviewed multi-user integration,
these operations retain the legacy human's own-thread authority; they grant neither
collaborator session writes nor organization-owner access to others' private context.
To address a nondefault session explicitly, use `mosaic talk --thread <thread>
--session <full-session-id> <text>`, `mosaic follow --thread <thread> --session <id>`,
or `mosaic thread read|open <thread> --session <id>`. Omitting `--session` preserves the
legacy saved-default behavior; opening a chooser session never redirects later sends.
`mosaic session cancel <thread> <clear-op-key> --yes` cancels a clear using its recorded
operation key. Before retirement this restores the old session to active without
replaying cancelled inputs. After retirement it cancels successor startup only; the
old session stays retired. A successor that already started refuses cancellation.
The Console's Check clear operation field and Cancel clear action use the same key
and require confirmation. Cancellation adds no force-stop or kill-switch authority.
Both before/after outcomes appear as `cancelled`, with `retired: null` or `true` in JSON.
If a routed message is stored but cancelled because its session is clearing/retired,
the CLI prints its ID, reports the cancellation and exits 3 without waiting for a reply.
+13 -3
View File
@@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url";
import { BINDING_NAME } from "../../discord/src/binding.mjs";
import { projectBusCommand, projectCommand, PROJECT_BUS_USAGE, PROJECT_USAGE } from "./project.mjs";
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
import { sessionCommand, SESSION_USAGE } from "./session.mjs";
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
@@ -50,6 +51,7 @@ export const USAGE = `usage:
${PROJECT_BUS_USAGE}
${THREADS_USAGE}
${THREAD_USAGE}
${SESSION_USAGE}
mosaic inbox [--business <id>] [--json] [--tester]
mosaic inbox --read <id>... | --read-all [--business <id>]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
@@ -377,8 +379,10 @@ function waitingLines(w, business) {
// tester's replies arrive only through message.receive, as for the human; inbox --tester lists
// decisions only.
async function talk(rest, io, deps, receiveOnly = false) {
const parsed = parse(rest, { flags: ["--follow", "--tester"], values: ["--business", "--thread", ...(receiveOnly ? [] : ["--wait"])] });
const parsed = parse(rest, { flags: ["--follow", "--tester"], values: ["--business", "--thread", "--session", ...(receiveOnly ? [] : ["--wait"])] });
const threadRef = parsed.values["--thread"];
const session = parsed.values["--session"];
if (session !== undefined && (threadRef === undefined || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(session))) throw usage('--session needs --thread and a full session id');
if (parsed.positional.length !== (receiveOnly ? 0 : threadRef === undefined ? 2 : 1)) {
throw usage(receiveOnly ? "follow takes no arguments" : threadRef === undefined ? "talk takes a role and one quoted text" : "talk --thread takes one quoted text");
}
@@ -398,7 +402,7 @@ async function talk(rest, io, deps, receiveOnly = false) {
}
if (!receiveOnly) {
try {
sent = await call(thread ? "thread.send" : "message.send", thread ? { thread: thread.id, body, class: "REQUEST" } : { to, body, class: "REQUEST" });
sent = await call(thread ? "thread.send" : "message.send", thread ? { thread: thread.id, ...(session ? { session } : {}), body, class: "REQUEST" } : { to, body, class: "REQUEST" });
} catch (e) {
// The bus never picks a thread for the human, and neither does the CLI (row 128).
if (e.code === "thread-required-use-thread-send") {
@@ -411,6 +415,11 @@ async function talk(rest, io, deps, receiveOnly = false) {
}
throw e;
}
if (sent.cancelled !== undefined) {
if (!['session-clearing', 'session-retired'].includes(sent.cancelled)) throw new CliError('outcome unknown: invalid cancellation receipt; nothing was retried', 1);
io.stdout.write(`stored message ${sent.id}\n`);
throw new CliError(`message cancelled (${sent.cancelled}); it was not delivered and will not be replayed`, 3);
}
io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
if (sent.waiting) io.stdout.write(waitingLines(sent.waiting, () => businessFor(parsed, (deps.system ?? loadSystem({ env: io.env })).dataRoot)));
if (!follow && wait === 0) return;
@@ -418,7 +427,7 @@ async function talk(rest, io, deps, receiveOnly = false) {
// message.receive says nothing of a message's thread, so --follow asks the thread for its ids.
// Reading the thread first makes `follow --thread` refuse a thread that isn't the user's before
// anything is received: message.receive writes a delivery row for every message it returns.
const members = thread && follow ? threadMembers(call, thread.id) : null;
const members = thread && follow ? threadMembers(call, thread.id, session) : null;
await members?.refresh();
const pollMs = deps.talkPollMs ?? TALK_POLL_MS;
const deadline = Date.now() + wait * 1000;
@@ -630,6 +639,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "session") return sessionCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
if (verb === "access") {
const system = deps.system ?? loadSystem({ env: io.env });
+94
View File
@@ -0,0 +1,94 @@
// Row 272 / 264e: logical thread sessions, through the verified human channel only.
import { CliError } from './errors.mjs';
import { parseArgs, resolveThread } from './thread.mjs';
export const SESSION_USAGE = [
'mosaic session list <thread> [--json] [--business <id>]',
'mosaic session show|open <thread> <session> [--json] [--business <id>]',
'mosaic session create <thread> <name> --op <key> [--json] [--business <id>]',
'mosaic session clear <thread> <session> --yes --op <key> [--json] [--business <id>]',
'mosaic session cancel <thread> <key> --yes [--json] [--business <id>]',
'mosaic session operation <thread> <key> [--json] [--business <id>]',
].join('\n ');
const KEY = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/;
const json = (io, value) => io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
export const compactionLabel = value => !value ? 'unavailable (count collector not installed)' :
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
`unknown (${value.reason ?? 'not-collected'})`;
const missing = e => e.code === 'unknown-verb' || e.code === 'channel-operation';
async function operation(call, verb, args) {
try { return await call(verb, args); }
catch (e) {
if (missing(e)) throw new CliError('session operation unavailable: its dependency is not installed; nothing was retried', 3);
throw e;
}
}
// Never resolve a foreign full ID by falling back to the thread default.
export function selectSession(rows, ref) {
const exact = rows.find(r => r.id === ref) ?? rows.find(r => r.name === ref);
if (exact) return exact;
const hits = /^[a-f0-9-]{8,}$/i.test(ref) ? rows.filter(r => r.id.startsWith(ref)) : [];
if (hits.length === 1) return hits[0];
throw new CliError(hits.length > 1 ? 'ambiguous session; use the full id' : 'session not found in this thread; use mosaic session list', 2);
}
const validReceipt = (v, thread, op, session) => v && v.thread === thread && v.op === op &&
(session === undefined || v.session === session) && typeof v.successor === 'string' &&
['clearing', 'uncertain', 'retired', 'failed', 'cancelled'].includes(v.state) && [null, 'held', 'started', 'refused'].includes(v.start) &&
(v.state !== 'cancelled' || v.retired === null || v.retired === true);
const receipt = (value, thread, op, session) => {
if (!validReceipt(value, thread, op, session)) throw new CliError(`outcome unknown; check mosaic session operation ${thread} ${op}; nothing was retried`, 1);
return value;
};
const terminal = value => ['failed', 'retired', 'cancelled'].includes(value.state);
export async function sessionCommand(args, io, callFor, { now = Date.now, sleep = ms => new Promise(r => setTimeout(r, ms)), waitMs = 30000 } = {}) {
const [sub, ...rest] = args;
const usage = why => new CliError(`${why}\n${SESSION_USAGE}`, 4);
if (!['list', 'show', 'create', 'open', 'clear', 'cancel', 'operation'].includes(sub)) throw usage('choose a session verb');
const p = parseArgs(rest, SESSION_USAGE, { flags: ['--json', '--yes'], values: ['--business', '--op'] });
if (p.positional.length !== (sub === 'list' ? 1 : 2)) throw usage('wrong number of session arguments');
if (p.flags.has('--yes') && !['clear', 'cancel'].includes(sub) || p.values['--op'] !== undefined && !['create', 'clear'].includes(sub)) throw usage('option does not apply to this verb');
if (['create', 'clear'].includes(sub) && !KEY.test(p.values['--op'] ?? '')) throw usage('--op needs a reusable operation key');
if (sub === 'clear' && !p.flags.has('--yes')) throw usage('clear needs --yes: it ends this context, preserves historical work and uses a fresh approved checkout');
if (sub === 'cancel' && !p.flags.has('--yes')) throw usage('cancel needs --yes: it stops replacement before retirement, or stops successor startup after retirement; nothing is un-retired or replayed');
if (['operation', 'cancel'].includes(sub) && !KEY.test(p.positional[1])) throw usage('invalid operation key');
const call = callFor(p), thread = await resolveThread(call, p.positional[0]);
let result;
if (sub === 'create') result = await operation(call, 'session.create', { thread: thread.id, name: p.positional[1], op: p.values['--op'] });
else if (sub === 'cancel') result = receipt(await operation(call, 'session.cancel', { thread: thread.id, op: p.positional[1], confirm: true }), thread.id, p.positional[1]);
else if (sub === 'operation') result = receipt(await operation(call, 'session.operation', { thread: thread.id, op: p.positional[1] }), thread.id, p.positional[1]);
else {
const rows = await call('session.list', { thread: thread.id });
if (sub === 'list') result = rows;
else {
const selected = selectSession(rows, p.positional[1]);
if (sub === 'show') result = selected;
else if (sub === 'open') result = await call('thread.open', { thread: thread.id, session: selected.id });
else {
if (!Number.isSafeInteger(selected.revision)) throw new CliError('clear unavailable: the safe-clear dependency is not installed', 3);
// Print recovery coordinates before the write, including when its answer is lost.
io.stderr.write(`clear operation ${p.values['--op']}; check mosaic session operation ${thread.id} ${p.values['--op']}\n`);
const op = p.values['--op'];
// Repeated CLI invocation resumes the recorded operation even after revision changes.
// Only a definitive not-found permits the first write. Transport uncertainty refuses.
try { result = receipt(await operation(call, 'session.operation', { thread: thread.id, op }), thread.id, op, selected.id); }
catch (e) { if (e.code !== 'operation-not-found') throw e; }
if (!result) result = receipt(await operation(call, 'session.clear', { thread: thread.id, session: selected.id, revision: selected.revision, confirm: true, op }), thread.id, op, selected.id);
const until = now() + waitMs;
while (!terminal(result) && now() < until) {
await sleep(Math.min(1000, Math.max(0, until - now())));
result = receipt(await operation(call, 'session.operation', { thread: thread.id, op }), thread.id, op, selected.id);
}
}
}
}
if (sub === 'cancel' && result.state !== 'cancelled') throw new CliError('outcome unknown: query the operation; cancel was not retried', 1);
if (p.flags.has('--json')) return json(io, result);
if (sub === 'list' || sub === 'show') {
const rows = sub === 'list' ? result : [result];
return io.stdout.write(rows.length ? rows.map(r => `${r.id} ${r.name}${r.default ? ' (default)' : ''} ${r.state ?? 'status unavailable'} Compactions: ${compactionLabel(r.compactions)}\n`).join('') : 'no sessions\n');
}
if (sub === 'create') return io.stdout.write(`created session ${result.id}; no run started\n`);
if (sub === 'open') return io.stdout.write(`resume requested for ${p.positional[1]} in ${thread.label}\n`);
const cancellation = result.state !== 'cancelled' ? '' : result.retired === true ? '; startup cancelled; old session remains retired' : '; replacement cancelled; old session active; cancelled inputs not replayed';
io.stdout.write(`operation ${result.op}: ${result.state}${terminal(result) ? '' : ' (pending; no stop forced)'}${cancellation}; successor ${result.successor ?? 'pending'}; start ${result.start ?? 'pending'}${result.refusal ? `; ${result.refusal}` : ''}\n`);
}
+12 -11
View File
@@ -14,12 +14,12 @@ import { rolesDir } from "./config.mjs";
export const THREADS_USAGE = "mosaic threads [--project <project>] [--business <id>] [--json] [--tester]";
export const THREAD_USAGE = [
"mosaic thread create <project> <name> [--business <id>] [--tester]",
"mosaic thread open <thread> [--business <id>] [--tester]",
"mosaic thread read <thread> [--business <id>] [--json] [--tester]",
"mosaic thread open <thread> [--session <id>] [--business <id>] [--tester]",
"mosaic thread read <thread> [--session <id>] [--business <id>] [--json] [--tester]",
].join("\n ");
export const THREAD_TALK_USAGE = [
"mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]",
"mosaic follow --thread <thread> [--business <id>] [--tester]",
"mosaic talk --thread <thread> <text> [--session <id>] [--wait <seconds> | --follow] [--business <id>] [--tester]",
"mosaic follow --thread <thread> [--session <id>] [--business <id>] [--tester]",
].join("\n ");
// The parse shape callFor reads (flags, values, positional). Errors carry the verb's own usage.
@@ -79,10 +79,10 @@ const PAGE = 200;
// The thread's messages after `after`, oldest first, from every page of thread.messages.
// Reading writes no delivery or read row.
export async function threadMessages(call, thread, after) {
export async function threadMessages(call, thread, after, session) {
const out = [];
for (;;) {
const page = await call("thread.messages", { thread, limit: PAGE, ...(after ? { after } : {}) });
const page = await call("thread.messages", { thread, ...(session ? { session } : {}), limit: PAGE, ...(after ? { after } : {}) });
out.push(...page);
if (page.length < PAGE) return out;
after = page.at(-1).id;
@@ -93,13 +93,13 @@ export async function threadMessages(call, thread, after) {
// does not say which thread a message is in, so `--follow` receives first and asks the thread
// second; the broker records a message's thread when it stores the message, so a message it
// received is already listed.
export function threadMembers(call, thread) {
export function threadMembers(call, thread, session) {
const seqs = new Map();
let last;
return {
seq: (id) => seqs.get(id),
async refresh() {
for (const m of await threadMessages(call, thread, last)) {
for (const m of await threadMessages(call, thread, last, session)) {
seqs.set(m.id, m.seq);
last = m.id;
}
@@ -127,7 +127,8 @@ export async function threadsCommand(args, io, callFor) {
export async function threadCommand(args, io, callFor, format) {
const [sub, ...rest] = args;
if (!["create", "open", "read"].includes(sub)) throw new CliError(`usage:\n ${THREAD_USAGE}`, 4);
const parsed = parseArgs(rest, THREAD_USAGE, { flags: sub === "read" ? ["--json", "--tester"] : ["--tester"], values: ["--business"] });
const parsed = parseArgs(rest, THREAD_USAGE, { flags: sub === "read" ? ["--json", "--tester"] : ["--tester"], values: ["--business", ...(sub === "create" ? [] : ["--session"])] });
if (parsed.values["--session"] !== undefined && !UUID.test(parsed.values["--session"])) throw new CliError("--session needs a full session id", 4);
if (parsed.positional.length !== (sub === "create" ? 2 : 1)) {
throw new CliError(`thread ${sub} takes ${sub === "create" ? "a project and a name" : "one thread"}\n${THREAD_USAGE}`, 4);
}
@@ -140,10 +141,10 @@ export async function threadCommand(args, io, callFor, format) {
}
const thread = await resolveThread(call, parsed.positional[0]);
if (sub === "open") {
await call("thread.open", { thread: thread.id });
await call("thread.open", { thread: thread.id, ...(parsed.values["--session"] ? { session: parsed.values["--session"] } : {}) });
return io.stdout.write(`opened ${thread.label}\n`);
}
const messages = await threadMessages(call, thread.id);
const messages = await threadMessages(call, thread.id, undefined, parsed.values["--session"]);
if (parsed.flags.has("--json")) return json(io, messages);
io.stdout.write(messages.length === 0 ? `${thread.label}: no messages\n` : messages.map(format).join(""));
}
+201
View File
@@ -0,0 +1,201 @@
// Row 272 / 264e: user-facing sessions use the same human channel as threads.
import test from 'node:test';
import assert from 'node:assert/strict';
import { run } from '../src/cli.mjs';
import { io } from './helpers.mjs';
const T = '10000000-0000-4000-8000-000000000001';
const A = '20000000-0000-4000-8000-000000000002';
const B = '30000000-0000-4000-8000-000000000003';
function setup() {
const calls = [], rows = [{ id: A, name: 'default', default: true }, { id: B, name: 'second', default: false }];
const transport = () => async (verb, args) => {
if (verb === 'plugins') return { plugins: { channel: ['cli'] } };
calls.push([verb, args]);
if (verb === 'thread.list') return [{ id: T, project: A, name: 'thread' }];
if (verb === 'project.list') return [{ id: A, name: 'project' }];
if (verb === 'session.list') return rows;
if (verb === 'session.create') return { id: B, thread: T, name: args.name };
if (verb === 'thread.open') return { opened: T };
throw Object.assign(new Error('unknown-verb'), { code: 'unknown-verb' });
};
const cli = async args => {
const x = io(); const code = await run(['session', ...args, '--business', 'demo'], x, { system: { dataRoot: '/unused' }, transport });
return { code, out: x.out.text, err: x.err.text };
};
return { calls, rows, cli };
}
test('session list/show/open select one session without changing the saved default', async () => {
const s = setup();
const listed = await s.cli(['list', T, '--json']);
assert.equal(listed.code, 0, listed.err);
assert.deepEqual(JSON.parse(listed.out), s.rows);
const shown = await s.cli(['show', T, B, '--json']);
assert.equal(shown.code, 0, shown.err);
assert.equal(JSON.parse(shown.out).id, B);
const opened = await s.cli(['open', T, B, '--json']);
assert.equal(opened.code, 0, opened.err);
assert.deepEqual(s.calls.at(-1), ['thread.open', { thread: T, session: B }]);
assert.equal(s.rows[0].default, true);
});
test('create requires a reusable operation key and returns the broker identity', async () => {
const s = setup();
assert.equal((await s.cli(['create', T, 'second'])).code, 4);
assert.ok(!s.calls.some(([v]) => v === 'session.create'));
const made = await s.cli(['create', T, 'second', '--op', 'create-second', '--json']);
assert.equal(made.code, 0, made.err);
assert.equal(JSON.parse(made.out).id, B);
assert.deepEqual(s.calls.at(-1), ['session.create', { thread: T, name: 'second', op: 'create-second' }]);
});
test('invalid/foreign session selection never falls back to the default or opens a session', async () => {
const s = setup();
for (const ref of ['missing', T, '200']) assert.notEqual((await s.cli(['open', T, ref])).code, 0);
assert.ok(!s.calls.some(([v]) => v === 'thread.open'));
assert.equal((await s.cli(['list', T, '--session', A])).code, 4);
});
test('unsupported clear refuses with a reason and never opens or creates a replacement', async () => {
const s = setup();
const result = await s.cli(['clear', T, B, '--yes', '--op', 'clear-second']);
assert.notEqual(result.code, 0);
assert.match(result.err, /unavailable|not available|not implemented/);
assert.ok(!s.calls.some(([v]) => ['session.create', 'thread.open'].includes(v)));
});
test('clear waits by operation reads only, keeps revision, then reports pending at 30s', async () => {
const { sessionCommand } = await import('../src/session.mjs');
const x = io(), seen = [];
let clock = 0, accepted = false;
const pending = { op:'clear-one', thread:T, session:B, successor:A, state:'uncertain', start:null, refusal:null, at:null };
const call = async (verb, args) => {
seen.push([verb, args]);
if (verb === 'thread.list') return [{id:T,project:A,name:'thread'}];
if (verb === 'project.list') return [{id:A,name:'project'}];
if (verb === 'session.list') return [{id:B,name:'second',revision:3}];
if (verb === 'session.clear') { accepted=true; return pending; }
if (verb === 'session.operation') { if (!accepted) throw Object.assign(new Error('operation-not-found'),{code:'operation-not-found'}); return pending; }
assert.fail(verb);
};
await sessionCommand(['clear',T,B,'--yes','--op','clear-one'], x, () => call, { now:()=>clock, sleep:async ms=>{clock+=ms;} });
assert.equal(clock, 30000);
assert.equal(seen.filter(([v])=>v==='session.clear').length, 1);
assert.deepEqual(seen.find(([v])=>v==='session.clear')[1], {thread:T,session:B,revision:3,confirm:true,op:'clear-one'});
assert.match(x.out.text, /uncertain.*pending/);
assert.match(x.err.text, /session operation.*clear-one/);
assert.ok(seen.filter(([v])=>v==='session.operation').every(([,a])=>a.thread===T && a.op==='clear-one'));
});
test('count output distinguishes exact zero, lower bounds and unavailable collection', async () => {
const { compactionLabel } = await import('../src/session.mjs');
assert.equal(compactionLabel({known:true,count:0,reason:null}), '0');
assert.match(compactionLabel({known:false,count:0,reason:'harness-unsupported'}), /^unknown.*harness-unsupported/);
assert.match(compactionLabel(undefined), /^unavailable/);
assert.match(compactionLabel({known:true,count:-1}), /^unknown/);
});
test('session CLI real broker: create/list/open keeps default and selectors isolated', async t => {
const { Broker } = await import('../../bus/src/broker.mjs');
const { Store } = await import('../../bus/src/store.mjs');
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const root = mkdtempSync(join(tmpdir(),'mosaic-session-cli-'));
const store = new Store(root); t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
const b = new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief:{definition:'chief-of-staff'},assistant:{definition:'assistant'}},definitions:{assistant:{},'chief-of-staff':{}}}}});
const cap = b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const call = async (verb,args={}) => verb === 'plugins' ? {plugins:{channel:['cli']}} : b.request(cap,{verb,args});
const project = (await call('project.create',{name:'work'})).id;
const thread = (await call('thread.create',{project,name:'one'})).id;
const command = async args => { const x=io();const code=await run(['session',...args,'--business','demo'],x,{system:{dataRoot:root},transport:()=>call});assert.equal(code,0,x.err.text);return x.out.text; };
const [defaultSession] = JSON.parse(await command(['list',thread,'--json']));
const second = JSON.parse(await command(['create',thread,'second','--op','new-second','--json']));
assert.notEqual(second.id, defaultSession.id);
await command(['open',thread,second.id]);
assert.equal(store.get('SELECT session FROM thread_opens ORDER BY seq DESC LIMIT 1').session, second.id);
assert.equal(JSON.parse(await command(['list',thread,'--json'])).find(r=>r.default).id, defaultSession.id);
});
test('explicit --session on thread reads, opens and talk never drops the selected id', async () => {
const B = '30000000-0000-8000-8000-000000000003'; // clear successor v8; thread T remains v4
const seen = [];
const transport = () => async (verb,args={}) => {
if (verb === 'plugins') return {plugins:{channel:['cli']}};
seen.push([verb,args]);
if (verb === 'thread.list') return [{id:T,project:A,name:'thread'}];
if (verb === 'project.list') return [{id:A,name:'project'}];
if (verb === 'thread.messages') return [];
if (verb === 'thread.open') return {opened:T};
if (verb === 'thread.send') return {id:A};
assert.fail(verb);
};
for (const args of [['thread','read',T,'--session',B], ['thread','open',T,'--session',B], ['talk','--thread',T,'hello','--session',B,'--wait','0']]) {
const x=io();assert.equal(await run([...args,'--business','demo'],x,{system:{dataRoot:'/unused'},transport}),0,x.err.text);
}
assert.ok(seen.filter(([v])=>['thread.messages','thread.open','thread.send'].includes(v)).every(([,a])=>a.session===B));
assert.equal(seen.filter(([v])=>v==='thread.send').length,1);
const x=io();assert.equal(await run(['talk','assistant','hello','--session',B],x,{system:{dataRoot:'/unused'},transport}),4);
});
test('repeating clear with a recorded operation queries it and never sends a new revision', async () => {
const { sessionCommand } = await import('../src/session.mjs');
const x=io(), seen=[];
const call=async (verb,args)=>{
seen.push(verb);
if(verb==='thread.list')return [{id:T,project:A,name:'thread'}];
if(verb==='project.list')return [{id:A,name:'project'}];
if(verb==='session.list')return [{id:B,name:'second',revision:9,state:'retired'}];
if(verb==='session.operation')return {op:'old-op',thread:T,session:B,successor:A,state:'retired',start:'held'};
assert.fail(`unexpected write ${verb}`);
};
await sessionCommand(['clear',T,B,'--yes','--op','old-op','--json'],x,()=>call);
assert.equal(JSON.parse(x.out.text).state,'retired');
assert.equal(seen.filter(v=>v==='session.clear').length,0);
});
test('new CLI surface refuses forged agent identity before reaching transport', async () => {
const x=io();x.env.MOSAIC_RUN_ID='worker';let calls=0;
const code=await run(['session','list',T,'--business','demo'],x,{system:{dataRoot:'/unused'},transport:()=>{calls++;return async()=>[];}});
assert.equal(code,3);assert.equal(calls,0);assert.match(x.err.text,/outside any agent/);
});
test('cancel requires confirmation, uses the clear op and accepts cancelled before or after retirement', async () => {
const { sessionCommand } = await import('../src/session.mjs');
for (const retired of [null, true]) {
const seen=[], x=io();
const envelope={op:'clear-one',thread:T,session:B,successor:A,state:'cancelled',start:retired ? 'held' : null,retired};
const call=async (verb,args)=>{
seen.push([verb,args]);
if(verb==='thread.list')return [{id:T,project:A,name:'thread'}];
if(verb==='project.list')return [{id:A,name:'project'}];
if(verb==='session.cancel')return envelope;
assert.fail(verb);
};
await assert.rejects(sessionCommand(['cancel',T,'clear-one'],x,()=>call),e=>e.exitCode===4);
assert.deepEqual(seen,[]);
await sessionCommand(['cancel',T,'clear-one','--yes','--json'],x,()=>call);
assert.deepEqual(JSON.parse(x.out.text),envelope);
assert.deepEqual(seen.at(-1),['session.cancel',{thread:T,op:'clear-one',confirm:true}]);
assert.ok(!seen.some(([v])=>['session.create','session.clear','thread.open'].includes(v)));
}
});
test('cancelled operation is terminal: clear polling stops without another write', async () => {
const { sessionCommand } = await import('../src/session.mjs');
const x=io();let slept=false;
const call=async verb=>{
if(verb==='thread.list')return [{id:T,project:A,name:'thread'}];
if(verb==='project.list')return [{id:A,name:'project'}];
if(verb==='session.list')return [{id:B,name:'second',revision:3}];
if(verb==='session.operation')return {op:'cancelled-op',thread:T,session:B,successor:A,state:'cancelled',retired:null,start:null};
assert.fail(`unexpected write ${verb}`);
};
await sessionCommand(['clear',T,B,'--op','cancelled-op','--yes','--json'],x,()=>call,{sleep:async()=>{slept=true;}});
assert.equal(JSON.parse(x.out.text).state,'cancelled');assert.equal(slept,false);
});
test('routed message cancellation prints its stored receipt and never waits for a reply', async () => {
for(const cancelled of ['session-clearing','session-retired']) {
const x=io(), seen=[];
const transport=()=>async(verb,args)=>{seen.push(verb);assert.equal(verb,'message.send');return {id:A,cancelled};};
const code=await run(['talk','assistant','reply','--business','demo'],x,{system:{dataRoot:'/unused'},transport});
assert.equal(code,3);assert.match(x.out.text,new RegExp(A));assert.match(x.err.text,new RegExp(cancelled));
assert.deepEqual(seen,['message.send']);
}
});
+5
View File
@@ -9,6 +9,11 @@ const VERBS = new Set([
"thread.messages",
"thread.send",
"thread.open",
"session.list",
"session.create",
"session.clear",
"session.cancel",
"session.operation",
"message.receive",
"message.read",
]);
+5
View File
@@ -9,6 +9,11 @@ const VERBS = new Set([
"thread.messages",
"thread.send",
"thread.open",
"session.list",
"session.create",
"session.clear",
"session.cancel",
"session.operation",
]);
export async function dispatch({ verb, args }, { call }) {
+2 -2
View File
@@ -26,8 +26,8 @@ test("channel shipped modules load and preserve every supported operation and bu
assert.deepEqual(loader.loaded().channel, ["cli", "webui"]);
const modules = loader.modules("channel");
for (const [index, verbs] of [
[0, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open", "message.receive", "message.read"]],
[1, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open"]],
[0, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open", "session.list", "session.create", "session.clear", "session.cancel", "session.operation", "message.receive", "message.read"]],
[1, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open", "session.list", "session.create", "session.clear", "session.cancel", "session.operation"]],
]) {
for (const verb of verbs) {
const seen = [];
+45 -6
View File
@@ -16,9 +16,14 @@ const STATUS = {
'project-exists': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403,
'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503,
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
'session-not-found': 404, 'operation-not-found': 404, 'session-exists': 409,
'session-op-conflict': 409, 'session-clear-complete': 409, 'session-name-too-long': 409, 'session-clearing': 409, 'session-revision-stale': 409,
'session-retired': 409, 'processing-halted': 409, 'confirm-required': 400,
'unknown-verb': 503, 'channel-operation': 503, 'access-refused': 403,
'tester-off': 403, 'tester-required': 403, 'tester-refused': 403,
};
function safeMessage(code) {
if (code === 'unknown-verb' || code === 'channel-operation') return 'This session operation is unavailable until its dependency is installed.';
if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
if (code === 'thread-not-owned') return 'This thread is not available to this user.';
if (code === 'outcome-unknown') return 'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried.';
@@ -65,11 +70,12 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
}
try {
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open)$/.exec(path);
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open|sessions|clear|cancel|operation)$/.exec(path);
if (match) {
thread = match[1];
if (!id(thread)) throw Error();
}
if (write && preview?.tester && ['sessions', 'clear', 'cancel'].includes(match?.[2])) return refuse(403, 'read-only');
if (write) {
query(search, []);
const v = await payload(req);
@@ -80,12 +86,22 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
} else if (path === '/api/chat/threads') {
if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
action = 'createThread'; args = v;
} else if (match?.[2] === 'sessions') {
if (!exact(v, ['name', 'op']) || !name(v.name) || !KEY.test(v.op ?? '')) throw Error();
action = 'createSession'; args = { thread, name: v.name, op: v.op };
} else if (match?.[2] === 'cancel') {
if (!exact(v, ['op', 'confirm']) || !KEY.test(v.op ?? '') || v.confirm !== true) throw Error();
action = 'cancelSession'; args = { thread, op: v.op, confirm: true };
} else if (match?.[2] === 'clear') {
if (!exact(v, ['session', 'revision', 'confirm', 'op']) || !id(v.session) ||
!Number.isSafeInteger(v.revision) || v.revision < 0 || v.confirm !== true || !KEY.test(v.op ?? '')) throw Error();
action = 'clearSession'; args = { thread, ...v };
} else if (match?.[2] === 'messages') {
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
action = 'send'; args = { thread, body: v.body };
if (!exact(v, ['body', 'session'], ['body']) || (Object.hasOwn(v, 'session') && !id(v.session)) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
action = 'send'; args = { thread, body: v.body, ...(v.session ? { session: v.session } : {}) };
} else if (match?.[2] === 'open') {
if (!exact(v, [])) throw Error();
action = 'open'; args = { thread };
if (!exact(v, ['session'], []) || (Object.hasOwn(v, 'session') && !id(v.session))) throw Error();
action = 'open'; args = { thread, ...(v.session ? { session: v.session } : {}) };
} else return refuse(404, 'not-found');
} else if (path === '/api/chat/projects') {
query(search, []); action = 'projects'; args = {};
@@ -93,11 +109,19 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
const q = query(search, ['project']);
if (q.has('project') && !id(q.get('project'))) throw Error();
action = 'threads'; args = q.has('project') ? { project: q.get('project') } : {};
} else if (match?.[2] === 'sessions') {
query(search, []); action = 'sessions'; args = { thread };
} else if (match?.[2] === 'operation') {
const q = query(search, ['op']);
if (!q.has('op') || !KEY.test(q.get('op'))) throw Error();
action = 'sessionOperation'; args = { thread, op: q.get('op') };
} else if (match?.[2] === 'messages') {
const q = query(search, ['after', 'limit']);
const q = query(search, ['after', 'limit', 'session']);
if (q.has('session') && !id(q.get('session'))) throw Error();
if (q.has('after') && !id(q.get('after'))) throw Error();
if (q.has('limit') && (!/^[1-9]\d*$/.test(q.get('limit')) || Number(q.get('limit')) > 200)) throw Error();
action = 'messages'; args = { thread, limit: q.has('limit') ? Number(q.get('limit')) : 100 };
if (q.has('session')) args.session = q.get('session');
if (q.has('after')) args.after = q.get('after');
} else return refuse(404, 'not-found');
} catch { return refuse(400, 'invalid-request'); }
@@ -105,6 +129,21 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
const line = outcome => log(`${new Date().toISOString()} chat action=${action} user=${JSON.stringify(preview?.user ?? null)} ${outcome}`);
try {
const value = await chat[action](args);
if (action === 'sessionOperation' || action === 'clearSession' || action === 'cancelSession') {
// Only this operation's receipt crosses the HTTP seam. Never expose native paths/proofs.
if (!value || value.thread !== args.thread || value.op !== args.op || !id(value.session) ||
!id(value.successor) || (action === 'clearSession' && value.session !== args.session) ||
!['clearing', 'uncertain', 'retired', 'failed', 'cancelled'].includes(value.state) ||
![null, 'held', 'started', 'refused'].includes(value.start) ||
(value.state === 'cancelled' && value.retired !== null && value.retired !== true) ||
(action === 'cancelSession' && value.state !== 'cancelled') ||
!(value.refusal === null || typeof value.refusal === 'string' && /^[a-z-]{1,64}$/.test(value.refusal))) {
return refuse(write ? 503 : 502, write ? 'outcome-unknown' : 'invalid-response', write ? 'unknown' : false);
}
if (write) line(`op=${args.op}`);
const { op, thread, session, successor, state, start, refusal, at } = value;
return json(res, 200, { op, thread, session, successor, state, start, refusal, at, ...(state === 'cancelled' ? { retired: value.retired } : {}) });
}
if (!write) {
if (!Array.isArray(value)) return refuse(502, 'invalid-response');
const page = action === 'messages' ? {
+8 -3
View File
@@ -21,8 +21,13 @@ export function createChat({ call }) {
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
createThread: ({ project, name }) => channel('thread.create', { project, name }),
messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }),
send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }),
open: ({ thread }) => channel('thread.open', { thread }),
sessions: ({ thread }) => channel('session.list', { thread }),
createSession: ({ thread, name, op }) => channel('session.create', { thread, name, op }),
clearSession: ({ thread, session, revision, confirm, op }) => channel('session.clear', { thread, session, revision, confirm, op }),
cancelSession: ({ thread, op, confirm }) => channel('session.cancel', { thread, op, confirm }),
sessionOperation: ({ thread, op }) => channel('session.operation', { thread, op }),
messages: ({ thread, session, after, limit }) => channel('thread.messages', { thread, ...(session ? { session } : {}), ...(after ? { after } : {}), limit }),
send: ({ thread, session, body }) => channel('thread.send', { thread, ...(session ? { session } : {}), body, class: 'REQUEST' }),
open: ({ thread, session }) => channel('thread.open', { thread, ...(session ? { session } : {}) }),
});
}
+6
View File
@@ -17,3 +17,9 @@
.chat-paging { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }
#chat-receipt { min-height: 1.5em; overflow-wrap: anywhere; }
@media (max-width: 40rem) { .chat-log { max-height: 48vh; padding: .5rem; } }
/* Logical sessions stay inside the selected thread, separate from machine seats. */
.chat-session-controls { border: 1px solid var(--border, #445); border-radius: .5rem; padding: .8rem; margin: .8rem 0; }
.chat-session-controls label { display: block; }
.chat-session-controls select, .chat-session-controls input { max-width: 100%; }
.chat-session-controls form { margin: .6rem 0; }
+137 -28
View File
@@ -46,13 +46,15 @@
try { data = await res.json(); }
catch { throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown'); }
if (!res.ok) throw new ChatError(typeof data?.error === 'string' ? data.error : 'invalid-response', res.status, data?.sent);
if (!data || typeof data !== 'object' || (value === undefined && !Array.isArray(data.rows))) {
if (!data || typeof data !== 'object' || (value === undefined && !path.includes('/operation?') && !Array.isArray(data.rows))) {
throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown');
}
return data;
}
const link = (type, id, label) => UUID.test(id) ? `<a href="#/chat/${type}/${id}">${txt(label)}</a>` : txt(label);
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
const selectedInactive = () => { const s = current?.sessions?.find(r => r.id === current.session); return !!s?.state && s.state !== 'active'; };
const disabledSession = pending => disabled(pending || selectedInactive());
const disabled = pending => !current.canSend || out() || pending ? ' disabled' : '';
const stamp = at => `<time>${txt(at)}</time>`;
function creation(kind, key) {
@@ -63,11 +65,21 @@
function threads(rows) {
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
}
const sessionPath = (thread, session) => `#/chat/threads/${thread}/sessions/${session}`;
const countLabel = value => !value ? 'unavailable (count collector not installed)' :
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
`unknown (${value.reason ?? 'not-collected'})`;
function sessionControls(t) {
const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id);
const clear = form('clear:' + c.key);
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label><button class="btn" type="submit"${disabled(f.pending)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
}
function conversation(t) {
const s = state(t.id);
const s = state(current.key);
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div><div class="chat-body">${txt(m.body)}</div></li>`).join('');
const receipt = s.receipt;
return `<section class="chat-conversation" data-thread="${esc(t.id)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabled(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabled(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabled(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${sessionControls(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
}
function capture() {
if (!current) return null;
@@ -82,7 +94,7 @@
if (c.error) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
if (c.kind === 'thread') {
const t = c.threads.find(t => t.id === c.id);
if (t && !c.refused) html += conversation(t);
if (t && c.session && !c.refused) html += conversation(t);
else html += heading('Thread unavailable');
} else if (c.kind === 'project') {
const p = c.projects.find(p => p.id === c.id);
@@ -97,7 +109,7 @@
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
const log = c.container.querySelector('.chat-log');
if (log) {
const s = state(c.id);
const s = state(c.key ?? c.id);
if (navigated || s.tailRequested || s.follow && (keep?.tail ?? true)) log.scrollTop = log.scrollHeight;
else if (keep?.top !== undefined) log.scrollTop = keep.top;
s.tailRequested = false;
@@ -106,38 +118,63 @@
const f = document.getElementById(keep.field.id);
if (f && !f.disabled) {
f.focus({ preventScroll: true });
if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
}
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
}
function readError(err) {
return err.code === 'thread-not-owned' ? 'This thread is not available to this user.' :
err.code === 'thread-not-found' ? 'Thread not found.' : 'Chat could not be read. Refresh to try again; your draft is kept.';
err.code === 'thread-not-found' ? 'Thread not found.' :
err.code === 'session-not-found' ? 'Session not found in this thread.' : 'Chat could not be read. Refresh to try again; your draft is kept.';
}
async function render({ path, container, canSend, navigated = false, next = false }) {
if (out()) return;
const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+))?$/.exec(path);
const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+)(?:\/sessions\/([0-9a-f-]+))?)?$/.exec(path);
controller?.abort(); controller = new AbortController();
const signal = controller.signal, g = ++generation;
const kind = match?.[1] === 'projects' ? 'project' : match?.[1] === 'threads' ? 'thread' : 'list';
const id = match?.[2];
if (!match || (id && !UUID.test(id))) {
if (!match || (id && !UUID.test(id)) || match?.[3] && (kind !== 'thread' || !UUID.test(match[3]))) {
current = { container, kind: 'list', canSend, projects: [], threads: [], repos: [], error: 'Invalid chat route.', refused: true };
paint(); return;
}
const old = current;
current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false };
current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false, sessions: old?.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old?.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old?.id === id ? old.key : id };
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
try {
const s = kind === 'thread' ? state(id) : null;
const after = s?.loaded && (!s.more || next) ? s.after : null;
const [projects, listed, page] = await Promise.all([
// Resolve the explicit session before touching history/cursors. Missing selectors never
// silently fall back to a sibling. The saved default is used only for thread-only routes.
const [projects, listed, catalog] = await Promise.all([
request('projects', undefined, signal),
request('threads' + (kind === 'project' ? '?project=' + id : ''), undefined, signal),
s ? request(`threads/${id}/messages?limit=100${after ? '&after=' + after : ''}`, undefined, signal) : null,
kind === 'thread' ? request(`threads/${id}/sessions`, undefined, signal) : null,
]);
if (g !== generation || out()) return;
current.projects = projects.rows; current.threads = listed.rows;
let s = null, page = null, after = null;
if (catalog) {
if (catalog.rows.some(r => !UUID.test(r.id) || typeof r.name !== 'string')) throw new ChatError('invalid-response');
const selected = match[3] ? catalog.rows.find(r => r.id === match[3]) : catalog.rows.find(r => r.default === true);
if (!selected) throw new ChatError('session-not-found', 404);
current.sessions = catalog.rows; current.session = selected.id; current.key = id + '/' + selected.id;
s = state(current.key);
after = s.loaded && (!s.more || next) ? s.after : null;
page = await request(`threads/${id}/messages?limit=100&session=${selected.id}${after ? '&after=' + after : ''}`, undefined, signal);
if (g !== generation || out()) return;
const clear = form('clear:' + current.key);
if (clear.op && !clear.pending) {
try {
const op = clear.op;
const receipt = await request(`threads/${id}/operation?op=${encodeURIComponent(op)}`, undefined, signal);
if (g !== generation || out()) return;
if (clear.op !== op) return;
clear.record = receipt; clear.receipt = clearText(receipt);
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError') return;
clear.receipt = `Status unavailable (${inert(err.code)}). Operation ${clear.op}; no write was retried.`;
}
}
}
if (s) {
if (typeof page.hasMore !== 'boolean' || !(page.after === null || UUID.test(page.after))) throw new ChatError('invalid-response');
const rows = after ? [...s.rows, ...page.rows] : page.rows;
@@ -164,7 +201,9 @@
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
current.error = readError(err);
if (err.status === 403 || err.status === 404) {
states.delete(id); current.projects = []; current.threads = []; current.refused = true;
for (const key of states.keys()) if (key === id || key.startsWith(id + '/')) states.delete(key);
for (const key of forms.keys()) if (key.includes(id)) forms.delete(key);
current.projects = []; current.threads = []; current.refused = true;
}
paint(navigated);
}
@@ -177,21 +216,23 @@
}
document.addEventListener('input', event => {
if (!current || out()) return;
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
if (event.target.id === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = document.getElementById('chat-cancel'); if (button) button.disabled = !current.canSend || f.pending || !f.op; }
if (event.target.id === 'chat-body') state(current.key ?? current.id).draft = event.target.value;
const f = event.target.closest('.chat-create');
if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
});
document.addEventListener('scroll', event => {
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
const el = event.target;
state(current.id).follow = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
state(current.key ?? current.id).follow = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}
}, true);
async function write(action, key, value, path) {
if (!current || !current.canSend || out()) return;
const isThread = action === 'send' || action === 'open';
const targetThread = current.id;
const s = isThread ? state(key) : form(key);
if (s.pending) return;
if (s.pending || isThread && selectedInactive()) return;
if (action === 'send' && (!value.body.trim() || new TextEncoder().encode(JSON.stringify(value)).length > 16384)) {
s.receipt = value.body.trim() ? 'Message is too long (16 KiB JSON maximum).' : 'Write a message first.';
paint(); return;
@@ -201,35 +242,46 @@
try {
const result = await request(path, value);
if (out()) return;
if (action === 'open' ? result.opened !== key : !UUID.test(result.id)) throw new ChatError('outcome-unknown');
if (action === 'open' ? result.opened !== targetThread : !UUID.test(result.id)) throw new ChatError('outcome-unknown');
if (action === 'send') {
s.receipt = `Accepted message ${result.id}. Waiting for the conversation to show it.`;
if (s.draft === value.body) s.draft = '';
s.follow = true; s.tailRequested = true; s.receiptId = result.id;
} else if (action === 'open') s.receipt = 'Assistant resume requested.';
else {
s.name = ''; s.repo = '';
if (location.hash === startedRoute) location.hash = '#/chat/' + (action === 'createProject' ? 'projects/' : 'threads/') + result.id;
s.name = ''; s.repo = ''; s.op = null; s.payload = null;
if (location.hash === startedRoute) location.hash = action === 'createSession' ? sessionPath(targetThread, result.id) : '#/chat/' + (action === 'createProject' ? 'projects/' : 'threads/') + result.id;
}
} catch (err) {
if (out() || err.code === 'signed-out') return;
s.receipt = err.sent === false ? `Not sent (${inert(err.code)}). Your draft is kept.` :
'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried; your draft is kept.';
if (err.status === 403 && err.code === 'thread-not-owned') {
if (action === 'createSession' && err.sent === false) { s.op = null; s.payload = null; }
if (err.status === 403 && ['thread-not-owned', 'access-refused'].includes(err.code)) {
states.delete(key);
if (current?.id === key) { current.threads = []; current.refused = true; current.error = readError(err); }
if ((current?.key ?? current?.id) === key) { current.threads = []; current.refused = true; current.error = readError(err); }
}
} finally {
s.pending = false;
if (!out() && current && (isThread ? current.id === key : (key === 'projects' ? current.kind === 'list' : current.id === key))) paint();
if (!out() && current && (isThread ? (current.key ?? current.id) === key : (action === 'createSession' ? current.id === targetThread : key === 'projects' ? current.kind === 'list' : current.id === key))) paint();
}
}
document.addEventListener('submit', event => {
if (!current || !event.target.id.startsWith('chat-')) return;
event.preventDefault();
if (event.target.id === 'chat-send-form') {
const id = current.id;
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
const id = current.id, session = current.session, key = current.key;
void write('send', key, { body: state(key).draft, session }, `threads/${id}/messages`);
} else if (event.target.id === 'chat-session-form') {
const key = 'session:' + current.id, f = form(key);
// Keep an ambiguous create's key and payload; editing requires a deliberate new attempt.
f.op ??= crypto.randomUUID();
f.payload ??= { name: f.name, op: f.op };
void write('createSession', key, f.payload, `threads/${current.id}/sessions`);
} else if (event.target.id === 'chat-operation-form') {
const clear = form('clear:' + current.key);
clear.op = document.getElementById('chat-operation').value;
void checkClear(current.id, current.key, clear);
} else if (event.target.classList.contains('chat-create')) {
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
@@ -238,10 +290,67 @@
});
document.addEventListener('click', event => {
if (!current || out()) return;
if (event.target.id === 'chat-resume') void write('open', current.id, {}, `threads/${current.id}/open`);
if (event.target.id === 'chat-resume') void write('open', current.key, { session: current.session }, `threads/${current.id}/open`);
if (event.target.id === 'chat-cancel') void cancelClear();
if (event.target.id === 'chat-clear') void clearContext();
if (event.target.id === 'chat-next') void refresh(true);
if (event.target.id === 'chat-new') { state(current.id).tailRequested = true; state(current.id).follow = true; paint(); }
if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
});
document.addEventListener('change', event => {
if (event.target.id === 'chat-session' && current && UUID.test(event.target.value)) {
location.hash = sessionPath(current.id, event.target.value);
}
});
const clearText = r => r.state === 'cancelled' ? `Operation ${r.op}: ${r.retired === true ? 'startup cancelled; old session remains retired' : 'replacement cancelled; old session active'}. Cancelled inputs are not replayed.` : `Operation ${r.op}: ${r.state}; successor ${r.successor}; start ${r.start ?? 'pending'}${r.refusal ? '; ' + r.refusal : ''}. No stop is forced.`;
async function checkClear(thread, key, f) {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) { f.receipt = 'Enter a valid operation key.'; paint(); return; }
const op = f.op;
try {
const r = await request(`threads/${thread}/operation?op=${encodeURIComponent(op)}`);
if (out() || f.op !== op) return;
f.record = r; f.receipt = clearText(r);
if (current?.key === key) await refresh();
} catch (e) { if (!out()) f.receipt = `Status unavailable (${inert(e.code)}). Operation ${f.op}; no write was retried.`; }
if (current?.key === key) paint();
}
async function cancelClear() {
const c = current, key = c.key, f = form('clear:' + key);
if (!c.canSend || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return;
if (!window.confirm(`Cancel clear operation ${f.op}? Before retirement this restores the old session to active. After retirement it only cancels successor startup. Cancelled messages are never replayed, and nothing is un-retired.`)) return;
const op = f.op;
f.pending = true; paint();
try {
const r = await request(`threads/${c.id}/cancel`, { op, confirm: true });
if (out()) return;
f.record = r; f.receipt = clearText(r);
} catch (e) {
if (out()) return;
f.receipt = e.sent === false ? `Cancel refused (${inert(e.code)}).` : `Outcome unknown. Check operation ${op}; cancel was not retried.`;
} finally {
f.pending = false;
if (!out() && current?.key === key) { await refresh(); paint(); }
}
}
async function clearContext() {
const c = current, key = c.key, selected = c.sessions.find(r => r.id === c.session), f = form('clear:' + key);
if (!c.canSend || out() || f.pending || selectedInactive() || !Number.isSafeInteger(selected?.revision)) return;
if (!window.confirm(`Clear context for "${selected.name}"? This ends this session after a safe stop and starts fresh context. History and the old checkout (including unpushed work) are retained. The successor uses a fresh approved checkout; old work is not copied. This can wait for safe stop or capacity.`)) return;
// A lost answer retains this key. Only status reads are allowed afterward.
if (f.op && !['cancelled', 'failed'].includes(f.record?.state)) { await checkClear(c.id, key, f); return; }
f.op = crypto.randomUUID(); f.pending = true; f.receipt = `Operation ${f.op}: requesting clear.`; paint();
try {
const r = await request(`threads/${c.id}/clear`, { session: selected.id, revision: selected.revision, confirm: true, op: f.op });
if (out()) return;
f.record = r; f.receipt = clearText(r);
} catch (e) {
if (out()) return;
f.receipt = e.sent === false ? `Clear refused (${inert(e.code)}).` : `Outcome unknown. Check operation ${f.op}; nothing was retried.`;
if (e.sent === false) f.op = null;
} finally {
f.pending = false;
if (!out() && current?.key === key) { await refresh(); paint(); }
}
}
document.addEventListener('mosaic-signed-out', () => { signedOut = true; forget(); });
window.MosaicChat = Object.freeze({ render, leave, forget });
})();
+95 -1
View File
@@ -13,6 +13,7 @@ const stub = `(() => {
const real = fetch.bind(window);
window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[];
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
window.__sessions={};
window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
window.fetch=async (url,init={})=>{
const path=String(url), method=init.method||'GET';
@@ -28,12 +29,15 @@ const stub = `(() => {
if(a.hold)await new Promise(r=>window.__releaseWrite=r);
if(a.reject)throw new TypeError('network');
if(a.redirect)return {type:'opaqueredirect',status:0,ok:false};
if(a.body?.state==='cancelled')window.__operation=a.body;
return new Response(JSON.stringify(a.body),{status:a.status||200});
}
window.__reads.push(path);
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
if(path==='/api/chat/projects')return data({rows:window.__projects});
if(path.endsWith('/sessions')){const t=path.split('/')[4];return data({rows:window.__sessions[t]||[{id:t,name:'default',default:true}]});}
if(path.includes('/operation?'))return data(window.__operation);
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
if(window.__pages.length)return data(window.__pages.shift());
return data({rows:[],after:null,hasMore:false});
@@ -106,7 +110,7 @@ test('chat browser: thread drafts, receipts and cursor history stay isolated; re
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '');
await route(B); assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'beta draft');
assert.doesNotMatch(await b.evaluate('document.querySelector("#chat-receipt").textContent'), /Accepted/);
assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.body)'), [{body:'alpha draft'}]);
assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.body)'), [{body:'alpha draft',session:A}]);
assert.equal(await b.evaluate('window.__writes[0].redirect'), 'manual');
assert.equal(await b.evaluate('window.__writes[0].path'), `/api/chat/threads/${A}/messages`);
await b.evaluate('window.__answers.push({body:{opened:location.hash.split("/").at(-1)}}),true');
@@ -264,3 +268,93 @@ test('chat browser: a late project creation receipt cannot navigate away from an
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
});
test('264e chooser: two sessions keep drafts, cursors, deep links and counts independent', async t => {
const B = '30000000-0000-8000-8000-000000000003'; // mixed v4/v8 logical sessions
const { b, wait, set, click, route, refresh } = await setup(t);
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,compactions:{known:true,count:3,reason:null}}, {id:'${B}',name:'second',default:false,compactions:{known:false,count:0,reason:'harness-unsupported'}}],true`);
await route(A);
assert.match(await b.evaluate('document.querySelector("#chat-compactions").textContent'), /Compactions: 3/);
await set('#chat-body', 'first session draft');
await b.evaluate(`document.querySelector('#chat-session').value='${B}';document.querySelector('#chat-session').dispatchEvent(new Event('change',{bubbles:true}));true`);
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${B}'`);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '');
assert.match(await b.evaluate('document.querySelector("#chat-compactions").textContent'), /unknown.*harness-unsupported/);
await set('#chat-body', 'second session draft');
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${A}',true`);
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${A}'`);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'first session draft');
await refresh();
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'first session draft');
assert.ok((await b.evaluate('window.__reads')).some(p => p.includes(`session=${B}`)));
await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`);
await click('#chat-send-form button[type=submit]');
await wait('window.__writes.length===1');
assert.deepEqual(await b.evaluate('window.__writes[0].body'), {body:'first session draft',session:A});
assert.equal(await b.evaluate('document.querySelector("#chat-clear").disabled'), true);
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${M}',true`);
await wait('document.querySelector("h1")?.textContent === "Thread unavailable"');
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'), false);
assert.equal(await b.evaluate('window.__writes.length'), 1);
});
test('264e clear confirmation and status never resend, force-stop or change selection', async t => {
const { b, wait, click, route, refresh } = await setup(t);
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2},{id:'${B}',name:'second',state:'active',revision:0}];window.confirm=()=>false;true`);
await route(A); await click('#chat-clear');
assert.equal(await b.evaluate('window.__writes.length'), 0);
await b.evaluate(`window.confirm=()=>true;window.__answers.push({body:{op:'placeholder',thread:'${A}',session:'${A}',successor:'${M}',state:'uncertain',start:null,refusal:null,at:null}});true`);
// The fixture echoes the actual idempotency key, as the broker contract does.
await b.evaluate(`(()=>{const real=window.fetch;window.fetch=async(u,i)=>{const r=await real(u,i);if(String(u).endsWith('/clear')){const v=await r.json();v.op=JSON.parse(i.body).op;window.__operation=v;return new Response(JSON.stringify(v));}return r;};return true;})()`);
await click('#chat-clear');
await wait('document.querySelector("#chat-clear-receipt")?.textContent.includes("uncertain")');
const posted = await b.evaluate('window.__writes[0]');
assert.equal(posted.path, `/api/chat/threads/${A}/clear`);
assert.deepEqual(Object.keys(posted.body).sort(), ['confirm','op','revision','session']);
assert.equal(posted.body.session, A); assert.equal(posted.body.revision, 2); assert.equal(posted.body.confirm, true);
await click('#chat-operation-form button');
await wait('window.__reads.some(p=>p.includes("/operation?"))');
await refresh();
assert.equal(await b.evaluate('window.__writes.length'), 1);
assert.equal(await b.evaluate('document.querySelector(".chat-conversation").dataset.session'), A);
});
test('264e new session keeps its operation key after an unknown response and uses no implicit open', async t => {
const {b,wait,set,click,route}=await setup(t);
await route(A); await set('#chat-session-name','new context');
await b.evaluate('window.__answers.push({reject:true}),true');
await click('#chat-session-form button');
await wait('document.querySelector("#chat-session-form [role=status]")?.textContent.includes("Outcome unknown")');
const first=await b.evaluate('window.__writes[0]');
assert.equal(first.path, `/api/chat/threads/${A}/sessions`);
assert.equal(first.body.name,'new context');
assert.match(first.body.op,/^[a-f0-9-]{36}$/);
// A deliberate retry uses the pinned payload; selecting a session never replays this write.
await b.evaluate(`window.__answers.push({body:{id:'${B}'}});window.__sessions['${A}']=[{id:'${A}',name:'default',default:true},{id:'${B}',name:'new context'}];true`);
await click('#chat-session-form button');
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${B}'`);
assert.deepEqual(await b.evaluate('window.__writes[1].body'), first.body);
assert.equal(await b.evaluate('window.__writes.length'),2);
assert.equal(await b.evaluate('location.hash'),`#/chat/threads/${A}/sessions/${B}`);
});
test('264e cancel shows before/after retirement and never starts or replays work', async t => {
const {b,wait,set,click,route}=await setup(t);
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2}];window.confirm=()=>true;true`);
await route(A);
for (const retired of [null,true]) {
const envelope={op:retired?'after-retirement':'before-retirement',thread:A,session:A,successor:M,state:'cancelled',start:retired?'held':null,retired,refusal:null,at:null};
await set('#chat-operation',envelope.op);
await b.evaluate(`window.__operation=${JSON.stringify({...envelope,state:'uncertain'})};window.__answers.push({body:${JSON.stringify(envelope)}});true`);
await click('#chat-cancel');
await b.evaluate(`window.__operation=${JSON.stringify(envelope)};true`);
await wait(`document.querySelector('#chat-clear-receipt')?.textContent.includes('${retired?'startup cancelled':'old session active'}')`);
const last=await b.evaluate('window.__writes.at(-1)');
assert.equal(last.path,`/api/chat/threads/${A}/cancel`);
assert.deepEqual(last.body,{op:envelope.op,confirm:true});
assert.equal(await b.evaluate('document.querySelector("#chat-cancel").disabled'),true);
}
assert.equal(await b.evaluate('window.__writes.length'),2);
assert.equal(await b.evaluate('window.__writes.some(w=>/\\/(open|messages|sessions|clear)$/.test(w.path))'),false);
});
+79
View File
@@ -335,3 +335,82 @@ test('the chat seam names the transport it is given: the human one and the teste
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
});
test('264e session catalog/create and explicit selectors keep the human transport and strict keys', async t => {
const c = await setup(t);
c.state.answer = [{ id: M, name: 'second', default: false }];
const list = await c.get(`/api/chat/threads/${T}/sessions`);
assert.equal(list.status, 200);
assert.deepEqual(c.calls.at(-1), ['session.list', { thread: T }]);
c.state.answer = { id: M, thread: T, name: 'second' };
const made = await c.get(`/api/chat/threads/${T}/sessions`, { method: 'POST', value: { name: 'second', op: 'new-second' } });
assert.equal(made.status, 200);
assert.deepEqual(c.calls.at(-1), ['session.create', { thread: T, name: 'second', op: 'new-second' }]);
c.state.answer = [];
assert.equal((await c.get(`/api/chat/threads/${T}/messages?session=${M}`)).status, 200);
assert.deepEqual(c.calls.at(-1), ['thread.messages', { thread: T, session: M, limit: 100 }]);
c.state.answer = { opened: T };
assert.equal((await c.get(`/api/chat/threads/${T}/open`, { method: 'POST', value: { session: M } })).status, 200);
assert.deepEqual(c.calls.at(-1), ['thread.open', { thread: T, session: M }]);
const before = c.calls.length;
for (const path of [`messages?session=no`, `messages?session=${M}&session=${M}`, `sessions?owner=other`]) {
assert.equal((await c.get(`/api/chat/threads/${T}/${path}`)).status, 400);
}
for (const value of [{name:'x'}, {name:'x',op:'key',owner:'other'}, {name:'x',op:'../escape'}]) {
assert.equal((await c.get(`/api/chat/threads/${T}/sessions`, { method: 'POST', value })).status, 400);
}
assert.equal(c.calls.length, before);
});
test('264e session writes retain Origin/identity fences and explicit selector errors', async t => {
const c = await setup(t);
for (const extra of [{origin:'https://evil.example'}, {'remote-user':'other'}, {'sec-fetch-site':'cross-site'}]) {
assert.ok((await c.get(`/api/chat/threads/${T}/sessions`, { method:'POST', value:{name:'x',op:'new-x'}, extra })).status >= 400);
}
assert.deepEqual(c.calls, []);
c.state.answer = new BusReadError('session-not-found');
const failed = await c.get(`/api/chat/threads/${T}/messages?session=${M}`);
assert.equal(failed.status, 404);
assert.equal(failed.data.error, 'session-not-found');
assert.equal(c.calls.length, 1);
});
test('264e clear envelope is projected, pending is preserved and GET never repeats the clear', async t => {
const c = await setup(t);
const envelope = {op:'clear-one',thread:T,session:M,successor:P,state:'uncertain',start:null,refusal:null,at:null};
c.state.answer = {...envelope, native:'/private/store', proof:'secret'};
const post = await c.get(`/api/chat/threads/${T}/clear`, {method:'POST',value:{session:M,revision:2,confirm:true,op:'clear-one'}});
assert.equal(post.status, 200);
assert.deepEqual(post.data, envelope);
assert.deepEqual(c.calls.at(-1), ['session.clear',{thread:T,session:M,revision:2,confirm:true,op:'clear-one'}]);
const get = await c.get(`/api/chat/threads/${T}/operation?op=clear-one`);
assert.equal(get.status, 200); assert.deepEqual(get.data, envelope);
assert.deepEqual(c.calls.at(-1), ['session.operation',{thread:T,op:'clear-one'}]);
assert.equal(c.calls.filter(([v])=>v==='session.clear').length, 1);
for (const value of [{session:M,revision:2,confirm:false,op:'clear-one'},{session:M,revision:-1,confirm:true,op:'clear-one'},{session:M,revision:2,confirm:true,op:'clear-one',pid:1}]) {
assert.equal((await c.get(`/api/chat/threads/${T}/clear`,{method:'POST',value})).status,400);
}
c.state.answer = new BusReadError('unknown-verb');
const missing = await c.get(`/api/chat/threads/${T}/clear`,{method:'POST',value:{session:M,revision:2,confirm:true,op:'clear-one'}});
assert.equal(missing.status,503);assert.equal(missing.data.sent,false);assert.match(missing.data.message,/unavailable/);
});
test('264e cancel is an authenticated, confirmed human write and retains retirement distinction', async t => {
const c=await setup(t), path=`/api/chat/threads/${T}/cancel`;
for(const retired of [null,true]) {
const envelope={op:'clear-one',thread:T,session:M,successor:P,state:'cancelled',start:retired?'held':null,retired,refusal:null,at:null};
c.state.answer={...envelope,proof:'private'};
const cancelled=await c.get(path,{method:'POST',value:{op:'clear-one',confirm:true}});
assert.equal(cancelled.status,200);assert.deepEqual(cancelled.data,envelope);
assert.deepEqual(c.calls.at(-1),['session.cancel',{thread:T,op:'clear-one',confirm:true}]);
assert.deepEqual((await c.get(`/api/chat/threads/${T}/operation?op=clear-one`)).data,envelope);
}
const before=c.calls.length;
for(const value of [{op:'clear-one'}, {op:'clear-one',confirm:false}, {op:'clear-one',confirm:true,user:'other'}]) assert.equal((await c.get(path,{method:'POST',value})).status,400);
for(const extra of [{origin:'https://evil.example'}, {'remote-user':'other'}, {'sec-fetch-site':'cross-site'}]) assert.ok((await c.get(path,{method:'POST',value:{op:'clear-one',confirm:true},extra})).status>=400);
assert.equal(c.calls.length,before);
for(const code of ['session-clear-complete','session-name-too-long','session-op-conflict']) {
c.state.answer=new BusReadError(code);
const result=await c.get(path,{method:'POST',value:{op:'clear-one',confirm:true}});
assert.equal(result.status,409);assert.equal(result.data.sent,false);assert.equal(result.data.error,code);
}
});