row 272: preserve untested session CLI and Console draft (sage-3145)
This commit is contained in:
1 parent
b23783f395
commit
850b226809
20 files changed
+953
-58
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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(""));
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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' ? {
|
||||
|
||||
@@ -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 } : {}) }),
|
||||
});
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 });
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user